diff --git a/Cargo.lock b/Cargo.lock index 26dbfa62..99a57b72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6050,9 +6050,11 @@ dependencies = [ "nexum-runtime", "nexum-status-body", "nexum-tasks", + "serde", "tempfile", "thiserror 2.0.18", "tokio", + "toml 1.1.2+spec-1.1.0", "tracing", "wasmtime", ] diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 72e243a0..52f92d95 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -281,7 +281,8 @@ const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", /// Apply to an inherent `impl` block whose associated functions are the /// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` /// (all required, from `videre:venue/adapter`), plus an optional `init` -/// (absent means a no-op). Each takes and returns the per-cdylib +/// (absent means a no-op) and an optional `body_versions` (absent +/// declares none). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the /// adapter face and importing exactly the manifest's declared scoped @@ -382,6 +383,23 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { }; let inline_world = &venue_world.wit; + // `body-versions` is a required adapter export; when the adapter + // omits it, declare none. Install asserts the export equals the + // manifest `[venue] body_versions` set. + let body_versions_impl = if defines("body_versions") { + quote! { + fn body_versions() -> ::std::vec::Vec { + <#self_ty>::body_versions() + } + } + } else { + quote! { + fn body_versions() -> ::std::vec::Vec { + ::std::vec::Vec::new() + } + } + }; + // `init` is a required world export; when the adapter omits it the // config is bound but unused, so drop it to stay warning-clean. let init_impl = if defines("init") { @@ -424,6 +442,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { } impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + #body_versions_impl + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result< diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index dfa05f7f..9d097fb0 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,6 +1,7 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, an optional provider kind, and optional event sources. +//! service, an optional provider kind, optional event sources, and +//! optional install predicates over the manifest sections it claims. //! Assembled at the composition root and threaded into every module //! linker. @@ -20,7 +21,7 @@ use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::manifest::NamespaceCaps; +use crate::manifest::{ExtensionSections, NamespaceCaps}; /// One runtime extension. A module that imports an extension interface /// boots only if the linker entry AND the capability namespace are both @@ -50,6 +51,32 @@ pub trait Extension: Send + Sync + 'static { None } + /// Manifest section names this extension claims. A non-core section + /// no wired extension claims is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install, over its opaque manifest sections. + /// Runs before compilation; an `Err` refuses the install fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install, over its own and the loaded + /// providers' opaque manifest sections. Runs before compilation; an + /// `Err` refuses the install fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + /// Manifest subscription kinds this extension's event sources emit. /// A `[[subscription]]` entry of any other non-core kind is refused /// at boot. @@ -151,7 +178,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// One provider instance ready to install: the compiled component, the /// linker the kind's [`ProviderKind::link`] populated, the supervised -/// store, the manifest `[config]`, and the per-call fuel budget. +/// store, the manifest `[config]` and extension sections, and the +/// per-call fuel budget. pub struct ProviderInstance<'a, T: RuntimeTypes> { /// Compiled provider component. pub component: &'a Component, @@ -161,6 +189,9 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub store: Store>, /// Manifest `[config]` handed to the guest `init`. pub config: Vec<(String, String)>, + /// The provider's extension-owned manifest sections, so a kind can + /// hold the instance to its manifest claims at install. + pub sections: &'a ExtensionSections, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, /// Shared liveness the installed instance reports traps on and the @@ -168,6 +199,19 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub liveness: Liveness, } +/// One loaded provider as [`Extension::admit_worker`] sees it: its +/// namespace, registered kind, and opaque manifest sections. Manifest +/// data only, so the predicate is static and liveness-independent. +#[derive(Clone, Debug)] +pub struct ProviderManifest { + /// The provider's namespace: its manifest name. + pub name: String, + /// Registered kind spelling. + pub kind: &'static str, + /// The provider's extension-owned manifest sections. + pub sections: ExtensionSections, +} + /// Outcome of one provider install. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Installed { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 132dd838..a7a70e98 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -232,6 +232,43 @@ scope = 7 assert!(err.to_string().contains("must be a string"), "{err}"); } + /// A non-core top-level section parses into the opaque extension + /// map: the runtime carries it verbatim and ascribes it no meaning. + #[test] + fn load_parses_extension_sections_opaquely() { + let toml = r#" +[module] +name = "keeper" + +[venue] +body_version = 2 + +[[subscription]] +kind = "block" +chain_id = 1 +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "keeper"); + assert_eq!(manifest.subscriptions.len(), 1); + assert_eq!(manifest.extensions.len(), 1); + let venue = manifest.extensions.get("venue").expect("venue section"); + assert_eq!( + venue.get("body_version").and_then(toml::Value::as_integer), + Some(2), + ); + } + + /// A manifest without extension sections carries an empty map. + #[test] + fn load_defaults_to_no_extension_sections() { + let toml = r#" +[module] +name = "plain" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(manifest.extensions.is_empty()); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index e93e179b..da7e0c06 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,6 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; +pub use types::ExtensionSections; pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index c13ca680..f6fbc95c 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -39,8 +39,18 @@ pub struct Manifest { /// parsed and ignored (deferred to 0.3). #[serde(default, rename = "subscription")] pub subscriptions: Vec, + /// Extension-owned sections: every non-core top-level key, parsed + /// opaquely. The runtime ascribes them no meaning; it routes them + /// to the wired extensions' install predicates, and a section no + /// extension claims is refused at boot. + #[serde(flatten)] + pub extensions: ExtensionSections, } +/// Extension-owned manifest sections, keyed by top-level name. Opaque +/// to the runtime; each claiming extension parses its own. +pub type ExtensionSections = BTreeMap; + /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 102dd196..e7c49ecf 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -53,6 +53,7 @@ use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, + ProviderManifest, }; use crate::host::http::HttpGate; #[cfg(test)] @@ -269,6 +270,9 @@ struct LoadedProvider { name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, + /// Extension-owned manifest sections, as the worker install + /// predicates see them. + sections: manifest::ExtensionSections, /// Cached for restart, like a module's. component: Component, /// Cached for restart: the manifest `[config]` handed to `init`. @@ -337,6 +341,27 @@ fn extension_subscription_vocabulary( .collect() } +/// Refuse a manifest section no wired extension claims, so a typo'd +/// section fails loudly instead of silently skipping its extension's +/// install predicate. +fn enforce_extension_sections( + owner: &str, + sections: &manifest::ExtensionSections, + extensions: &[Arc>], +) -> Result<()> { + for key in sections.keys() { + let claimed = extensions + .iter() + .any(|ext| ext.manifest_sections().contains(&key.as_str())); + if !claimed { + return Err(anyhow!( + "{owner} declares manifest section [{key}]; no wired extension claims it" + )); + } + } + Ok(()) +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -385,11 +410,22 @@ impl Supervisor { &provider_registry, clocks.as_ref(), &kinds, + extensions, ) .await .with_context(|| format!("load provider {}", entry.path.display()))?; providers.push(loaded); } + // The loaded providers' manifests, as the worker install + // predicates see them. + let provider_manifests: Vec = providers + .iter() + .map(|p| ProviderManifest { + name: p.name.clone(), + kind: p.kind, + sections: p.sections.clone(), + }) + .collect(); let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); @@ -404,6 +440,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &provider_manifests, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -467,6 +505,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &[], ) .await?; Ok(Self { @@ -579,6 +619,8 @@ impl Supervisor { clocks: Option<&WasiClockOverride>, services: HostServices, extension_kinds: &BTreeSet<&'static str>, + extensions: &[Arc>], + provider_manifests: &[ProviderManifest], ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -594,6 +636,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the worker against the loaded providers' manifests. + let sections = &loaded_manifest.manifest.extensions; + enforce_extension_sections(&module_namespace, sections, extensions)?; + for ext in extensions { + ext.admit_worker(&module_namespace, sections, provider_manifests) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // Compile + instantiate. info!(component = %entry.path.display(), "compiling component"); @@ -608,11 +665,6 @@ impl Supervisor { registry, ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; // Layer the manifest's `[module.resources]` over the engine `[limits]` // defaults: an unset override field keeps the engine default. let ResolvedLimits { @@ -761,6 +813,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, kinds: &ProviderKinds, + extensions: &[Arc>], ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -776,6 +829,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the provider's own sections. + let sections = loaded_manifest.manifest.extensions.clone(); + enforce_extension_sections(&namespace, §ions, extensions)?; + for ext in extensions { + ext.admit_provider(&namespace, §ions) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // The manifest kind is the discriminator: an [[adapters]] entry // must name a registered provider kind, caught here before @@ -822,11 +890,6 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let namespace = if loaded_manifest.manifest.module.name.is_empty() { - "provider".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; info!( provider = %namespace, kind = kind.kind(), @@ -870,6 +933,7 @@ impl Supervisor { linker: &linker, store, config: config.clone(), + sections: §ions, fuel_per_call: limits_cfg.fuel(), liveness: liveness.clone(), }, @@ -883,6 +947,7 @@ impl Supervisor { Ok(LoadedProvider { name: namespace, kind: kind.kind(), + sections, component, init_config: config, http_allow: entry.http_allow.clone(), @@ -1626,6 +1691,7 @@ impl Supervisor { linker: &linker, store, config: provider.init_config.clone(), + sections: &provider.sections, fuel_per_call: provider.fuel_per_call, liveness: provider.liveness.clone(), }, diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6396ffe0..2617869f 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -28,6 +28,41 @@ fn manifest_resource_overrides_take_effect_and_are_field_local() { assert_eq!(resolved.state_bytes, 2048); } +/// A manifest section a wired extension claims passes; an unclaimed one +/// (a typo, or a section for an unwired extension) is refused. +#[test] +fn extension_sections_must_be_claimed() { + struct Claiming; + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + "acme" + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn manifest_sections(&self) -> &'static [&'static str] { + &["venue"] + } + } + let extensions: Vec>> = vec![Arc::new(Claiming)]; + + let mut sections = manifest::ExtensionSections::new(); + sections.insert("venue".into(), toml::Value::Boolean(true)); + enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); + + sections.insert("venu".into(), toml::Value::Boolean(true)); + let err = enforce_extension_sections("keeper", §ions, &extensions) + .expect_err("unclaimed section"); + assert!(err.to_string().contains("[venu]"), "{err}"); + assert!(err.to_string().contains("keeper"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 1a7195b9..25364863 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,7 +2,8 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the five intent functions from `videre:venue/adapter`. +//! world itself, the intent functions and the body-version declaration +//! from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. @@ -21,6 +22,13 @@ pub trait VenueAdapter { /// boots both component kinds through the same machinery. fn init(config: Config) -> Result<(), Fault>; + /// Body-schema versions this adapter decodes. Install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to + /// declaring none. + fn body_versions() -> Vec { + Vec::new() + } + /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to @@ -70,6 +78,10 @@ macro_rules! export_venue_adapter { impl $crate::bindings::exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + fn body_versions() -> ::std::vec::Vec { + <$adapter as $crate::VenueAdapter>::body_versions() + } + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index a2d9ae30..8db90362 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -21,7 +21,9 @@ anyhow.workspace = true thiserror.workspace = true async-trait.workspace = true futures.workspace = true +serde.workspace = true tokio.workspace = true +toml.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..a1aa23d7 --- /dev/null +++ b/crates/videre-host/src/handshake.rs @@ -0,0 +1,285 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one body-schema version it encodes, +//! an adapter the set it decodes, and a keeper boots only when every +//! installed venue adapter decodes its version, since any of them is a +//! legal runtime submit target. + +use std::collections::BTreeSet; + +use anyhow::{anyhow, bail}; +use nexum_runtime::host::extension::ProviderManifest; +use nexum_runtime::manifest::ExtensionSections; +use serde::Deserialize; +use tracing::error; + +use crate::registry::VenueAdapterKind; + +/// The manifest section the videre platform claims. +pub(crate) const SECTION: &str = "venue"; + +/// The claimed-section list handed to the runtime. +pub(crate) const SECTIONS: &[&str] = &[SECTION]; + +/// Keeper-side `[venue]`: the one body-schema version it encodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeeperSection { + body_version: u32, +} + +/// Adapter-side `[venue]`: the body-schema versions it decodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterSection { + body_versions: BTreeSet, +} + +/// Parse one `[venue]` value as `S`, tagging failures with the owner. +fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { + value + .clone() + .try_into() + .map_err(|e| anyhow!("{owner} [venue]: {e}")) +} + +/// Admit one provider: a `[venue]` section, when present, must be the +/// adapter shape with a non-empty version set. +pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let section: AdapterSection = parse(provider, value)?; + if section.body_versions.is_empty() { + bail!("{provider} [venue]: body_versions must not be empty"); + } + Ok(()) +} + +/// An adapter's declared decode set: its `[venue] body_versions`, empty +/// when the section is absent. +pub(crate) fn declared_versions( + provider: &str, + sections: &ExtensionSections, +) -> anyhow::Result> { + let Some(value) = sections.get(SECTION) else { + return Ok(BTreeSet::new()); + }; + let section: AdapterSection = parse(provider, value)?; + Ok(section.body_versions) +} + +/// Assert an adapter's `body-versions()` export equals its manifest +/// claim, refusing the install on divergence so the two sources of the +/// decode set cannot drift. +pub(crate) fn verify_exported_versions( + provider: &str, + declared: &BTreeSet, + exported: Vec, +) -> anyhow::Result<()> { + let exported: BTreeSet = exported.into_iter().collect(); + if exported != *declared { + bail!( + "{provider} exports body versions {exported:?}; the manifest [venue] \ + body_versions declares {declared:?}" + ); + } + Ok(()) +} + +/// The membership install predicate: a worker declaring `[venue] +/// body_version` is admitted only when every installed venue adapter's +/// `[venue] body_versions` set contains that version. Every installed +/// venue is a legal runtime submit target, so one non-decoding adapter +/// refuses the keeper. +pub(crate) fn admit_worker( + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], +) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let KeeperSection { body_version } = parse(worker, value)?; + let adapters: Vec<&ProviderManifest> = providers + .iter() + .filter(|p| p.kind == VenueAdapterKind::KIND) + .collect(); + if adapters.is_empty() { + return Err(refuse( + worker, + body_version, + "no venue adapter declares [venue] body_versions", + )); + } + for provider in adapters { + let Some(value) = provider.sections.get(SECTION) else { + return Err(refuse( + worker, + body_version, + &format!("{} declares no [venue] body_versions", provider.name), + )); + }; + let section: AdapterSection = parse(&provider.name, value)?; + if !section.body_versions.contains(&body_version) { + return Err(refuse( + worker, + body_version, + &format!("{} decodes {:?}", provider.name, section.body_versions), + )); + } + } + Ok(()) +} + +/// Log and build the refusal for one keeper/adapter pairing. +fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { + error!( + keeper = %worker, + body_version, + %decoded, + "body-version handshake refused the keeper/adapter pair", + ); + anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sections(toml: &str) -> ExtensionSections { + let table: toml::Table = toml.parse().expect("parse"); + table.into_iter().collect() + } + + fn adapter(name: &str, toml: &str) -> ProviderManifest { + ProviderManifest { + name: name.to_owned(), + kind: VenueAdapterKind::KIND, + sections: sections(toml), + } + } + + /// A keeper whose version every installed adapter decodes is admitted. + #[test] + fn matching_pair_is_admitted() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [2, 3]"), + ]; + admit_worker("keeper", &keeper, &adapters).expect("admitted"); + } + + /// A keeper whose version no adapter decodes is refused, and the + /// refusal names the version and the declared set. + #[test] + fn mismatched_pair_is_refused() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("cow decodes {1}"), "{msg}"); + } + + /// Membership is a conjunction over the installed adapters: one + /// non-decoding adapter refuses the keeper even when another decodes + /// its version, since either is a legal runtime submit target. + #[test] + fn one_non_decoding_adapter_refuses_the_keeper() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [1]"), + ]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("uni decodes {1}"), "{msg}"); + } + + /// A declaring keeper with no installed venue adapter is refused. + #[test] + fn undeclared_adapters_refuse_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); + assert!(err.to_string().contains("no venue adapter declares")); + } + + /// An installed adapter without a `[venue]` section refuses a + /// declaring keeper: its decode set is undeclared, not universal. + #[test] + fn a_section_less_adapter_refuses_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let adapters = [adapter("cow", "")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + assert!(err.to_string().contains("cow declares no [venue]")); + } + + /// A provider of another kind never satisfies the membership check. + #[test] + fn other_provider_kinds_are_ignored() { + let keeper = sections("[venue]\nbody_version = 1"); + let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); + other.kind = "price-oracle"; + admit_worker("keeper", &keeper, &[other]).expect_err("refused"); + } + + /// Workers and providers without a `[venue]` section are admitted. + #[test] + fn undeclared_sections_are_admitted() { + admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); + admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); + } + + /// The wrong-side spelling fails loudly on both faces. + #[test] + fn wrong_side_spelling_is_refused() { + let keeper = sections("[venue]\nbody_versions = [1]"); + admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); + + let venue = sections("[venue]\nbody_version = 1"); + admit_provider("venue", &venue).expect_err("adapter with the keeper key"); + } + + /// An adapter declaring an empty decode set is refused at install. + #[test] + fn empty_adapter_set_is_refused() { + let venue = sections("[venue]\nbody_versions = []"); + let err = admit_provider("venue", &venue).expect_err("refused"); + assert!(err.to_string().contains("must not be empty")); + } + + /// A well-formed adapter declaration is admitted. + #[test] + fn adapter_declaration_is_admitted() { + let venue = sections("[venue]\nbody_versions = [1, 2]"); + admit_provider("venue", &venue).expect("admitted"); + } + + /// The exported set must equal the manifest claim exactly; either + /// direction of drift refuses the install. + #[test] + fn exported_versions_must_equal_the_manifest_claim() { + let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) + .expect("declared"); + verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); + + let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); + assert!( + err.to_string().contains("exports body versions {1}"), + "{err}" + ); + verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); + } + + /// A section-less adapter must export an empty set: an undeclared + /// manifest with a declaring export is drift, not a default. + #[test] + fn a_section_less_adapter_must_export_no_versions() { + let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); + assert!(declared.is_empty()); + verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); + verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); + } +} diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index fcd715bb..d228e27a 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -8,6 +8,7 @@ pub mod bindings; mod client; +mod handshake; mod registry; use std::sync::Arc; @@ -18,9 +19,10 @@ use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::RuntimeTypes; use nexum_runtime::host::extension::{ EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, + ProviderManifest, }; use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::NamespaceCaps; +use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; use tokio::sync::mpsc; use wasmtime::component::{HasSelf, Linker}; @@ -94,6 +96,28 @@ impl Extension for Videre { Some(Box::new(VenueAdapterKind)) } + fn manifest_sections(&self) -> &'static [&'static str] { + handshake::SECTIONS + } + + /// Adapter-side handshake shape check: a `[venue]` section must + /// declare a non-empty `body_versions` set. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + handshake::admit_provider(provider, sections) + } + + /// The body-version membership predicate: a keeper declaring + /// `[venue] body_version` boots only when every installed venue + /// adapter's `[venue] body_versions` set contains it. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + handshake::admit_worker(worker, sections, providers) + } + fn subscriptions(&self) -> &'static [&'static str] { &[INTENT_STATUS_KIND] } diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index b7e961a0..d4228cbe 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -706,6 +706,7 @@ impl ProviderKind for VenueAdapterKind { linker, mut store, config, + sections, fuel_per_call, liveness, } = instance; @@ -715,6 +716,18 @@ impl ProviderKind for VenueAdapterKind { .context("instantiate adapter")?; // The venue id is the adapter's namespace: its manifest name. let venue_id = VenueId::from(&*store.data().run.module); + // The manifest `[venue] body_versions` is the install-time + // authority the keeper handshake reads; the export must agree, + // so a manifest claiming versions the code does not decode never + // installs. + let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; + let exported = bindings + .videre_venue_adapter() + .call_body_versions(&mut store) + .await + .map_err(anyhow::Error::from) + .context("read adapter body-versions")?; + crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; match bindings .call_init(&mut store, &config) .await diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 05bc062e..462eae22 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -580,6 +580,132 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); } +/// The body-version handshake refuses a mismatched pair: an adapter +/// decoding only v1 against a keeper encoding v2 fails the boot at the +/// keeper's install, before instantiation, naming both sides' versions. +#[tokio::test] +async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1] +"#, + ) + .expect("write adapter manifest"); + let keeper_manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &keeper_manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[venue] +body_version = 2 +"#, + ) + .expect("write keeper manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(keeper_manifest), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("mismatched pair must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("body version 2"), "{chain}"); + assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); +} + +/// An adapter whose manifest claims versions its code does not decode +/// fails its own install: the `body-versions()` export must equal the +/// manifest `[venue] body_versions` set. +#[tokio::test] +async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { + let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1, 2] +"#, + ) + .expect("write adapter manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("a diverging adapter must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("exports body versions {1}"), "{chain}"); + assert!(chain.contains("declares {1, 2}"), "{chain}"); +} + // ── venue-adapter trap recovery ─────────────────────────────────────── /// Boot one flaky-venue adapter over the mock chain, whose head starts at diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml index 4b1df513..9dee6673 100644 --- a/modules/examples/echo-client/module.toml +++ b/modules/examples/echo-client/module.toml @@ -30,3 +30,9 @@ venue = "echo-venue" [config] name = "echo-client" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index f57607ca..63f9a7a2 100644 --- a/modules/examples/echo-venue/module.toml +++ b/modules/examples/echo-venue/module.toml @@ -22,3 +22,8 @@ allow = [] [config] name = "echo-venue" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 92b0e62c..9af90589 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -32,6 +32,12 @@ impl EchoVenue { Ok(()) } + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + fn derive_header(body: Vec) -> Result { // The echo venue gives back exactly the bytes handed to it, so the // header's `gives` amount is the body length: enough to exercise diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 1f01b9cc..33e73f10 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -17,6 +17,10 @@ interface client { interface adapter { use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + /// Body-schema versions this adapter decodes. Must equal the + /// manifest `[venue] body_versions` set; install asserts it. + body-versions: func() -> list; + /// Pure: derive the guard-facing header from a body. No I/O. derive-header: func(body: list) -> result; quote: func(body: list) -> result;