From 63ce69819d8977face43ea495e31ca1fab666375 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 10:21:45 +0000 Subject: [PATCH] sdk: land the alloy-grade DX polish cluster Add an Order typestate builder over the 12-field CoW OrderBody with SellToken and BuyToken newtypes, so a keeper cannot swap sides or skip a required field: sell/buy entry fixes the kind, the counter-side limit and expiry are compile-time required states, and the optionals default. Seal the extension traits: Host and HostFault (nexum-sdk), RuntimeTypes and Runtime (nexum-runtime), and VenueTransport (videre-sdk, the pool seam's successor) each gain a doc-hidden sealing marker an implementor opts into, so the surfaces can grow without silent downstream breakage. Apply #[non_exhaustive] uniformly across the public error and label enums, adding wildcard folds at the cross-crate match sites. Emit the mirrored vocabularies from single-source consts in nexum-world: the capability name consts and CORE_IFACES feed both the world-synthesis table and the runtime registry, and the fault-label consts feed the runtime's label projection with the SDK's strum labels pinned to them in test. Fix the operator logs that debug-formatted venue faults: the SDK Fault's rate-limited Display now carries the retry-after hint, the runtime's fault_message keeps it, and the venue registry renders wire errors through message projections instead of the bindgen's debug Display, so rate-limited retry-after-ms survives to the log line. The golden-bridge sweep found no residue. --- Cargo.lock | 2 + crates/cow-venue/src/body.rs | 22 +- crates/cow-venue/src/classification_data.rs | 1 + crates/cow-venue/src/client.rs | 26 +- crates/cow-venue/src/lib.rs | 4 +- crates/cow-venue/src/order.rs | 229 ++++++++++++++++++ crates/nexum-runtime/Cargo.toml | 3 + crates/nexum-runtime/src/builder.rs | 4 + crates/nexum-runtime/src/host/actor.rs | 1 + .../src/host/component/builder.rs | 1 + .../nexum-runtime/src/host/component/chain.rs | 1 + .../nexum-runtime/src/host/component/mod.rs | 2 + .../src/host/component/runtime_types.rs | 4 +- crates/nexum-runtime/src/host/error.rs | 39 +-- .../src/host/local_store_redb.rs | 1 + crates/nexum-runtime/src/host/logs/mod.rs | 1 + crates/nexum-runtime/src/lib.rs | 8 + .../src/manifest/capabilities.rs | 5 +- crates/nexum-runtime/src/manifest/error.rs | 1 + crates/nexum-runtime/src/manifest/types.rs | 12 +- crates/nexum-runtime/src/preset.rs | 7 +- .../nexum-runtime/src/runtime/event_loop.rs | 1 + crates/nexum-runtime/src/supervisor.rs | 7 +- crates/nexum-runtime/src/test_utils/types.rs | 2 + crates/nexum-sdk/Cargo.toml | 2 + crates/nexum-sdk/src/chain/method.rs | 1 + crates/nexum-sdk/src/config.rs | 1 + crates/nexum-sdk/src/host.rs | 65 ++++- crates/nexum-sdk/src/http.rs | 1 + crates/nexum-status-body/src/lib.rs | 1 + crates/nexum-world/src/lib.rs | 119 ++++++++- crates/shepherd-cow-host/tests/cow_boot.rs | 2 + crates/shepherd-sdk/src/cow/composable.rs | 4 +- crates/shepherd-sdk/src/cow/error.rs | 2 + crates/shepherd-sdk/src/cow/mod.rs | 4 +- crates/shepherd/src/main.rs | 4 + crates/videre-host/src/bindings.rs | 39 +++ crates/videre-host/src/registry.rs | 5 +- crates/videre-sdk/src/body.rs | 1 + crates/videre-sdk/src/client.rs | 13 +- crates/videre-sdk/src/faults.rs | 6 +- crates/videre-sdk/src/keeper.rs | 2 + crates/videre-sdk/tests/adapter.rs | 2 + crates/videre-test/src/fixture.rs | 1 + modules/examples/http-probe/src/strategy.rs | 4 +- 45 files changed, 573 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 171b17d9..242c99f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3633,6 +3633,7 @@ dependencies = [ "metrics-exporter-prometheus", "nexum-runtime", "nexum-tasks", + "nexum-world", "redb", "serde", "serde_json", @@ -3667,6 +3668,7 @@ dependencies = [ "nexum-module-macros", "nexum-sdk-test", "nexum-status-body", + "nexum-world", "proptest", "serde_json", "strum", diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index a4ff6aa3..e7e40fe4 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -38,23 +38,15 @@ mod tests { use super::*; use videre_test::{CodecVectors, Expectation}; - use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; + use crate::order::{BuyToken, SellToken}; fn order_body() -> OrderBody { - OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - } + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build() } fn composable_body() -> ComposableBody { diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index 3938eddd..49162e0b 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -46,6 +46,7 @@ struct Document { /// Why the shipped classification data could not be turned into a table. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ClassificationError { /// The TOML did not parse or a field had the wrong type. #[error("classification data is not valid TOML: {0}")] diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 274906c7..892e4f4e 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -47,6 +47,8 @@ mod tests { submitted: SubmitLog, } + impl videre_sdk::client::sealed::SealedTransport for SpyClient {} + impl VenueTransport for SpyClient { async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") @@ -78,21 +80,15 @@ mod tests { fn sample_body() -> CowIntentBody { use crate::body::CowIntent; - use crate::order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; - CowIntentBody::V1(CowIntent::Order(OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - })) + use crate::order::{BuyToken, OrderBody, SellToken}; + CowIntentBody::V1(CowIntent::Order( + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build(), + )) } #[test] diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 3b4054fb..440fa77b 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -59,7 +59,9 @@ pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use composable::ComposableBody; #[cfg(feature = "body")] -pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; +pub use order::{ + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, +}; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index cc31b934..a8ac4a57 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,8 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use core::marker::PhantomData; + use borsh::{BorshDeserialize, BorshSerialize}; /// A 20-byte EVM address in wire form. @@ -17,6 +19,28 @@ pub type Address = [u8; 20]; /// A 256-bit amount as its 32-byte big-endian representation. pub type U256 = [u8; 32]; +/// The token an order sells, typed so a builder call cannot swap +/// sides with the buy token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SellToken(pub Address); + +impl From
for SellToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// The token an order buys, typed so a builder call cannot swap sides +/// with the sell token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuyToken(pub Address); + +impl From
for BuyToken { + fn from(address: Address) -> Self { + Self(address) + } +} + /// Which side of the trade is fixed. #[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderKind { @@ -79,6 +103,162 @@ pub struct OrderBody { pub buy_token_balance: BuyTokenDestination, } +impl OrderBody { + /// Start a sell order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) + } + + /// Start a buy order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + } +} + +/// Builder state: the buy-side limit is unset. +pub enum NeedsBuy {} +/// Builder state: the sell-side limit is unset. +pub enum NeedsSell {} +/// Builder state: the expiry is unset. +pub enum NeedsValidTo {} +/// Builder state: every required field is set. +pub enum Ready {} + +/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or +/// [`OrderBody::buy`] fixes the kind and its side, the counter-side +/// limit and the expiry are compile-time required, and the optionals +/// default (self-receive, zero `app_data` and `fee_amount`, +/// fill-or-kill, ERC-20 balances). +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, + state: PhantomData, +} + +impl OrderBuilder { + const fn start( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to: 0, + app_data: [0; 32], + fee_amount: [0; 32], + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + state: PhantomData, + } + } + + const fn into_state(self) -> OrderBuilder { + OrderBuilder { + body: self.body, + state: PhantomData, + } + } +} + +impl OrderBuilder { + /// Demand at least `amount` of `token` in return. + #[must_use] + pub const fn for_at_least( + mut self, + token: BuyToken, + amount: U256, + ) -> OrderBuilder { + self.body.buy_token = token.0; + self.body.buy_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Spend at most `amount` of `token`. + #[must_use] + pub const fn for_at_most( + mut self, + token: SellToken, + amount: U256, + ) -> OrderBuilder { + self.body.sell_token = token.0; + self.body.sell_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Expire at `valid_to` (Unix seconds). + #[must_use] + pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { + self.body.valid_to = valid_to; + self.into_state() + } +} + +impl OrderBuilder { + /// Deliver the buy token to `receiver` instead of the owner. + #[must_use] + pub const fn receiver(mut self, receiver: Address) -> Self { + self.body.receiver = Some(receiver); + self + } + + /// Set the 32-byte on-chain app-data hash. + #[must_use] + pub const fn app_data(mut self, app_data: [u8; 32]) -> Self { + self.body.app_data = app_data; + self + } + + /// Set the fee taken in the sell token. + #[must_use] + pub const fn fee_amount(mut self, fee_amount: U256) -> Self { + self.body.fee_amount = fee_amount; + self + } + + /// Allow the order to fill partially. + #[must_use] + pub const fn partially_fillable(mut self) -> Self { + self.body.partially_fillable = true; + self + } + + /// Source the sell token from `source`. + #[must_use] + pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self { + self.body.sell_token_balance = source; + self + } + + /// Deliver the buy token to `destination`. + #[must_use] + pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self { + self.body.buy_token_balance = destination; + self + } + + /// The finished body. + #[must_use] + pub const fn build(self) -> OrderBody { + self.body + } +} + #[cfg(test)] mod tests { use super::*; @@ -104,6 +284,55 @@ mod tests { } } + #[test] + fn sell_builder_matches_the_literal() { + let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount) + .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) + .valid_to(0xffff_ffff) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) + .for_at_most(SellToken([0x11; 20]), [0x01; 32]) + .valid_to(100) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, [0x11; 20]); + assert_eq!(built.buy_token, [0x22; 20]); + assert_eq!(built.sell_amount, [0x01; 32]); + assert_eq!(built.buy_amount, [0xff; 32]); + assert_eq!(built.valid_to, 100); + assert!(built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::External); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); + assert_eq!(built.fee_amount, [0x05; 32]); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, [0; 32]); + assert!(!built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); + assert_eq!(built.kind, OrderKind::Sell); + } + #[test] fn order_body_borsh_round_trips() { let body = sample(); diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index cb29b7b9..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,6 +36,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Single-source capability and fault-label vocabularies; the registry's +# core interface set is emitted from its table. +nexum-world = { path = "../nexum-world" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 47145902..fda2b575 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -674,6 +674,8 @@ mod tests { linked: Arc, } + impl crate::sealed::SealedRuntime for ExtPreset {} + impl RuntimePreset for ExtPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; @@ -740,6 +742,8 @@ mod tests { logs: LogPipeline, } + impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} + impl RuntimePreset for PrebuiltLogsPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index f55bf2c7..c10f8e0a 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -60,6 +60,7 @@ impl Liveness { /// A guest call failed outside the component's typed error space. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ActorFault { /// The pre-call refuel failed; the guest was never entered. #[error("refuel failed: {0}")] diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 60b734cf..6b4111cc 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -98,6 +98,7 @@ impl ComponentBuilder for LogPipelineBuilder { /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 072ebfe8..687abf3a 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -16,6 +16,7 @@ use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, /// structural ceiling; an operator allowlist narrows within it and /// never widens it. #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { #[strum(serialize = "eth_blockNumber")] EthBlockNumber, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index eaa0e70a..fd9d24e1 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -52,6 +52,8 @@ mod tests { #[derive(Clone, Copy, Default)] struct CoreTypes; + impl crate::sealed::SealedRuntimeTypes for CoreTypes {} + impl RuntimeTypes for CoreTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index 0540e4d4..33741499 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -13,7 +13,9 @@ use crate::host::component::{ChainProvider, StateStore}; /// Names the core backend seams a runtime assembly provides, plus the /// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core /// backend an extension needs. -pub trait RuntimeTypes: 'static { +/// +/// Sealed: a lattice opts in by also implementing the sealing marker. +pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { /// JSON-RPC dispatch and subscriptions. type Chain: ChainProvider + Clone + Send + Sync + 'static; /// Process-wide store vending per-module handles. diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 65f0c303..65fbe9d7 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -15,32 +15,39 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { } /// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary. -pub(crate) fn fault_label(fault: &Fault) -> &'static str { +/// structured-log `kind` field. Emitted from the single-source +/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` +/// mirrors. +pub fn fault_label(fault: &Fault) -> &'static str { + use nexum_world::fault_labels as labels; match fault { - Fault::Unsupported(_) => "unsupported", - Fault::Unavailable(_) => "unavailable", - Fault::Denied(_) => "denied", - Fault::RateLimited(_) => "rate_limited", - Fault::Timeout => "timeout", - Fault::InvalidInput(_) => "invalid_input", - Fault::Internal(_) => "internal", + Fault::Unsupported(_) => labels::UNSUPPORTED, + Fault::Unavailable(_) => labels::UNAVAILABLE, + Fault::Denied(_) => labels::DENIED, + Fault::RateLimited(_) => labels::RATE_LIMITED, + Fault::Timeout => labels::TIMEOUT, + Fault::InvalidInput(_) => labels::INVALID_INPUT, + Fault::Internal(_) => labels::INTERNAL, } } /// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The payload-bearing cases carry their own detail; the two -/// payload-free cases render a fixed phrase. -pub(crate) fn fault_message(fault: &Fault) -> &str { +/// field. The bindgen `Display` is the `{0:?}` debug form, so operator +/// logs render through this instead. The payload-bearing cases carry +/// their own detail; a rate limit keeps its `retry-after-ms` hint; +/// `timeout` renders a fixed phrase. +pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) | Fault::Unavailable(m) | Fault::Denied(m) | Fault::InvalidInput(m) - | Fault::Internal(m) => m, - Fault::RateLimited(_) => "rate limited", - Fault::Timeout => "timeout", + | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), + Fault::RateLimited(rl) => match rl.retry_after_ms { + Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => std::borrow::Cow::Borrowed("rate limited"), + }, + Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 03610763..8e2d95e4 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -285,6 +285,7 @@ impl ModuleStore { /// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StorageError { #[error("open redb: {0}")] Open(#[source] redb::DatabaseError), diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..b619bf3e 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -61,6 +61,7 @@ impl RunId { /// `source` field on the host tracing event. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum LogSource { /// The `nexum:host/logging` glue: an explicit guest `log` call. HostInterface, diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index b4cccbdd..4a29dd47 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -17,6 +17,14 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +/// Sealing markers for [`preset::Runtime`] and +/// [`host::component::RuntimeTypes`]: implement alongside the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedRuntimeTypes {} + pub trait SealedRuntime {} +} + pub mod addons; pub mod bindings; pub mod bootstrap; diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index dc6edeb4..3492634f 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -44,7 +44,8 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { /// moves bytes to and from its counterparty and nothing else. `http` is /// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = + &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; /// The provider namespace: the same `nexum:host/` prefix as core but only /// the scoped-transport interfaces. Validating a provider manifest against @@ -62,7 +63,7 @@ const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = "http"; +const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; /// Gated WASI capability names. Declaring one grants the matching `wasi:` /// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 470cc0bb..ed5858c7 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -51,6 +51,7 @@ pub struct CapabilityViolation { /// Error returned when a component's WIT imports exceed its declared /// capabilities. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. #[error(transparent)] diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index f6fbc95c..1960dc61 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -11,19 +11,13 @@ use serde::Deserialize; use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a +/// world links into every module linker, emitted from the +/// `nexum-world` capability table. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled /// separately by the registry. Domain-extension capabilities are not /// listed here; each extension contributes its own namespace to the /// [`super::capabilities::CapabilityRegistry`] at the composition root. -pub const CORE_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", -]; +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] pub struct Manifest { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 9ec4dd64..56fe1629 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -30,7 +30,9 @@ use crate::host::provider_pool::ProviderPool; /// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) /// binds a value, so a preset can hand back already-built backends through a /// pass-through builder such as `Prebuilt`. -pub trait Runtime { +/// +/// Sealed: a preset opts in by also implementing the sealing marker. +pub trait Runtime: crate::sealed::SealedRuntime { /// The lattice the preset assembles. type Types: RuntimeTypes; /// Builds the chain backend ([`RuntimeTypes::Chain`]). @@ -73,6 +75,9 @@ pub trait Runtime { #[derive(Debug, Clone, Copy, Default)] pub struct CoreRuntime; +impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} +impl crate::sealed::SealedRuntime for CoreRuntime {} + impl RuntimeTypes for CoreRuntime { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 3f1aa287..3b848306 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -50,6 +50,7 @@ use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; /// supervisor consumes. Library-side code keeps `anyhow::Error` out /// of long-lived stream item types per the rust idiomatic rubric. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { /// Underlying provider / transport failure while opening or /// pumping the subscription. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index e7c49ecf..fd369eeb 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -106,6 +106,9 @@ pub struct Supervisor { #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; +#[cfg(test)] +impl crate::sealed::SealedRuntimeTypes for TestTypes {} + #[cfg(test)] impl RuntimeTypes for TestTypes { type Chain = ProviderPool; @@ -738,7 +741,7 @@ impl Supervisor { warn!( module = %module_namespace, kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + message = %crate::host::error::fault_message(&e), "init failed - module loaded but marked dead; dispatcher will skip it", ); false @@ -1483,7 +1486,7 @@ impl Supervisor { block_number, latency_ms, kind, - message = crate::host::error::fault_message(&fault), + message = %crate::host::error::fault_message(&fault), "on-event returned fault", ); metrics::counter!( diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..4180b328 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -15,6 +15,8 @@ use crate::test_utils::{MockChainProvider, MockStateStore}; /// it derives no traits and is zero-sized at runtime. pub struct MockTypes(PhantomData E>); +impl crate::sealed::SealedRuntimeTypes for MockTypes {} + impl RuntimeTypes for MockTypes { type Chain = MockChainProvider; type Store = MockStateStore; diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index a5c53524..4118fb81 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -65,6 +65,8 @@ proptest.workspace = true # The keeper never touches the orderbook, so a CoW-layer mock would only drag # the domain crates into this crate's dev graph. nexum-sdk-test = { path = "../nexum-sdk-test" } +# Pins the strum-derived fault labels to the single-source vocabulary. +nexum-world = { path = "../nexum-world" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs index 3f45013b..58ecfae6 100644 --- a/crates/nexum-sdk/src/chain/method.rs +++ b/crates/nexum-sdk/src/chain/method.rs @@ -8,6 +8,7 @@ use strum::{EnumString, IntoStaticStr}; /// WIT edge; [`HostTransport`](super::HostTransport) rejects anything /// outside this set before calling the host. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { /// `eth_blockNumber`. #[strum(serialize = "eth_blockNumber")] diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index 4a278cfc..fdfcfc9c 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -20,6 +20,7 @@ use thiserror::Error; /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] +#[non_exhaustive] pub enum ConfigError { /// The key was not present in the `entries` slice. #[error("missing key {key:?}")] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 82b562d8..9fc0cb68 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -45,7 +45,7 @@ pub enum Fault { Denied(String), /// Rate-limited by an upstream service; may carry backoff guidance /// when the host knows the retry window. - #[error("rate limited")] + #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] RateLimited(RateLimit), /// Operation took too long. #[error("timeout")] @@ -66,13 +66,32 @@ pub struct RateLimit { pub retry_after_ms: Option, } +/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside +/// the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedHost {} + pub trait SealedHostFault {} +} + +impl sealed::SealedHost for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +impl sealed::SealedHostFault for Fault {} +impl sealed::SealedHostFault for ChainError {} + /// Recovers the shared [`Fault`] from a richer, per-interface error. /// /// Typed interface errors that embed a fault case implement this so a /// caller can dispatch on the structured cause and pull a stable /// snake_case [`label`](HostFault::label) for logs and metrics without /// matching the outer type. -pub trait HostFault { +/// +/// Sealed: an error type opts in by also implementing the sealing +/// marker. +pub trait HostFault: sealed::SealedHostFault { /// The embedded fault, when this value represents one. fn fault(&self) -> Option<&Fault>; /// Stable snake_case label for logs and metrics. @@ -121,6 +140,7 @@ pub struct RpcError { /// [`HostFault`] recovers the embedded [`Fault`] (present only on the /// `Fault` case) and a stable snake_case label for logs and metrics. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ChainError { /// A shared host fault. #[error(transparent)] @@ -397,8 +417,15 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` +/// Sealed: the blanket impl is the only implementation. pub trait Host: - ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost + sealed::SealedHost + + ChainHost + + IdentityHost + + LocalStoreHost + + RemoteStoreHost + + MessagingHost + + LoggingHost { } impl Host for T where @@ -481,15 +508,19 @@ mod tests { } #[test] - fn fault_labels_are_stable_snake_case() { + fn fault_labels_match_the_single_source_vocabulary() { + use nexum_world::fault_labels as labels; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), "unsupported"), - (Fault::Unavailable(String::new()), "unavailable"), - (Fault::Denied(String::new()), "denied"), - (Fault::RateLimited(RateLimit::default()), "rate_limited"), - (Fault::Timeout, "timeout"), - (Fault::InvalidInput(String::new()), "invalid_input"), - (Fault::Internal(String::new()), "internal"), + (Fault::Unsupported(String::new()), labels::UNSUPPORTED), + (Fault::Unavailable(String::new()), labels::UNAVAILABLE), + (Fault::Denied(String::new()), labels::DENIED), + ( + Fault::RateLimited(RateLimit::default()), + labels::RATE_LIMITED, + ), + (Fault::Timeout, labels::TIMEOUT), + (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), + (Fault::Internal(String::new()), labels::INTERNAL), ]; for (fault, label) in cases { assert_eq!(fault.label(), label); @@ -497,6 +528,18 @@ mod tests { } } + #[test] + fn rate_limit_display_carries_the_retry_hint() { + let hinted = Fault::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); + assert_eq!( + Fault::RateLimited(RateLimit::default()).to_string(), + "rate limited" + ); + } + #[test] fn host_fault_is_object_safe() { let boxed: Box = Box::new(Fault::Timeout); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7ca0c551..e4a6dec0 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -56,6 +56,7 @@ impl Default for FetchOptions { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum FetchError { /// The host's `[capabilities.http].allow` list refused the request /// before any connection was made. diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs index d5cc4fab..2bd9c009 100644 --- a/crates/nexum-status-body/src/lib.rs +++ b/crates/nexum-status-body/src/lib.rs @@ -97,6 +97,7 @@ pub struct EncodeError { /// Why bytes failed to decode as a status body. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum DecodeError { /// No bytes at all: not even a version tag. #[error("empty status body: missing the version tag")] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index aae0b9db..5ca9e1ca 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -17,6 +17,54 @@ use std::path::{Path, PathBuf}; +/// Capability name consts: the single source the [`CORE`] table and the +/// runtime's capability registry emit from. +pub mod caps { + /// `nexum:host/chain`. + pub const CHAIN: &str = "chain"; + /// `nexum:host/identity`. + pub const IDENTITY: &str = "identity"; + /// `nexum:host/local-store`. + pub const LOCAL_STORE: &str = "local-store"; + /// `nexum:host/remote-store`. + pub const REMOTE_STORE: &str = "remote-store"; + /// `nexum:host/messaging`. + pub const MESSAGING: &str = "messaging"; + /// `nexum:host/logging`. + pub const LOGGING: &str = "logging"; + /// Gates `wasi:http/*`; no world import. + pub const HTTP: &str = "http"; +} + +/// Snake_case labels of the `nexum:host/types.fault` cases, in +/// declaration order: the single source every label mirror emits from. +pub mod fault_labels { + /// `fault.unsupported`. + pub const UNSUPPORTED: &str = "unsupported"; + /// `fault.unavailable`. + pub const UNAVAILABLE: &str = "unavailable"; + /// `fault.denied`. + pub const DENIED: &str = "denied"; + /// `fault.rate-limited`. + pub const RATE_LIMITED: &str = "rate_limited"; + /// `fault.timeout`. + pub const TIMEOUT: &str = "timeout"; + /// `fault.invalid-input`. + pub const INVALID_INPUT: &str = "invalid_input"; + /// `fault.internal`. + pub const INTERNAL: &str = "internal"; + /// All seven, in declaration order. + pub const ALL: [&str; 7] = [ + UNSUPPORTED, + UNAVAILABLE, + DENIED, + RATE_LIMITED, + TIMEOUT, + INVALID_INPUT, + INTERNAL, + ]; +} + /// One manifest capability and its world wiring. pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. @@ -38,49 +86,79 @@ pub struct Capability { /// core registry and nothing else; extension rows are the caller's. pub const CORE: &[Capability] = &[ Capability { - name: "chain", + name: caps::CHAIN, import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { - name: "identity", + name: caps::IDENTITY, import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: Some("identity"), }, Capability { - name: "local-store", + name: caps::LOCAL_STORE, import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { - name: "remote-store", + name: caps::REMOTE_STORE, import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: Some("remote_store"), }, Capability { - name: "messaging", + name: caps::MESSAGING, import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: Some("messaging"), }, Capability { - name: "logging", + name: caps::LOGGING, import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, Capability { - name: "http", + name: caps::HTTP, import: None, packages: &[], adapter: None, }, ]; +/// Number of import-bearing [`CORE`] rows. +const fn core_iface_count() -> usize { + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + n += 1; + } + i += 1; + } + n +} + +/// Names of the import-bearing [`CORE`] rows, in emission order: the +/// `nexum:host` interface set the runtime's capability registry +/// enforces. `http` is absent (no world import). +pub const CORE_IFACES: [&str; core_iface_count()] = { + let mut out = [""; core_iface_count()]; + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + out[n] = CORE[i].name; + n += 1; + } + i += 1; + } + out +}; + /// One registered extension row: a per-namespace capability a /// composition root declares in its `extensions.toml`. An extension /// always has a WIT import and never a host-adapter ident (adapter @@ -411,6 +489,33 @@ mod tests { assert!(err.contains("extension capability `acme` collides")); } + #[test] + fn core_ifaces_are_the_import_bearing_rows() { + assert_eq!( + CORE_IFACES, + [ + caps::CHAIN, + caps::IDENTITY, + caps::LOCAL_STORE, + caps::REMOTE_STORE, + caps::MESSAGING, + caps::LOGGING, + ], + ); + assert!(!CORE_IFACES.contains(&caps::HTTP)); + } + + #[test] + fn fault_labels_are_snake_case_and_distinct() { + for label in fault_labels::ALL { + assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); + } + let mut labels = fault_labels::ALL.to_vec(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), fault_labels::ALL.len()); + } + #[test] fn core_table_carries_no_extension_row() { assert!( diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 4612f12b..99080dac 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -27,6 +27,8 @@ const SEPOLIA: u64 = 11_155_111; #[derive(Debug, Clone, Copy, Default)] struct CowTestTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} + impl RuntimeTypes for CowTestTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index a7a4674e..fdb28adf 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -181,7 +181,9 @@ impl LegacyRevertAdapter { } _ => Verdict::TryNextBlock { reason: [0; 4] }, }, - ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + // `ChainError` is `#[non_exhaustive]`: transport faults and + // any future case are payload-free, so they stay retryable. + _ => Verdict::TryNextBlock { reason: [0; 4] }, } } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index b626ab94..3f676861 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -67,6 +67,8 @@ pub enum CowApiError { Rejected(OrderRejection), } +impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} + impl HostFault for CowApiError { fn fault(&self) -> Option<&Fault> { match self { diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 1d216924..19e00a6d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -33,8 +33,8 @@ pub use run::run; /// codec, re-exported from the `cow-venue` default slice. The shim keeps /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ - BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, OrderKind, - SellTokenSource, + BuyToken, BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, + OrderBuilder, OrderKind, SellToken, SellTokenSource, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 9a32d9fd..f7c629cd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -23,6 +23,8 @@ use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; #[derive(Debug, Clone, Copy, Default)] struct ReferenceTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} + impl RuntimeTypes for ReferenceTypes { type Chain = ProviderPool; type Store = LocalStore; @@ -34,6 +36,8 @@ impl RuntimeTypes for ReferenceTypes { #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; +impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} + impl Runtime for ShepherdRuntime { type Types = ReferenceTypes; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index c6281016..c07618a9 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -77,6 +77,45 @@ pub use venue_adapter::videre::types::types::{ /// The value-flow vocabulary the header is expressed in. pub use venue_adapter::videre::value_flow::types as value_flow; +/// Operator-log rendering of the wire `venue-error`: the bindgen +/// `Display` is the `{0:?}` debug form, so logs render through this +/// instead and the rate-limit `retry-after-ms` hint survives. +pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + match err { + VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), + VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), + VenueError::Unsupported => Cow::Borrowed("unsupported"), + VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), + VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { + Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => Cow::Borrowed("rate limited"), + }, + VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), + VenueError::Timeout => Cow::Borrowed("timeout"), + } +} + +#[cfg(test)] +mod display_smoke { + use super::{RateLimit, VenueError, venue_error_message}; + + #[test] + fn venue_error_message_keeps_the_rate_limit_hint() { + let hinted = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!( + venue_error_message(&hinted), + "rate limited, retry after 250 ms" + ); + let unhinted = VenueError::RateLimited(RateLimit { + retry_after_ms: None, + }); + assert_eq!(venue_error_message(&unhinted), "rate limited"); + } +} + /// Bindgen smoke for the `videre:value-flow` types package, compiled under /// test through a throwaway world that imports the interface. Its value is /// the identifier-hygiene gate: the test names every generated type, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index d4228cbe..0f1057e9 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -575,7 +575,7 @@ impl VenueRegistry { Err(err) => { warn!( venue = %venue, - error = ?err, + error = %crate::bindings::venue_error_message(&err), "status poll failed - retrying on the next cadence", ); } @@ -737,7 +737,8 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - fault = ?e, + kind = nexum_runtime::host::error::fault_label(&e), + fault = %nexum_runtime::host::error::fault_message(&e), "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); diff --git a/crates/videre-sdk/src/body.rs b/crates/videre-sdk/src/body.rs index eb3a64dd..33c40378 100644 --- a/crates/videre-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -38,6 +38,7 @@ pub trait IntentBody: Sized + __private::Derived { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum BodyError { /// No bytes at all: not even a version tag. #[error("empty body: missing the version tag")] diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index baa40118..c3e9b1af 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -75,11 +75,20 @@ pub trait Venue { type Body: IntentBody; } +/// Sealing marker for [`VenueTransport`]: a transport opts in by also +/// implementing it. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} +} + /// The byte-level seam under the typed client: `videre:venue/client` /// with the venue named per call. Native AFIT, so a [`VenueClient`] /// over any transport dispatches statically. [`HostVenues`] binds it to /// the module's own import; tests implement it in memory. -pub trait VenueTransport { +/// +/// Sealed: a transport opts in by also implementing the sealing marker. +pub trait VenueTransport: sealed::SealedTransport { /// Price an opaque intent body at the named venue. fn quote( &self, @@ -117,6 +126,8 @@ pub trait VenueTransport { #[derive(Clone, Copy, Debug, Default)] pub struct HostVenues; +impl sealed::SealedTransport for HostVenues {} + impl VenueTransport for HostVenues { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { shims::quote(venue.as_str(), &body).map_err(VenueFault::from) diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index 00d6840c..ac57203f 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -163,9 +163,9 @@ impl From for VenueError { match err { FetchError::Denied => VenueError::Denied(err.to_string()), FetchError::Timeout(_) => VenueError::Timeout, - FetchError::Transport(_) | FetchError::InvalidRequest(_) => { - VenueError::Unavailable(err.to_string()) - } + // `FetchError` is `#[non_exhaustive]`: a future transport + // case folds to retryable `unavailable` with its detail. + _ => VenueError::Unavailable(err.to_string()), } } } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 3240faed..f94a88f8 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -237,6 +237,8 @@ mod tests { } } + impl crate::client::sealed::SealedTransport for &StubVenue {} + impl VenueTransport for &StubVenue { async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index 9c0814f9..0c490331 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -141,6 +141,8 @@ impl Venue for NowhereVenue { /// binds. struct InProcessClient; +impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} + impl VenueTransport for InProcessClient { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { if venue.as_str() != "demo" { diff --git a/crates/videre-test/src/fixture.rs b/crates/videre-test/src/fixture.rs index 624ec1c9..6b2b5118 100644 --- a/crates/videre-test/src/fixture.rs +++ b/crates/videre-test/src/fixture.rs @@ -54,6 +54,7 @@ where /// serde's rendered detail rather than the error value so the type /// stays independent of `serde_json`'s feature set. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum FixtureError { /// The file could not be read. #[error("failed to read {path}: {source}")] diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 09eb6980..3d3d80b7 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -86,7 +86,9 @@ fn fetch_err(url: &str, error: &FetchError) -> Fault { FetchError::Denied => Fault::Denied(detail), FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), FetchError::Timeout(_) => Fault::Timeout, - FetchError::Transport(_) => Fault::Unavailable(detail), + // `FetchError` is `#[non_exhaustive]`: a future case folds to + // retryable `unavailable` with its detail. + _ => Fault::Unavailable(detail), } }