diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed0f134..a158c6e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,8 @@ jobs: run: | cargo build --release --target wasm32-wasip2 --locked \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe \ - -p clock-reader -p flaky-bomb -p fuel-bomb \ + -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ + -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb \ -p memory-bomb -p panic-bomb { echo "### module .wasm sizes" diff --git a/.gitignore b/.gitignore index d34b1806..35532e08 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ skills-lock.json # Engine runtime state (default state_dir from engine.toml). data/ +# Shipped crate data slices are source, not runtime state: keep them. +!crates/*/data/ +!crates/*/data/** # E2E automation: rendered configs with embedded RPC keys + script state # never get committed. diff --git a/Cargo.lock b/Cargo.lock index db6e15e7..7ae97278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cow-venue" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-sdk", + "nexum-venue-sdk", + "serde", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "cowprotocol" version = "0.2.0" @@ -2138,6 +2150,23 @@ dependencies = [ "spki", ] +[[package]] +name = "echo-client" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "wit-bindgen 0.58.0", +] + +[[package]] +name = "echo-venue" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", + "nexum-venue-test", + "wit-bindgen 0.58.0", +] + [[package]] name = "educe" version = "0.6.0" @@ -2266,6 +2295,7 @@ dependencies = [ name = "example" version = "0.1.0" dependencies = [ + "nexum-sdk", "wit-bindgen 0.59.0", ] @@ -3545,6 +3575,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "nexum-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "nexum-runtime" version = "0.2.0" @@ -3591,9 +3631,10 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "http", + "nexum-macros", + "nexum-sdk-test", "proptest", "serde_json", - "shepherd-sdk-test", "strum", "thiserror 2.0.18", "tracing", @@ -3609,6 +3650,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-venue-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-macros", + "nexum-sdk", + "strum", + "thiserror 2.0.18", + "wit-bindgen 0.59.0", +] + +[[package]] +name = "nexum-venue-test" +version = "0.1.0" +dependencies = [ + "borsh", + "hex", + "http", + "nexum-sdk", + "nexum-sdk-test", + "nexum-venue-sdk", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5063,7 +5132,6 @@ dependencies = [ "nexum-sdk", "serde", "serde_json", - "shepherd-sdk", "shepherd-sdk-test", ] @@ -5099,17 +5167,24 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", + "serde_json", + "shepherd-sdk-test", "strum", "thiserror 2.0.18", + "tracing", ] [[package]] name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ + "alloy-primitives", + "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", @@ -5779,8 +5854,6 @@ dependencies = [ "serde_json", "shepherd-sdk", "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", "tracing", "wit-bindgen 0.59.0", ] @@ -6055,6 +6128,18 @@ dependencies = [ "wasmparser 0.253.0", ] +[[package]] +name = "wasm-metadata" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f998ccc6e012f7b86865eb2a106c8a0422017a1a88977ce01a69f2244be2e57" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", +] + [[package]] name = "wasm-metadata" version = "0.253.0" @@ -6795,13 +6880,33 @@ dependencies = [ "bitflags", ] +[[package]] +name = "wit-bindgen" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" +dependencies = [ + "wit-bindgen-rust-macro 0.58.0", +] + [[package]] name = "wit-bindgen" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" dependencies = [ - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.59.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.251.0", ] [[package]] @@ -6815,6 +6920,22 @@ dependencies = [ "wit-parser 0.253.0", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130ce1f531bc9f9a75922244aa773bf5e2117fda1ef4a86b9f98d6b8135eb46" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.118", + "wasm-metadata 0.251.0", + "wit-bindgen-core 0.58.0", + "wit-component 0.251.0", +] + [[package]] name = "wit-bindgen-rust" version = "0.59.0" @@ -6826,9 +6947,24 @@ dependencies = [ "indexmap 2.14.0", "prettyplease", "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.253.0", + "wit-bindgen-core 0.59.0", + "wit-component 0.253.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07296369e4d598e7e79b64eef66f724d83324ea671bcf677d78fc5cf92604ae5" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core 0.58.0", + "wit-bindgen-rust 0.58.0", ] [[package]] @@ -6842,8 +6978,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.59.0", + "wit-bindgen-rust 0.59.0", +] + +[[package]] +name = "wit-component" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a5e60173c413659c689f0581b0cf5d1a2404077568f9ffdce748a9eb2fc913" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.251.0", + "wasm-metadata 0.251.0", + "wasmparser 0.251.0", + "wit-parser 0.251.0", ] [[package]] @@ -6860,7 +7015,7 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.253.0", - "wasm-metadata", + "wasm-metadata 0.253.0", "wasmparser 0.253.0", "wit-parser 0.253.0", ] diff --git a/Cargo.toml b/Cargo.toml index 24b465db..d462ee44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,13 @@ [workspace] members = [ + "crates/cow-venue", "crates/nexum-cli", + "crates/nexum-macros", "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-venue-sdk", + "crates/nexum-venue-test", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", @@ -11,6 +15,8 @@ members = [ "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", + "modules/examples/echo-client", + "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", "modules/examples/stop-loss", @@ -51,6 +57,12 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Borsh wire codec behind the venue SDK's versioned `IntentBody` bodies. +# The venue SDK re-exports the runtime crate for its derive's generated +# code; `derive` is on so venue payload types can `#[derive(BorshSerialize, +# BorshDeserialize)]` through the same dependency. +borsh = { version = "1", features = ["derive"] } + # Observability. tracing = "0.1" # `tracing-core` alone (no subscriber registry) backs the guest-side @@ -115,6 +127,13 @@ http-body = "1" http-body-util = "0.1" bytes = "1" +# Proc-macro toolkit backing `nexum-macros`. Host-side only: the +# proc-macro crate always builds for the host, even when the module +# consuming it targets wasm. +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + # `wit-bindgen` is consumed by every guest module crate (example + # every strategy + every fixture). Hoisted so a single bump moves # them in lock-step. diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml new file mode 100644 index 00000000..975ea7a9 --- /dev/null +++ b/crates/cow-venue/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "cow-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "CoW venue slices. The default `body` slice carries the venue-neutral order/composable intent body types and their borsh IntentBody codec, linkable by adapters and modules." + +[lib] +# Plain library. The default `body` slice is dependency-light so a venue +# adapter component or a strategy module can link the intent body types +# and codec without the host-side CoW machinery. + +[lints] +workspace = true + +[dependencies] +# Payload structs derive the borsh traits directly, so the crate carries +# its own borsh declaration (the IntentBody derive reaches borsh through +# the venue SDK re-export, but the payload derives need it by name). +borsh = { workspace = true, optional = true } +# Source of the `IntentBody` derive and trait the version enum implements, +# and the typed intent client the `client` slice binds to the CoW venue. +nexum-venue-sdk = { path = "../nexum-venue-sdk", optional = true } +# `client` slice only: the keeper `RetryAction` the generated +# classification table maps each errorType to. The TOML parse happens in +# `build.rs`, so serde/toml/thiserror are build- and dev-only and never +# reach a guest that links this slice. +nexum-sdk = { path = "../nexum-sdk", optional = true } + +# `build.rs` parses `data/classification.toml` and emits the static +# lookup table; the same parse is shared with the parity tests below. +[build-dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } + +[features] +# The body-type + codec slice ships by default; the `client` slice layers +# the typed client and the table-driven retry classification on top. +# `--no-default-features` drops both to an empty crate so downstream can +# depend on a future `adapter` slice without pulling the codec or the +# keeper transitively. +default = ["body"] +body = ["dep:borsh", "dep:nexum-venue-sdk"] +client = ["body", "dep:nexum-sdk"] diff --git a/crates/cow-venue/build.rs b/crates/cow-venue/build.rs new file mode 100644 index 00000000..10122c97 --- /dev/null +++ b/crates/cow-venue/build.rs @@ -0,0 +1,43 @@ +//! Turn the shipped `data/classification.toml` into a generated lookup +//! table at build time, so the runtime `client` slice (and any wasm +//! guest that links it) carries no TOML parser. The parse and the table +//! invariants live in `src/classification_data.rs`, shared verbatim with +//! the crate's parity tests. + +#[path = "src/classification_data.rs"] +mod classification_data; + +use std::{env, fs, path::Path}; + +use classification_data::{Action, parse_and_validate}; + +fn main() { + let manifest = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let data = Path::new(&manifest).join("data/classification.toml"); + println!("cargo:rerun-if-changed={}", data.display()); + println!("cargo:rerun-if-changed=src/classification_data.rs"); + + let toml = fs::read_to_string(&data).expect("read data/classification.toml"); + let entries = + parse_and_validate(&toml).expect("shipped cow classification.toml is well formed"); + + let mut out = String::new(); + out.push_str("// @generated from data/classification.toml by build.rs; do not edit.\n"); + out.push_str("static GENERATED_ROWS: &[GeneratedRow] = &[\n"); + for e in &entries { + let action = match e.action { + Action::TryNextBlock => "GenAction::TryNextBlock", + Action::Backoff => "GenAction::Backoff", + Action::Drop => "GenAction::Drop", + }; + out.push_str(&format!( + " GeneratedRow {{ error_type: {:?}, action: {action}, \ + backoff_seconds: {}, already_submitted: {} }},\n", + e.error_type, e.backoff_seconds, e.already_submitted, + )); + } + out.push_str("];\n"); + + let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("classification_table.rs"); + fs::write(&dest, out).expect("write generated classification table"); +} diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml new file mode 100644 index 00000000..46507d48 --- /dev/null +++ b/crates/cow-venue/data/classification.toml @@ -0,0 +1,127 @@ +# CoW orderbook order-submission retry classification. +# +# This file is the source of truth for how a submitted order's rejection +# `errorType` maps to a retry action. It is plain TOML: a non-Rust author +# (or any TOML reader in any language) can add, remove, or re-target an +# entry without touching Rust. The `cow-venue` `client` slice embeds and +# parses this exact file, and a parity test guards the Rust contract +# against it. +# +# Each entry names one orderbook `errorType` and the action the retry +# ledger takes when the orderbook returns it: +# +# action = "try-next-block" Transient: a fresh submission on a later +# block may succeed. Leave the watch in +# place. +# action = "backoff" Server- or account-throttle: retrying next +# block cannot clear the condition. Gate the +# watch for `backoff-seconds` before the next +# attempt. `backoff-seconds` is required and +# must be at least 1. +# action = "drop" Permanent: no retry can succeed. Remove the +# watch and its gates. +# +# `already-submitted = true` marks a rejection that means the orderbook +# already holds this exact order. It is success wearing an error status, +# so the action is "try-next-block" (never drop, which would kill every +# future tranche of a TWAP) and the submit path records the submitted +# receipt so the next tick short-circuits instead of re-posting. +# +# Any `errorType` absent from this table classifies as "drop": an +# unrecognised, structured contract-level rejection is treated as +# permanent rather than retried every block forever. +# +# Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream +# `cowprotocol` crate (a shepherd-sdk dependency) also classifies +# orderbook `errorType`s, via `RetryHint`. This table is deliberately +# NOT delegated to it: it is shepherd's own, more conservative retry +# policy, kept as data of record here so a non-Rust author owns it and +# so the guest `client` slice stays free of the upstream error module. +# The two intentionally diverge on several types - e.g. this table drops +# `InvalidEip1271Signature`, `InsufficientBalance`, `InsufficientAllowance` +# and `InvalidAppData` where upstream retries or backs off, and backs off +# `TooManyLimitOrders` for 30s rather than an hour. These are ratified +# shepherd decisions (a permanent-looking contract rejection is dropped +# rather than retried every block); revisit them here, not by switching +# the source of truth to `RetryHint`. + +# --- Transient: retry on the next block ------------------------------ + +[[entry]] +error-type = "InsufficientFee" +action = "try-next-block" + +[[entry]] +error-type = "PriceExceedsMarketPrice" +action = "try-next-block" + +# --- Throttle: wait, then retry -------------------------------------- + +# The account already holds the maximum number of open limit orders. A +# next-block retry cannot help: the slot only frees when an existing +# order settles or expires, so back off and re-check rather than hammer +# the orderbook every block. +[[entry]] +error-type = "TooManyLimitOrders" +action = "backoff" +backoff-seconds = 30 + +# --- Already submitted: keep the watch, record the receipt ----------- + +# The orderbook's canonical spelling. +[[entry]] +error-type = "DuplicatedOrder" +action = "try-next-block" +already-submitted = true + +# The spelling older deployments emit; classify identically. +[[entry]] +error-type = "DuplicateOrder" +action = "try-next-block" +already-submitted = true + +# --- Permanent: drop the watch --------------------------------------- +# +# Listed for documentation; each is also the default for any unlisted +# `errorType`. Flip one to "backoff" or "try-next-block" here to change +# the policy without touching Rust. + +[[entry]] +error-type = "InvalidSignature" +action = "drop" + +[[entry]] +error-type = "InvalidEip1271Signature" +action = "drop" + +[[entry]] +error-type = "WrongOwner" +action = "drop" + +[[entry]] +error-type = "InsufficientBalance" +action = "drop" + +[[entry]] +error-type = "InsufficientAllowance" +action = "drop" + +[[entry]] +error-type = "UnsupportedToken" +action = "drop" + +[[entry]] +error-type = "InvalidAppData" +action = "drop" + +[[entry]] +error-type = "AppDataHashMismatch" +action = "drop" + +[[entry]] +error-type = "ZeroAmount" +action = "drop" + +[[entry]] +error-type = "SameBuyAndSellToken" +action = "drop" diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs new file mode 100644 index 00000000..3cad2c1e --- /dev/null +++ b/crates/cow-venue/src/body.rs @@ -0,0 +1,122 @@ +//! The CoW intent body and its versioned `IntentBody` codec. +//! +//! A CoW intent is either a direct order or a composable (conditional) +//! order; [`CowIntent`] is that sum. [`CowIntentBody`] is the outer +//! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` +//! gives it the borsh codec: a one-byte version tag plus the borsh +//! payload, with an unknown tag failing as a typed +//! [`BodyError`](nexum_venue_sdk::BodyError) rather than a stringly borsh +//! error. The one non-obvious invariant: the tag order is the schema, so +//! new versions append at the end and no variant is ever reordered or +//! removed. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_venue_sdk::IntentBody; + +use crate::composable::ComposableBody; +use crate::order::OrderBody; + +/// What the CoW venue accepts: a direct order or a conditional order. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub enum CowIntent { + /// A direct `GPv2Order` to place on the orderbook. + Order(OrderBody), + /// A ComposableCoW conditional order that mints tradeable orders. + Composable(ComposableBody), +} + +/// The outer per-venue version enum: the schema the CoW venue publishes. +/// Tag order is the schema; append new versions, never reorder. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum CowIntentBody { + /// First published version: a [`CowIntent`] sum. + V1(CowIntent), +} + +#[cfg(test)] +mod tests { + use super::*; + use nexum_venue_sdk::BodyError; + + use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; + + 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, + } + } + + fn composable_body() -> ComposableBody { + ComposableBody { + handler: [0xab; 20], + salt: [0xcd; 32], + static_input: vec![9, 8, 7], + } + } + + #[test] + fn version_body_round_trips_through_the_derive() { + for intent in [ + CowIntent::Order(order_body()), + CowIntent::Composable(composable_body()), + ] { + let body = CowIntentBody::V1(intent); + let bytes = body.to_bytes().expect("derived payload encodes"); + assert_eq!(CowIntentBody::from_bytes(&bytes).unwrap(), body); + } + } + + #[test] + fn wire_tag_is_the_declaration_index() { + let bytes = CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .unwrap(); + assert_eq!(bytes[0], 0); + } + + #[test] + fn unknown_version_fails_typedly() { + let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .unwrap(); + bytes[0] = 9; + assert_eq!( + CowIntentBody::from_bytes(&bytes), + Err(BodyError::UnknownVersion { version: 9 }) + ); + } + + #[test] + fn empty_and_malformed_bodies_fail_typedly() { + assert_eq!(CowIntentBody::from_bytes(&[]), Err(BodyError::Empty)); + + let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .unwrap(); + bytes.truncate(bytes.len() - 1); + assert!(matches!( + CowIntentBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 0, .. }) + )); + + let mut bytes = CowIntentBody::V1(CowIntent::Composable(composable_body())) + .to_bytes() + .unwrap(); + bytes.push(0); + assert!(matches!( + CowIntentBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 0, .. }) + )); + } +} diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs new file mode 100644 index 00000000..73d0e71f --- /dev/null +++ b/crates/cow-venue/src/classification.rs @@ -0,0 +1,251 @@ +//! Table-driven CoW retry classification. +//! +//! The `errorType -> {try-next-block, backoff, drop}` policy is shipped +//! as data in `data/classification.toml`. `build.rs` parses and +//! validates that file at build time and emits a static lookup table, so +//! [`classify`] and [`is_already_submitted`] read generated data rather +//! than a hand-coded `match` and no TOML parser reaches the guest. A +//! non-Rust author edits the TOML; a parity test re-parses the same file +//! and asserts the generated table agrees. +//! +//! The one non-obvious invariant: an `errorType` absent from the table +//! classifies as [`RetryAction::Drop`]. An unrecognised structured +//! rejection is a permanent contract-level refusal, not a transient +//! transport error, so it must not be retried every block forever. + +use nexum_sdk::keeper::RetryAction; + +/// The shipped classification data, embedded verbatim so a parity test +/// can re-parse the exact bytes `build.rs` generated the table from. +pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml"); + +/// The retry action a generated row selects, mirroring the TOML `action` +/// field. Turned into a keeper [`RetryAction`] by [`GeneratedRow`]. +/// +/// The variants are constructed only by the build-generated table, so a +/// shipped `classification.toml` that happens to carry no row of a given +/// action leaves that variant unconstructed. `allow(dead_code)` keeps +/// such a data edit from tripping the `-D warnings` build: which actions +/// appear is a property of the data, not the code. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GenAction { + TryNextBlock, + Backoff, + Drop, +} + +/// One classification row, generated from the shipped data table. +#[derive(Clone, Copy, Debug)] +struct GeneratedRow { + error_type: &'static str, + action: GenAction, + backoff_seconds: u64, + already_submitted: bool, +} + +impl GeneratedRow { + fn retry_action(&self) -> RetryAction { + match self.action { + GenAction::TryNextBlock => RetryAction::TryNextBlock, + // `build.rs` validated backoff rows non-zero; `.max(1)` keeps + // the mapping total even if that guard is ever relaxed. + GenAction::Backoff => RetryAction::Backoff { + seconds: self.backoff_seconds.max(1), + }, + GenAction::Drop => RetryAction::Drop, + } + } +} + +// `static GENERATED_ROWS: &[GeneratedRow]`, one row per TOML entry. +include!(concat!(env!("OUT_DIR"), "/classification_table.rs")); + +/// The shipped classification: a lookup over the generated rows. +/// Unlisted types classify as [`RetryAction::Drop`]. +#[derive(Clone, Copy, Debug)] +pub struct ClassificationTable { + rows: &'static [GeneratedRow], +} + +impl ClassificationTable { + fn row(&self, error_type: &str) -> Option<&GeneratedRow> { + self.rows.iter().find(|r| r.error_type == error_type) + } + + /// The retry action for an orderbook `errorType`. Unlisted types are + /// permanent: [`RetryAction::Drop`]. + pub fn classify(&self, error_type: &str) -> RetryAction { + self.row(error_type) + .map_or(RetryAction::Drop, GeneratedRow::retry_action) + } + + /// Whether the orderbook is reporting that it already holds this + /// exact order. Such a rejection keeps the watch and records the + /// receipt rather than retrying a fresh submission. + pub fn is_already_submitted(&self, error_type: &str) -> bool { + self.row(error_type).is_some_and(|r| r.already_submitted) + } + + /// Number of classified `errorType`s, for tests and diagnostics. + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether the table carries no entries. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// The classification table generated from the shipped data. +pub fn table() -> ClassificationTable { + ClassificationTable { + rows: GENERATED_ROWS, + } +} + +/// Classify an orderbook `errorType` into a keeper [`RetryAction`] via +/// the shipped table. Unlisted types are permanent ([`RetryAction::Drop`]). +pub fn classify(error_type: &str) -> RetryAction { + table().classify(error_type) +} + +/// Whether an orderbook `errorType` means the order is already held. +pub fn is_already_submitted(error_type: &str) -> bool { + table().is_already_submitted(error_type) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classification_data::{Action, ClassificationError, parse_and_validate}; + + /// The generated table is non-empty. + #[test] + fn shipped_data_parses() { + assert!(!table().is_empty()); + } + + /// Data-vs-code parity: re-parse the shipped file independently and + /// assert the generated table (code) agrees with it (data) on every + /// entry. This catches a data edit the generated table missed and a + /// generator bug that drifts from the file. + #[test] + fn data_matches_code_contract() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + assert_eq!(table().len(), entries.len(), "row count matches the data"); + for entry in &entries { + let expected = match entry.action { + Action::TryNextBlock => RetryAction::TryNextBlock, + Action::Backoff => RetryAction::Backoff { + seconds: entry.backoff_seconds.max(1), + }, + Action::Drop => RetryAction::Drop, + }; + assert_eq!( + classify(&entry.error_type), + expected, + "classify {}", + entry.error_type, + ); + assert_eq!( + is_already_submitted(&entry.error_type), + entry.already_submitted, + "already-submitted {}", + entry.error_type, + ); + } + } + + /// A spot check of the contract in code, independent of the parse: + /// the exemplar rows the slice must carry, including the `Backoff` + /// producer the hand-coded classifier lacked. + #[test] + fn known_rows_classify_as_documented() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert_eq!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { seconds: 30 }, + ); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + assert!(is_already_submitted("DuplicatedOrder")); + assert!(is_already_submitted("DuplicateOrder")); + } + + /// Unlisted (including newly minted) types are permanent, so a + /// contract-level rejection is never retried every block forever. + #[test] + fn unlisted_type_drops() { + assert_eq!(classify("NewlyMintedErrorType"), RetryAction::Drop); + assert!(!is_already_submitted("NewlyMintedErrorType")); + } + + /// All three retry arms are reachable from the table alone. + #[test] + fn table_reaches_every_arm() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert!(matches!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { .. } + )); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + } + + #[test] + fn duplicate_type_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Dup" + action = "drop" + [[entry]] + error-type = "Dup" + action = "drop" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::Duplicate("Dup".to_string()), + ); + } + + #[test] + fn backoff_without_delay_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Slow" + action = "backoff" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::ZeroBackoff("Slow".to_string()), + ); + } + + #[test] + fn already_submitted_must_try_next_block() { + let toml = r#" + [[entry]] + error-type = "Held" + action = "drop" + already-submitted = true + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::AlreadySubmittedAction("Held".to_string()), + ); + } + + /// A non-Rust reader sees the same file as plain data: parsing it + /// with the untyped TOML value model (no Rust schema) exposes the + /// entries and their fields, proving any TOML library reads it. + #[test] + fn non_rust_reader_sees_plain_toml() { + let value: toml::Table = + toml::from_str(CLASSIFICATION_TOML).expect("valid TOML for any reader"); + let entries = value["entry"].as_array().expect("entry is an array"); + assert!(!entries.is_empty()); + let first = entries[0].as_table().expect("entry is a table"); + assert!(first.contains_key("error-type")); + assert!(first.contains_key("action")); + } +} diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs new file mode 100644 index 00000000..3938eddd --- /dev/null +++ b/crates/cow-venue/src/classification_data.rs @@ -0,0 +1,87 @@ +//! Parse and validate the shipped classification data. +//! +//! This module is the single source of the TOML schema and the table +//! invariants. It is compiled twice, never into a guest: `build.rs` +//! includes it to turn `data/classification.toml` into a generated +//! lookup table at build time, and the crate's own tests include it to +//! re-parse the same file and assert the generated table agrees. The +//! runtime `client` slice carries only the generated table, so no TOML +//! parser reaches the wasm guest. + +use serde::Deserialize; + +/// One of the three retry actions an `errorType` maps to on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + /// Transient: a fresh submission on a later block may succeed. + TryNextBlock, + /// Throttle: gate the watch for `backoff_seconds` before retrying. + Backoff, + /// Permanent: remove the watch and its gates. + Drop, +} + +/// A single classification row as it appears in the TOML. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Entry { + /// The orderbook `errorType` this row classifies. + pub error_type: String, + /// The retry action the row selects. + pub action: Action, + /// Required (and meaningful) only for `action = "backoff"`. + #[serde(default)] + pub backoff_seconds: u64, + /// Marks a rejection meaning the orderbook already holds this order. + #[serde(default)] + pub already_submitted: bool, +} + +#[derive(Debug, Deserialize)] +struct Document { + #[serde(default)] + entry: Vec, +} + +/// Why the shipped classification data could not be turned into a table. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ClassificationError { + /// The TOML did not parse or a field had the wrong type. + #[error("classification data is not valid TOML: {0}")] + Toml(String), + /// Two entries named the same `errorType`. + #[error("duplicate errorType `{0}` in classification data")] + Duplicate(String), + /// A `backoff` entry left `backoff-seconds` at zero (or absent). + #[error("errorType `{0}` is backoff but backoff-seconds is not >= 1")] + ZeroBackoff(String), + /// An `already-submitted` entry did not classify as try-next-block. + #[error("errorType `{0}` is already-submitted but action is not try-next-block")] + AlreadySubmittedAction(String), +} + +/// Parse a classification document and validate the table invariants: no +/// duplicate `errorType`, every `backoff` carries a positive delay, and +/// `already-submitted` implies `try-next-block`. +pub fn parse_and_validate(toml: &str) -> Result, ClassificationError> { + let doc: Document = + toml::from_str(toml).map_err(|e| ClassificationError::Toml(e.to_string()))?; + + let mut seen: Vec<&str> = Vec::with_capacity(doc.entry.len()); + for entry in &doc.entry { + if entry.action == Action::Backoff && entry.backoff_seconds == 0 { + return Err(ClassificationError::ZeroBackoff(entry.error_type.clone())); + } + if entry.already_submitted && entry.action != Action::TryNextBlock { + return Err(ClassificationError::AlreadySubmittedAction( + entry.error_type.clone(), + )); + } + if seen.contains(&entry.error_type.as_str()) { + return Err(ClassificationError::Duplicate(entry.error_type.clone())); + } + seen.push(&entry.error_type); + } + Ok(doc.entry) +} diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs new file mode 100644 index 00000000..81bd4e5c --- /dev/null +++ b/crates/cow-venue/src/client.rs @@ -0,0 +1,128 @@ +//! The typed CoW intent client. +//! +//! [`CowClient`] binds the strategy-facing [`IntentClient`] to the CoW +//! venue id and speaks the venue's own [`CowIntentBody`] over it, so +//! strategy code submits a typed CoW body without naming the venue on +//! every call or handling wire bytes. The classification API +//! ([`classify`](crate::classification::classify)) travels in the same +//! slice so the client that submits an order and the table that +//! classifies its rejection version together. + +use nexum_venue_sdk::client::{ClientError, IntentClient, IntentPool}; +use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; + +use crate::body::CowIntentBody; + +/// The venue id the CoW adapter registers under and the router resolves. +/// Every [`CowClient`] call routes here. +pub const VENUE: &str = "cow"; + +/// A typed intent client pre-bound to the CoW venue. A thin newtype over +/// [`IntentClient`] that fixes the venue id and the body type so callers +/// cannot mis-route or submit a foreign body. +#[derive(Clone, Debug)] +pub struct CowClient

{ + inner: IntentClient

, +} + +impl CowClient

{ + /// Bind a pool handle to the CoW venue. + pub fn new(pool: P) -> Self { + Self { + inner: IntentClient::new(pool, VENUE), + } + } + + /// The venue id every call routes to (always [`VENUE`]). + pub fn venue(&self) -> &str { + self.inner.venue() + } + + /// Encode a typed CoW body and submit it to the venue. + pub fn submit(&self, body: &CowIntentBody) -> Result { + self.inner.submit(body) + } + + /// Report where a previously submitted intent is in its life. + pub fn status(&self, receipt: &[u8]) -> Result { + self.inner.status(receipt) + } + + /// Ask the venue to withdraw an intent. + pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + self.inner.cancel(receipt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nexum_venue_sdk::VenueError; + use std::cell::RefCell; + use std::rc::Rc; + + /// One recorded submit: the venue it routed to and the wire bytes. + type SubmitLog = Rc)>>>; + + /// Records the venue every call routed to and the bytes submitted. + /// Cloneable over a shared log so the test can inspect it after the + /// pool moves into the client. + #[derive(Clone, Default)] + struct SpyPool { + submitted: SubmitLog, + } + + impl IntentPool for SpyPool { + fn submit(&self, venue: &str, body: Vec) -> Result { + self.submitted + .borrow_mut() + .push((venue.to_string(), body.clone())); + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(&self, _venue: &str, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + fn cancel(&self, _venue: &str, _receipt: &[u8]) -> Result<(), VenueError> { + unreachable!("cancel not exercised") + } + } + + 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, + })) + } + + #[test] + fn submit_routes_to_the_cow_venue_with_encoded_body() { + use nexum_venue_sdk::IntentBody; + + let pool = SpyPool::default(); + let body = sample_body(); + let expected = body.to_bytes().expect("body encodes"); + + let client = CowClient::new(pool.clone()); + assert_eq!(client.venue(), VENUE); + client.submit(&body).expect("submit succeeds"); + + let calls = pool.submitted.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, VENUE); + assert_eq!(calls[0].1, expected); + } +} diff --git a/crates/cow-venue/src/composable.rs b/crates/cow-venue/src/composable.rs new file mode 100644 index 00000000..c3535865 --- /dev/null +++ b/crates/cow-venue/src/composable.rs @@ -0,0 +1,56 @@ +//! The venue-neutral composable (conditional) order body. +//! +//! ComposableCoW expresses a conditional order as the +//! `ConditionalOrderParams` tuple: the handler contract that mints the +//! tradeable order, a salt that distinguishes otherwise-identical +//! conditional orders, and the opaque handler-specific static input. This +//! body type is that tuple in wire form. The one non-obvious invariant: +//! `static_input` is opaque to the venue; only the named handler parses +//! it, so this crate never inspects its bytes. + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::order::Address; + +/// The venue-neutral conditional order body: `ConditionalOrderParams` in +/// wire form. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct ComposableBody { + /// The `IConditionalOrder` handler that mints the tradeable order. + pub handler: Address, + /// Salt distinguishing otherwise-identical conditional orders. + pub salt: [u8; 32], + /// Handler-specific static input; opaque to the venue. + pub static_input: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ComposableBody { + ComposableBody { + handler: [0xab; 20], + salt: [0xcd; 32], + static_input: vec![1, 2, 3, 4, 5], + } + } + + #[test] + fn composable_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); + } + + #[test] + fn empty_static_input_round_trips() { + let mut body = sample(); + body.static_input = Vec::new(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(ComposableBody::try_from_slice(&bytes).unwrap(), body); + } +} diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs new file mode 100644 index 00000000..1fa8a521 --- /dev/null +++ b/crates/cow-venue/src/lib.rs @@ -0,0 +1,64 @@ +//! # cow-venue +//! +//! The CoW venue, staged as a crate of feature slices. Today only the +//! default [`body`] slice exists: the venue-neutral order and composable +//! intent body types and the borsh `IntentBody` codec over them. The +//! typed client and the adapter component are later slices. +//! +//! The body slice is dependency-light on purpose. It links only the +//! venue SDK (for the [`IntentBody`](nexum_venue_sdk::IntentBody) derive) +//! and borsh, so a venue adapter component or a strategy module can carry +//! the body types and codec without dragging in the host-side CoW +//! machinery. The one non-obvious constraint: the derive's generated code +//! names `::std`, so the slice links std and is not a bare-metal +//! `#![no_std]` crate; it is guest-consumable on the runtime's +//! std-bearing wasm target rather than target-free. +//! +//! With `--no-default-features` the slice drops out entirely and the +//! crate compiles empty, so a consumer can depend on a future slice +//! without pulling the codec transitively. +//! +//! The `client` slice layers on top: a typed [`CowClient`] bound to the +//! CoW venue plus the table-driven retry [`classification`] generated at +//! build time from the shipped `data/classification.toml` (the TOML +//! parser stays a build-time dependency, off the guest). It links the +//! strategy keeper (for the retry action type) and is off by default, +//! so an adapter or a module that wants only the body types stays +//! dependency-light. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[cfg(feature = "body")] +pub mod body; + +#[cfg(feature = "body")] +pub mod composable; + +#[cfg(feature = "body")] +pub mod order; + +#[cfg(feature = "client")] +pub mod classification; + +// The shared TOML parse and table invariants. `build.rs` includes this +// file to generate the classification table; the crate links it only in +// tests, to re-parse the shipped data and check parity. It never reaches +// a guest. +#[cfg(all(feature = "client", test))] +mod classification_data; + +#[cfg(feature = "client")] +pub mod client; + +#[cfg(feature = "body")] +pub use body::{CowIntent, CowIntentBody}; +#[cfg(feature = "body")] +pub use composable::ComposableBody; +#[cfg(feature = "body")] +pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; + +#[cfg(feature = "client")] +pub use classification::{ClassificationTable, classify, is_already_submitted}; +#[cfg(feature = "client")] +pub use client::{CowClient, VENUE}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs new file mode 100644 index 00000000..cc31b934 --- /dev/null +++ b/crates/cow-venue/src/order.rs @@ -0,0 +1,141 @@ +//! The venue-neutral CoW order body. +//! +//! On the wire a CoW order is the 12-field `GPv2Order` tuple. The +//! host-side path speaks it through the on-chain alloy types; this body +//! type is the same shape reduced to plain wire primitives (byte arrays +//! for addresses and 256-bit amounts, small enums for the balance and +//! kind markers) so it borsh-encodes and links without the on-chain +//! stack. The one non-obvious invariant: `amount`, `receiver`, and the +//! 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 borsh::{BorshDeserialize, BorshSerialize}; + +/// A 20-byte EVM address in wire form. +pub type Address = [u8; 20]; + +/// A 256-bit amount as its 32-byte big-endian representation. +pub type U256 = [u8; 32]; + +/// Which side of the trade is fixed. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderKind { + /// Sell a fixed `sell_amount`; `buy_amount` is the limit. + Sell, + /// Buy a fixed `buy_amount`; `sell_amount` is the limit. + Buy, +} + +/// Where the settlement pulls the sell token from. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum SellTokenSource { + /// Ordinary ERC-20 `transferFrom`. + Erc20, + /// Balancer external balance. + External, + /// Balancer internal balance. + Internal, +} + +/// Where the settlement delivers the buy token. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum BuyTokenDestination { + /// Ordinary ERC-20 transfer. + Erc20, + /// Balancer internal balance. + Internal, +} + +/// The venue-neutral order body: the `GPv2Order` fields in wire form. +/// +/// `receiver` is `None` for the self-receive default the orderbook +/// normalises the zero address to; the adapter round-trips that +/// normalisation on the chain edge. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct OrderBody { + /// Token the owner sells. + pub sell_token: Address, + /// Token the owner buys. + pub buy_token: Address, + /// Recipient of the buy token; `None` sends it back to the owner. + pub receiver: Option

, + /// Sell amount, or its limit when `kind` is `Buy`. + pub sell_amount: U256, + /// Buy amount, or its limit when `kind` is `Sell`. + pub buy_amount: U256, + /// Unix-seconds expiry. + pub valid_to: u32, + /// The 32-byte on-chain app-data hash. + pub app_data: [u8; 32], + /// Fee amount taken in the sell token. + pub fee_amount: U256, + /// Which side is fixed. + pub kind: OrderKind, + /// Whether the order may partially fill. + pub partially_fillable: bool, + /// Where the sell token is sourced from. + pub sell_token_balance: SellTokenSource, + /// Where the buy token is delivered to. + pub buy_token_balance: BuyTokenDestination, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> OrderBody { + OrderBody { + sell_token: [0x11; 20], + buy_token: [0x22; 20], + receiver: Some([0x33; 20]), + sell_amount: { + let mut a = [0u8; 32]; + a[31] = 0x2a; + a + }, + buy_amount: [0xff; 32], + valid_to: 0xffff_ffff, + app_data: [0x44; 32], + fee_amount: [0u8; 32], + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + } + } + + #[test] + fn order_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).expect("decode"), body); + } + + #[test] + fn none_receiver_round_trips() { + let mut body = sample(); + body.receiver = None; + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap().receiver, None); + } + + #[test] + fn marker_enums_round_trip() { + for kind in [OrderKind::Sell, OrderKind::Buy] { + for sell in [ + SellTokenSource::Erc20, + SellTokenSource::External, + SellTokenSource::Internal, + ] { + for buy in [BuyTokenDestination::Erc20, BuyTokenDestination::Internal] { + let mut body = sample(); + body.kind = kind; + body.sell_token_balance = sell; + body.buy_token_balance = buy; + let bytes = borsh::to_vec(&body).unwrap(); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap(), body); + } + } + } + } +} diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml new file mode 100644 index 00000000..ad74099f --- /dev/null +++ b/crates/nexum-macros/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nexum-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export; derive(IntentBody) emits the venue SDK's versioned body codec." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } +toml.workspace = true diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs new file mode 100644 index 00000000..79b074cd --- /dev/null +++ b/crates/nexum-macros/src/intent_body.rs @@ -0,0 +1,134 @@ +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a +//! per-venue version enum. +//! +//! The derive enforces the outer-enum shape at compile time (an enum of +//! newtype variants, one published body version per variant) and emits +//! `to_bytes` / `from_bytes` whose wire form is the borsh enum layout: a +//! one-byte version tag (the variant's declaration index) followed by the +//! borsh-encoded payload. Decoding matches the tag itself, so an unknown +//! version surfaces as the typed `BodyError::UnknownVersion` rather than +//! a stringly borsh error, and a known version delegates the payload to +//! its type's `BorshDeserialize`. +//! +//! Generated code names the venue SDK by its crate path +//! (`::nexum_venue_sdk`), so the derive is only usable through that +//! crate's re-export. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +/// Expand the derive input into the `IntentBody` impl, or a compile +/// error naming the shape rule the input broke. +pub(crate) fn expand(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[derive(IntentBody)] does not support generic version enums: a wire schema has \ + exactly one shape", + )); + } + + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] applies to the outer per-venue version enum: an enum with one \ + newtype variant per published body version", + )); + }; + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] needs at least one version variant", + )); + } + if data.variants.len() > usize::from(u8::MAX) + 1 { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] supports at most 256 versions: the wire tag is one byte", + )); + } + + let mut encode_arms = Vec::with_capacity(data.variants.len()); + let mut decode_arms = Vec::with_capacity(data.variants.len()); + for (index, variant) in data.variants.iter().enumerate() { + if let Some((eq, _)) = &variant.discriminant { + return Err(syn::Error::new_spanned( + eq, + "#[derive(IntentBody)] does not support explicit discriminants: the version tag \ + is the variant's declaration index, so append new versions at the end", + )); + } + let payload_ty = match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0].ty, + _ => { + return Err(syn::Error::new_spanned( + &variant.ident, + "#[derive(IntentBody)] version variants carry exactly one unnamed payload \ + field, e.g. `V1(BodyV1)`", + )); + } + }; + + let ident = &variant.ident; + let tag = proc_macro2::Literal::u8_suffixed( + u8::try_from(index).expect("variant count checked above"), + ); + + encode_arms.push(quote! { + Self::#ident(payload) => { + let mut out = ::std::vec::Vec::new(); + out.push(#tag); + ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( + |err| ::nexum_venue_sdk::body::BodyError::Encode { + version: #tag, + detail: ::std::string::ToString::to_string(&err), + }, + )?; + ::core::result::Result::Ok(out) + } + }); + decode_arms.push(quote! { + #tag => ::core::result::Result::Ok(Self::#ident( + ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) + .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { + version: #tag, + detail: ::std::string::ToString::to_string(&err), + })?, + )), + }); + } + + Ok(quote! { + #[automatically_derived] + impl ::nexum_venue_sdk::body::IntentBody for #name { + fn to_bytes( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec, + ::nexum_venue_sdk::body::BodyError, + > { + match self { + #(#encode_arms)* + } + } + + fn from_bytes( + bytes: &[u8], + ) -> ::core::result::Result { + let (version, payload) = bytes + .split_first() + .ok_or(::nexum_venue_sdk::body::BodyError::Empty)?; + match *version { + #(#decode_arms)* + version => ::core::result::Result::Err( + ::nexum_venue_sdk::body::BodyError::UnknownVersion { version }, + ), + } + } + } + }) +} diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs new file mode 100644 index 00000000..c760b37f --- /dev/null +++ b/crates/nexum-macros/src/lib.rs @@ -0,0 +1,547 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a +//! per-module world derived from the crate's `module.toml` +//! `[capabilities]` declarations, the host adapter (via +//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation +//! whose `on-event` dispatches to the handlers present, and `export!`. +//! +//! [`venue`] is the adapter counterpart: it emits the same per-cdylib +//! wit-bindgen and `export!`, but for a per-component venue-adapter +//! world exporting the `nexum:intent/adapter` face and importing only +//! the manifest's declared scoped transport. +//! +//! [`derive@IntentBody`] implements the venue SDK's versioned body codec +//! over a per-venue version enum. +//! +//! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, +//! `nexum_venue_sdk::venue`, `nexum_venue_sdk::IntentBody`) rather than +//! depending on this crate directly. + +mod intent_body; +mod world; + +use std::path::Path; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, ImplItem, ItemImpl, Type}; + +/// Derive the venue SDK's `IntentBody` codec on the outer per-venue +/// version enum: one newtype variant per published body version, each +/// payload a borsh type. +/// +/// The wire form is the borsh enum layout (a one-byte tag, the variant's +/// declaration index, then the borsh payload), so the tag order is the +/// schema: append new versions, never reorder. Decoding an unknown tag +/// fails typedly as `BodyError::UnknownVersion`. +/// +/// Generated code resolves the SDK by crate path, so use the +/// `nexum_venue_sdk::IntentBody` re-export with `nexum-venue-sdk` as a +/// direct dependency. +#[proc_macro_derive(IntentBody)] +pub fn derive_intent_body(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + intent_body::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// The handler names recognised on a `#[module]` impl. Any method not in +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; + +/// 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 +/// payload for its event and returns `Result<(), Fault>`; `init` takes +/// the config table. +/// Handlers left undefined are ignored (their events become no-ops). The +/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` +/// impl, and `export!` around the untouched impl. +/// +/// The world is per module, not shared: the macro reads the crate's +/// `module.toml` and synthesizes a world whose imports are exactly the +/// `[capabilities].required` and `optional` declarations, so the built +/// component imports what the manifest declares and nothing else - the +/// runtime's load-time capability check passes by construction instead +/// of relying on the toolchain eliding unused imports. Corollaries: the +/// manifest must sit at the crate root and carry a `[capabilities]` +/// section, an undeclared capability's bindings simply do not exist +/// (using one is a compile error, the cue to declare it), and only the +/// host-adapter pieces for declared capabilities are emitted. +/// +/// The other non-obvious invariant: the wit-bindgen output (`Guest`, +/// `Fault`, the `nexum::host::*` modules) lands at the module crate +/// root, so the emitted glue and the handler bodies resolve those names +/// there; the WIT package directories are located by walking up from +/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must +/// declare `wit-bindgen` as a direct dependency (the emitted +/// `wit_bindgen::generate!` call resolves against the consumer's +/// namespace), and the crate root must not shadow std prelude names +/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` +/// trait refers to them unqualified). +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_sdk::module] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[nexum_sdk::module] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise + // compile as an ordinary helper while its event silently no-ops, so + // reserve the `on_` prefix for the recognised handler set. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + ) + .to_compile_error() + .into(); + } + } + } + + let present: Vec<&str> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS.into_iter().find(|h| *h == name) + } + _ => None, + }) + .collect(); + if present.is_empty() { + 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`", + ) + .to_compile_error() + .into(); + } + let has = |name: &str| present.contains(&name); + + let (manifest_path, module_world) = match derive_module_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match resolve_wit_packages(&module_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // `init` is a required export; when the handler is absent the config + // is bound but unused, so drop it to keep the module warning-clean. + let init_impl = if has("init") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + let arm = |handler: &str, variant| -> proc_macro2::TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + if has(handler) { + let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); + quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } + } else { + quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } + } + }; + let block_arm = arm("on_block", "Block"); + 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"); + + quote! { + // Anchor a rebuild on the manifest: the emitted world is derived + // from it, so an edited [capabilities] must recompile the module. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + #[doc(hidden)] + struct __NexumModuleExport; + + impl Guest for __NexumModuleExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #intent_status_arm + } + } + } + + export!(__NexumModuleExport); + } + .into() +} + +/// The associated functions the `nexum:intent/adapter` face mandates. A +/// venue adapter must define all four; `init` is separate (a no-op when +/// absent, exactly as in a module). +const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"]; + +/// Generate the per-cdylib glue for a venue adapter. +/// +/// Apply to an inherent `impl` block whose associated functions are the +/// adapter face: `derive_header`, `submit`, `status`, `cancel` (all +/// required, from `nexum:intent/adapter`), plus an optional `init` +/// (absent means a no-op). 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 +/// transport, then emits `wit_bindgen::generate!`, the `Guest` impls +/// wiring the world to the adapter's functions, and `export!` around the +/// untouched impl. So the built component imports what the manifest +/// declares and nothing else, retiring the toolchain-elision dependency +/// on the venue side. +/// +/// A venue's capabilities are scoped transport only: an undeclared +/// capability's bindings do not exist (using one is a compile error), +/// and a capability outside the venue-permitted set (`chain`, +/// `messaging`, `http`) is rejected at expansion. +/// +/// The same crate-root resolution invariants as [`macro@module`] apply: +/// the wit-bindgen output lands at the module crate root (so the emitted +/// glue resolves `Guest`, `Fault`, and the `nexum::*` type modules +/// there), the consuming crate must declare `wit-bindgen` as a direct +/// dependency, and the crate root must not shadow std prelude names. +#[proc_macro_attribute] +pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_venue_sdk::venue] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[nexum_venue_sdk::venue] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_venue_sdk::venue] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[nexum_venue_sdk::venue] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + let defines = |name: &str| { + input + .items + .iter() + .any(|item| matches!(item, ImplItem::Fn(f) if f.sig.ident == name)) + }; + let missing: Vec<&str> = VENUE_EXPORTS + .into_iter() + .filter(|name| !defines(name)) + .collect(); + if !missing.is_empty() { + return syn::Error::new_spanned( + self_ty, + format!( + "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ + Define all of `derive_header`, `submit`, `status`, `cancel` (plus an optional \ + `init`)", + missing + ), + ) + .to_compile_error() + .into(); + } + + let (manifest_path, venue_world) = match derive_venue_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match resolve_wit_packages(&venue_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &venue_world.wit; + + // `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") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + quote! { + // Anchor a rebuild on the manifest: the emitted world is derived + // from it, so an edited [capabilities] must recompile the adapter. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:venue-world/venue-adapter", + generate_all, + }); + + #input + + #[doc(hidden)] + struct __NexumVenueAdapterExport; + + impl Guest for __NexumVenueAdapterExport { + #init_impl + } + + impl exports::nexum::intent::adapter::Guest for __NexumVenueAdapterExport { + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::IntentHeader, + nexum::intent::types::VenueError, + > { + <#self_ty>::derive_header(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::SubmitOutcome, + nexum::intent::types::VenueError, + > { + <#self_ty>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::IntentStatus, + nexum::intent::types::VenueError, + > { + <#self_ty>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), nexum::intent::types::VenueError> { + <#self_ty>::cancel(receipt) + } + } + + export!(__NexumVenueAdapterExport); + } + .into() +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +fn is_plain_type(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.qself.is_none()) +} + +/// Read the consuming crate's `module.toml` and return its declared +/// capability names alongside the manifest path (for the rebuild +/// anchor). Shared by the module and venue worlds, which differ only in +/// how they turn the declarations into a world. +fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; + let manifest_path = Path::new(&manifest_dir).join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); {attribute} derives the component's WIT world from the \ + manifest's [capabilities] section, so the manifest must sit next to Cargo.toml", + manifest_path.display() + ) + })?; + let declared = world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + Ok((manifest_path.to_string_lossy().into_owned(), declared)) +} + +/// Read the consuming crate's `module.toml` and synthesize the +/// per-module world from its `[capabilities]` declarations. Returns the +/// manifest path (for the rebuild anchor) alongside the world. +fn derive_module_world() -> Result<(String, world::ModuleWorld), String> { + let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; + let module_world = world::synthesize(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, module_world)) +} + +/// Read the consuming crate's `module.toml` and synthesize the +/// per-component venue-adapter world from its `[capabilities]` +/// declarations. Returns the manifest path (for the rebuild anchor) +/// alongside the world. +fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { + let (manifest_path, declared) = read_manifest_capabilities("#[nexum_venue_sdk::venue]")?; + let venue_world = + world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, venue_world)) +} + +/// Locate the workspace `wit/` root (the ancestor directory whose `wit/` +/// contains the `nexum-host` package) and resolve each needed package +/// directory under it. +fn resolve_wit_packages(packages: &[&str]) -> Result, String> { + let manifest = std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; + let mut dir: Option<&Path> = Some(Path::new(&manifest)); + let root = loop { + let Some(cur) = dir else { + return Err(format!( + "could not find a `wit/` directory containing `nexum-host` in any ancestor \ + of {manifest}" + )); + }; + let wit = cur.join("wit"); + if wit.join("nexum-host").is_dir() { + break wit; + } + dir = cur.parent(); + }; + packages + .iter() + .map(|package| { + let path = root.join(package); + if path.is_dir() { + Ok(path.to_string_lossy().into_owned()) + } else { + Err(format!( + "declared capabilities need the `{package}` WIT package, but {} is not \ + a directory", + path.display() + )) + } + }) + .collect() +} diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs new file mode 100644 index 00000000..d8791708 --- /dev/null +++ b/crates/nexum-macros/src/world.rs @@ -0,0 +1,431 @@ +//! Per-module world synthesis: turn the manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability table here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because this module derives the imports from +//! the same manifest, a component built through `#[nexum_sdk::module]` +//! passes that check by construction rather than by relying on the +//! toolchain eliding unused imports. + +use std::fmt::Write as _; + +/// One manifest capability and its world wiring. +struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + name: &'static str, + /// The WIT import the declaration turns into, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + import: Option<&'static str>, + /// WIT package directories (under the workspace `wit/` root) the + /// import needs on the resolve path, beyond `nexum-host`. + packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + adapter: Option<&'static str>, +} + +/// Every capability the macro recognises, in emission order. Mirrors +/// the runtime's core registry plus the extension namespaces the +/// workspace ships (`nexum:intent/pool`, `shepherd:cow/cow-api`). +const KNOWN: &[Capability] = &[ + Capability { + name: "chain", + import: Some("nexum:host/chain@0.2.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: "identity", + import: Some("nexum:host/identity@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "local-store", + import: Some("nexum:host/local-store@0.2.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: "remote-store", + import: Some("nexum:host/remote-store@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "messaging", + import: Some("nexum:host/messaging@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "logging", + import: Some("nexum:host/logging@0.2.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: "pool", + import: Some("nexum:intent/pool@0.1.0"), + packages: &["nexum-intent", "nexum-value-flow"], + adapter: None, + }, + Capability { + name: "cow-api", + import: Some("shepherd:cow/cow-api@0.2.0"), + packages: &["shepherd-cow"], + adapter: None, + }, + Capability { + name: "http", + import: None, + packages: &[], + adapter: None, + }, +]; + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories (relative to the workspace `wit/` root) + /// the resolve path must carry, in dependency order (a package + /// precedes its dependants). Always starts with the base set the + /// host `event` variant needs. + pub packages: Vec<&'static str>, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// macro has nothing to build from without one. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// Capabilities a venue adapter may import. A venue speaks one venue's +/// protocol over scoped transport and nothing else: chain RPC, +/// messaging, and outbound HTTP (granted through the SDK's wasi:http +/// client, so no world import). It structurally cannot reach host key +/// material or persistent state, so `local-store`, `remote-store`, +/// `identity`, and `logging` are refused rather than silently imported. +const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; + +/// Build the per-component venue-adapter world from the declared +/// capability names. The world exports `init` and the +/// `nexum:intent/adapter` face and imports exactly the declared scoped +/// transport, so a macro-built adapter's imports equal its declarations +/// by construction. A capability outside the venue-permitted set is a +/// compile error: an adapter that reaches for host key material or +/// persistent state is rejected at expansion, not at boot. +pub fn synthesize_venue(declared: &[String]) -> Result { + for name in declared { + if !VENUE_CAPABILITIES.contains(&name.as_str()) { + let permitted = VENUE_CAPABILITIES.join(", "); + return Err(format!( + "capability `{name}` is not available to a venue adapter; a venue may import \ + only scoped transport ({permitted}) and structurally cannot touch local-store, \ + remote-store, identity, or logging" + )); + } + } + + let mut imports = String::new(); + // The export face (`nexum:intent/adapter`, its types, and the + // value-flow vocabulary they are expressed in) resolves against the + // same base package set every module world carries, in dependency + // order: a package precedes its dependants. + let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + for cap in KNOWN { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + writeln!(imports, " import {import};").expect("write to String"); + } + // Accumulate any extra WIT packages a venue capability needs, exactly + // as `synthesize` does. All venue-permitted capabilities are + // packageless today, so this leaves the base set untouched; mirroring + // the loop keeps a future venue capability from silently failing to + // reach its package onto the resolve path. + for package in cap.packages { + if !packages.contains(package) { + packages.push(package); + } + } + } + + let mut wit = String::from( + "package nexum:venue-world;\n\nworld venue-adapter {\n \ + use nexum:host/types@0.2.0.{config, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export nexum:intent/adapter@0.1.0;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + // The venue export glue wires the adapter's associated functions + // to the world's Guest traits directly; there is no host-trait + // adapter to bind, so no capability idents to pass on. + adapters: Vec::new(), + }) +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). Unknown names are a compile error so a typo cannot silently +/// drop an import. +pub fn synthesize(declared: &[String]) -> Result { + for name in declared { + if !KNOWN.iter().any(|c| c.name == name.as_str()) { + let known = KNOWN.iter().map(|c| c.name).collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {known}" + )); + } + } + + let mut imports = String::new(); + // The host `event` variant carries the intent vocabulary (the + // `intent-status` case), so every module world resolves against the + // intent and value-flow packages regardless of declared capabilities. + // Dependency order: each directory is parsed against the packages + // before it, so a package precedes its dependants. + let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + let mut adapters = Vec::new(); + for cap in KNOWN { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + writeln!(imports, " import {import};").expect("write to String"); + } + for package in cap.packages { + if !packages.contains(package) { + packages.push(package); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.2.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against: the host + /// package plus the intent vocabulary its `event` variant carries, in + /// dependency order. + const BASE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.2.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert!(!world.wit.contains("shepherd:cow")); + assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn cow_api_pulls_the_shepherd_cow_package() { + let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); + assert!(world.wit.contains("import shepherd:cow/cow-api@0.2.0;")); + assert_eq!( + world.packages, + vec![ + "nexum-value-flow", + "nexum-intent", + "nexum-host", + "shepherd-cow" + ] + ); + } + + #[test] + fn pool_adds_no_packages_beyond_the_base_set() { + let world = synthesize(&["pool".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); + assert_eq!(world.packages, BASE_PACKAGES); + assert!(world.adapters.is_empty()); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, BASE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()]).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + } + + #[test] + fn venue_world_exports_the_adapter_face() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.starts_with("package nexum:venue-world;")); + assert!(world.wit.contains("world venue-adapter {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + assert_eq!(world.packages, BASE_PACKAGES); + assert!(world.adapters.is_empty()); + } + + #[test] + fn venue_world_imports_only_declared_transport() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:host/chain@0.2.0;")); + assert!(!world.wit.contains("import nexum:host/messaging")); + + let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); + assert!(both.wit.contains("import nexum:host/chain@0.2.0;")); + assert!(both.wit.contains("import nexum:host/messaging@0.2.0;")); + } + + #[test] + fn venue_world_grants_http_without_a_world_import() { + let world = synthesize_venue(&["http".to_string()]).unwrap(); + assert!(!world.wit.contains("import")); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, BASE_PACKAGES); + } + + #[test] + fn venue_world_with_no_capabilities_imports_nothing() { + let world = synthesize_venue(&[]).unwrap(); + assert!(!world.wit.contains("import")); + assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + } + + #[test] + fn venue_world_refuses_non_transport_capabilities() { + for cap in ["local-store", "remote-store", "identity", "logging", "pool"] { + let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); + assert!(err.contains(cap), "message was: {err}"); + assert!(err.contains("venue adapter"), "message was: {err}"); + } + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } +} diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index a48bebca..927861bf 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -8,10 +8,240 @@ //! //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. +//! +//! The `nexum:intent` and `nexum:value-flow` packages sit on the core +//! resolve path because the host `event` variant carries the intent +//! vocabulary (the `intent-status` case). Their types therefore generate +//! here first, and the adapter and pool bindgens below remap onto them +//! with `with`, so one Rust type serves the event payload, the router, +//! and the adapter face alike. `PartialEq` is derived so the router can +//! compare a polled status against the last delivered one. wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + ], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, + additional_derives: [PartialEq], }); + +/// WIT bindings for the second component kind: the +/// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped +/// transport it needs (chain and messaging; outbound HTTP is wasi:http, +/// linked and allowlisted separately as for event-module) and exports the +/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host`, +/// `nexum:intent`, and `nexum:value-flow` interfaces are reused from the +/// `event-module` bindings above via `with`, so the `chain`/`messaging` +/// `Host` impls, the `fault` type, and the intent vocabulary an adapter +/// sees are the very ones the core host constructs. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": super::nexum::host::types, + "nexum:host/chain": super::nexum::host::chain, + "nexum:host/messaging": super::nexum::host::messaging, + "nexum:intent/types": super::nexum::intent::types, + "nexum:value-flow/types": super::nexum::value_flow::types, + }, + }); +} + +pub use venue_adapter::VenueAdapter; + +/// The strategy-facing `nexum:intent/pool` import bound host-side. The pool +/// world imports the interface a module calls; the intent and value-flow +/// types it uses are reused from the core bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the router hands back to a module are +/// the very ones an adapter's `submit` produced - no lift between two +/// structurally identical copies. Async, because the `Host` impl awaits the +/// per-adapter mutex and the adapter's own async guest calls. +mod pool_host { + wasmtime::component::bindgen!({ + inline: " + package nexum:pool-host; + world pool-host { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + imports: { default: async }, + with: { + "nexum:value-flow/types": super::nexum::value_flow::types, + "nexum:intent/types": super::nexum::intent::types, + }, + }); +} + +/// The router-observed status transition delivered through the `event` +/// variant, re-exported at the plain spelling the router names. +pub use nexum::host::types::IntentStatusUpdate; +/// The shared intent ontology, re-exported at the plain spellings the router +/// and the `pool::Host` impl name. +pub use nexum::intent::types::{AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; +/// The value-flow vocabulary the header is expressed in. +pub use nexum::value_flow::types as value_flow; +/// The host-bound pool interface: the `Host` trait the router implements and +/// the `add_to_linker` the module linker calls. +pub use pool_host::nexum::intent::pool; + +/// Bindgen smoke for the `nexum:value-flow` types package. The package has +/// no host consumer yet (the intent router that will bind it lands later), +/// so this compiles it under test only, through a throwaway world that +/// imports the interface. Its value is the identifier-hygiene gate: the +/// test names every generated type, variant, and field by its plain Rust +/// spelling, so a WIT id that collided with a Rust keyword would surface as +/// an `r#` escape and fail to compile here rather than in a downstream +/// binding. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package nexum:value-flow-smoke; + world smoke { + import nexum:value-flow/types@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; + + let _ = Settlement::EvmChain(1); + let _ = Settlement::Offchain(String::new()); + + let service = ServiceDesc { + kind: String::new(), + summary: String::new(), + }; + let offchain = OffchainDesc { + domain: String::new(), + summary: String::new(), + }; + + let _ = Asset::NativeToken(Settlement::EvmChain(1)); + let _ = Asset::Erc20((1, Vec::new())); + let _ = Asset::Erc721((1, Vec::new(), Vec::new())); + let _ = Asset::Erc1155((1, Vec::new(), Vec::new())); + let _ = Asset::Service(service); + let asset = Asset::Offchain(offchain); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// Bindgen smoke for the `nexum:intent` package, mirroring the value-flow +/// smoke above: no host consumer exists yet (the pool router lands later), +/// so the package compiles under test only, through a throwaway world that +/// imports the pool interface and, transitively, the types interface and +/// its value-flow dependency. The test names every generated type, case, +/// and field by its plain Rust spelling, and a dummy `pool` host impl pins +/// the three function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod intent_smoke { + wasmtime::component::bindgen!({ + inline: " + package nexum:intent-smoke; + world smoke { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + }); + + use nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, + }; + use nexum::value_flow::types::Settlement; + + struct DummyPool; + + impl nexum::intent::pool::Host for DummyPool { + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + #[test] + fn identifiers_bind_unescaped() { + use nexum::intent::pool::Host; + + let _ = AuthScheme::Eip712; + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Presign; + let _ = AuthScheme::OffchainSig; + let _ = AuthScheme::Unsigned; + + let header = IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }; + assert!(header.gives.is_empty() && header.wants.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Settled(None); + let _ = IntentStatus::Failed(FailReason { + code: String::new(), + detail: String::new(), + }); + let _ = IntentStatus::Expired; + let _ = IntentStatus::Cancelled; + + let tx = UnsignedTx { + chain_id: 1, + to: Vec::new(), + value: Vec::new(), + input: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::InvalidReceipt; + let _ = VenueError::Rejected(String::new()); + let _ = VenueError::Denied(String::new()); + let _ = VenueError::Unsupported(String::new()); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::InternalError(String::new()); + + let mut pool = DummyPool; + assert!(pool.submit(String::new(), Vec::new()).is_err()); + assert!(pool.status(String::new(), Vec::new()).is_err()); + assert!(pool.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 86457759..306ac323 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -156,7 +156,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { clocks, ) .await? - } else if !engine_cfg.modules.is_empty() { + } else if !engine_cfg.modules.is_empty() || !engine_cfg.adapters.is_empty() { Supervisor::boot( &engine, &linker, @@ -168,13 +168,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { .await? } else { anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] entries \ - in engine.toml" + "no modules to run - set a module source or declare [[modules]] or \ + [[adapters]] entries in engine.toml" ); }; info!( modules = supervisor.module_count(), + adapters = supervisor.adapter_count(), chains = supervisor.block_chains().len(), "supervisor ready" ); @@ -189,10 +190,15 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let block_chains = supervisor.block_chains(); let chain_log_subs = supervisor.chain_log_subscriptions(); + // Status polling runs only when it can produce something a module + // will see: at least one intent-status subscriber and at least one + // installed adapter to poll. + let poll_statuses = supervisor.has_intent_status_subscribers() + && supervisor.pool_router().venue_count() > 0; // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() { + if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { info!("no [[subscription]] entries - engine has nothing to run; exiting"); let event_loop = ctx .executor @@ -221,6 +227,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { ctx.executor, &mut reconnect_tasks, ); + let intent_status_stream = poll_statuses.then(|| { + event_loop::open_intent_status_stream( + supervisor.pool_router(), + engine_cfg.limits.status_poll_interval(), + ctx.executor, + &mut reconnect_tasks, + ) + }); let event_loop = ctx.executor.spawn(Box::pin(async move { let shutdown = async move { @@ -246,6 +260,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, + intent_status_stream, reconnect_tasks, shutdown, ) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index b93a0096..a5046b75 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,6 +26,7 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; +use crate::host::pool_router::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, PoolQuota}; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; /// Errors surfaced by [`load_or_default`]. @@ -82,6 +83,14 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, + /// Venue adapters the supervisor should boot alongside the modules. + /// Each entry resolves a `(component.wasm, module.toml)` pair like a + /// module, but the operator scopes its transport here rather than in + /// the adapter's own manifest: the installer of a venue adapter, not + /// the adapter author, decides which hosts and messaging topics it may + /// reach. + #[serde(default)] + pub adapters: Vec, } /// One `[[modules]]` table from `engine.toml`. @@ -98,6 +107,33 @@ pub struct ModuleEntry { pub manifest: Option, } +/// One `[[adapters]]` table from `engine.toml`. +/// +/// `path` and `manifest` mirror [`ModuleEntry`]; `manifest` defaults to a +/// sibling `module.toml`. The two scope fields are the operator's grant of +/// the adapter's transport: `http_allow` is the outbound HTTP host +/// allowlist the adapter's wasi:http gate enforces, and `messaging_topics` +/// scopes the messaging content topics it may publish to. Both default +/// empty; an empty `http_allow` denies every outbound request, and an +/// empty `messaging_topics` leaves messaging unscoped for parity with the +/// module default (the messaging backend itself is deferred). +#[derive(Debug, Deserialize)] +pub struct AdapterEntry { + /// Path to the compiled `.wasm` adapter component. + pub path: std::path::PathBuf, + /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. + #[serde(default)] + pub manifest: Option, + /// Outbound HTTP host allowlist granted to this adapter. Each entry is + /// either an exact hostname or a `*.suffix` wildcard, matched the same + /// way as a module's `[capabilities.http].allow`. + #[serde(default)] + pub http_allow: Vec, + /// Messaging content topics this adapter may reach. + #[serde(default)] + pub messaging_topics: Vec, +} + #[derive(Debug, Deserialize)] pub struct EngineSection { #[serde(default = "default_state_dir")] @@ -229,6 +265,11 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; +/// Default cadence for router-driven intent status polling (5 s). Fast +/// enough that a settling intent is observed within a block time or two, +/// slow enough that per-receipt venue calls stay negligible. +const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); + /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: /// zero would fail every request instantly, and huge values overflow /// timer arithmetic. @@ -274,6 +315,12 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, + /// Per-caller intent submission quota. + #[serde(default)] + pub quota: QuotaLimitsSection, + /// Router-driven intent status polling cadence. + #[serde(default)] + pub status_poll: StatusPollSection, } impl ModuleLimits { @@ -351,6 +398,30 @@ impl ModuleLimits { .unwrap_or(POISON_WINDOW), ) } + + /// Resolved status-poll cadence (override or default). A zero interval + /// saturates up to 1 ms so a misconfigured cadence busy-loops a poll + /// task instead of dividing by zero timer arithmetic. + pub fn status_poll_interval(&self) -> Duration { + self.status_poll + .interval_ms + .map(|ms| Duration::from_millis(ms.max(1))) + .unwrap_or(DEFAULT_STATUS_POLL_INTERVAL) + } + + /// Resolved per-caller submission quota (overrides or defaults). A zero + /// `max_charges` is saturated up to 1 by the router builder, so a + /// misconfigured budget still admits one submission rather than bricking + /// every venue. + pub fn quota(&self) -> PoolQuota { + PoolQuota::new( + self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), + self.quota + .window_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_QUOTA_WINDOW), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -424,6 +495,35 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } +/// `[limits.quota]` per-caller intent submission budget. Both optional; +/// omitted values resolve to the router defaults via [`ModuleLimits::quota`]. +/// +/// A caller (a strategy module, keyed by its namespace) may accrue at most +/// `max_charges` submissions within a sliding `window_secs`; a decode failure +/// charged back to the caller counts the same, so a module feeding garbage +/// bodies exhausts its own budget rather than the adapter's fuel. +#[derive(Debug, Default, Deserialize)] +pub struct QuotaLimitsSection { + /// Maximum submissions (plus charged decode failures) per caller in the + /// window. + pub max_charges: Option, + /// Sliding window the charges are counted across, in seconds. + pub window_secs: Option, +} + +/// `[limits.status_poll]` intent status polling cadence. Optional; an +/// omitted value resolves to the built-in default and a degenerate zero +/// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. +/// +/// The cadence is how often the router polls each installed adapter's +/// `status` export for the receipts it watches; only observed transitions +/// fan out as `intent-status` events. +#[derive(Debug, Default, Deserialize)] +pub struct StatusPollSection { + /// Milliseconds between status poll sweeps. + pub interval_ms: Option, +} + /// Resolved log retention limits the in-memory store enforces. Built by /// [`ModuleLimits::logs`]. #[derive(Debug, Clone, Copy)] @@ -795,6 +895,44 @@ window_secs = 0 assert_eq!(poison.window, Duration::from_secs(1)); } + #[test] + fn adapters_parse_with_scoped_transport_grants() { + let cfg: EngineConfig = toml::from_str( + r#" +[[adapters]] +path = "adapters/cow/cow_adapter.wasm" +http_allow = ["api.cow.fi", "*.cow.fi"] +messaging_topics = ["/nexum/1/cow-orders/proto"] + +[[adapters]] +path = "adapters/bare/bare.wasm" +manifest = "adapters/bare/module.toml" +"#, + ) + .expect("adapters parse"); + assert_eq!(cfg.adapters.len(), 2); + let first = &cfg.adapters[0]; + assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert!(first.manifest.is_none(), "manifest defaults to sibling"); + assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + let second = &cfg.adapters[1]; + assert_eq!( + second.manifest.as_deref(), + Some(Path::new("adapters/bare/module.toml")) + ); + assert!( + second.http_allow.is_empty() && second.messaging_topics.is_empty(), + "unset scope grants default empty", + ); + } + + #[test] + fn adapters_default_empty_when_absent() { + let cfg = EngineConfig::default(); + assert!(cfg.adapters.is_empty()); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index f2ff87c2..bd450cb3 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,13 +1,43 @@ -//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` -//! returns an empty result, same posture as `identity::accounts`. +//! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so +//! `publish` reports `unsupported` and `query` returns empty, the same +//! posture as `identity::accounts`. The per-store topic scope is enforced +//! ahead of that stub: a venue adapter carrying a +//! `[[adapters]].messaging_topics` grant may only publish within it, so +//! the egress boundary is live even though delivery is not. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; +/// Whether `topic` falls within `scope`. An empty scope is unscoped and +/// admits every topic (the module default); otherwise a topic is admitted +/// when it equals a scope entry or descends from one read as a path prefix +/// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is +/// the `/` path separator, so a grant never leaks into a longer sibling +/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + impl nexum::host::messaging::Host for HostState { - async fn publish(&mut self, _content_topic: String, _payload: Vec) -> Result<(), Fault> { + async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), Fault> { + if !topic_in_scope(&content_topic, &self.messaging_topics) { + return Err(Fault::Denied(format!( + "content topic {content_topic:?} outside this component's messaging scope" + ))); + } Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) } @@ -21,3 +51,39 @@ impl nexum::host::messaging::Host for HostState { Ok(vec![]) } } + +#[cfg(test)] +mod tests { + use super::topic_in_scope; + + #[test] + fn empty_scope_admits_everything() { + assert!(topic_in_scope("/nexum/1/anything/proto", &[])); + } + + #[test] + fn exact_topic_is_admitted() { + let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); + } + + #[test] + fn prefix_scope_admits_the_family_but_not_a_sibling() { + let scope = vec!["/nexum/1/".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); + // A sibling namespace stays out. + assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + } + + #[test] + fn prefix_boundary_is_a_path_segment_not_a_substring() { + // A scope entry without a trailing slash still bounds on the path + // separator, so it cannot leak into a longer sibling segment. + let scope = vec!["/nexum/1/cow".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow", &scope)); + assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + } +} diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 2247ed60..a5b80c14 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,5 +11,6 @@ mod identity; mod local_store; mod logging; mod messaging; +mod pool; mod remote_store; mod types; diff --git a/crates/nexum-runtime/src/host/impls/pool.rs b/crates/nexum-runtime/src/host/impls/pool.rs new file mode 100644 index 00000000..d02e29e5 --- /dev/null +++ b/crates/nexum-runtime/src/host/impls/pool.rs @@ -0,0 +1,30 @@ +//! `nexum:intent/pool`: the strategy-facing intent import. Every method is a +//! thin delegation to the shared [`PoolRouter`](crate::host::pool_router) +//! carried in the store; the router owns the venue resolution, per-adapter +//! serialisation, guard seam, and quota. The caller identity the router meters +//! against is this store's module namespace. + +use crate::bindings::pool::Host; +use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; + +impl Host for HostState { + async fn submit(&mut self, venue: String, body: Vec) -> Result { + self.pool_router + .submit(&self.run.module, &venue, body) + .await + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + self.pool_router.status(&venue, receipt).await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + self.pool_router.cancel(&venue, receipt).await + } +} diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/crates/nexum-runtime/src/host/impls/types.rs index f9516569..a035a97d 100644 --- a/crates/nexum-runtime/src/host/impls/types.rs +++ b/crates/nexum-runtime/src/host/impls/types.rs @@ -1,8 +1,13 @@ -//! `nexum:host/types` is a type-only interface (no functions). The -//! generated trait is empty; we just provide the marker impl. +//! `nexum:host/types` and the intent vocabulary it uses are type-only +//! interfaces (no functions). The generated traits are empty; we just +//! provide the marker impls. use crate::bindings::nexum; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; impl nexum::host::types::Host for HostState {} + +impl nexum::intent::types::Host for HostState {} + +impl nexum::value_flow::types::Host for HostState {} diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 3f4e16c8..22096775 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -47,7 +47,7 @@ pub struct ModuleStore { } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the shared + /// Open (or create) the redb file at `path`. Initialises the shared /// table so subsequent read transactions never hit `TableDoesNotExist`. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 66e4121e..5c41e467 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -31,5 +31,6 @@ pub mod http; mod impls; pub mod local_store_redb; pub mod logs; +pub mod pool_router; pub mod provider_pool; pub mod state; diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs new file mode 100644 index 00000000..a90797f0 --- /dev/null +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -0,0 +1,1075 @@ +//! The intent pool router: the strategy-facing `nexum:intent/pool` import +//! resolved to installed venue adapters. +//! +//! A module's `pool::submit(venue, body)` reaches the host here. The router +//! resolves the venue id to the one installed adapter that answers for it, +//! then drives a fixed sequence against that adapter: derive the header, +//! run the guard interposition seam on it, and only then submit. Status and +//! cancel are pass-throughs; they are not submissions, so they skip the +//! header, the guard, and the quota. +//! +//! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, +//! so each adapter sits behind its own async mutex: concurrent pool calls to +//! the same venue queue on that mutex, while calls to different venues run +//! in parallel. The lock is held across the guest await, which is the whole +//! point - it is the actor boundary that keeps one adapter store +//! single-threaded. +//! +//! Fuel cannot cross stores, so a module that spams undecodable bodies would +//! otherwise burn an adapter's budget for free. Two mechanisms close that: +//! a per-caller submission quota gates every submit before the adapter is +//! touched, and a decode failure (the adapter's `invalid-body`) is charged +//! to the calling module's quota, so a caller feeding garbage exhausts its +//! own budget rather than the adapter's. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures::future::BoxFuture; +use tokio::sync::Mutex as AsyncMutex; +use tracing::warn; +use wasmtime::Store; + +use crate::bindings::{ + IntentHeader, IntentStatus, IntentStatusUpdate, SubmitOutcome, VenueAdapter, VenueError, +}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; + +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); + +/// Per-caller submission quota. Both a forwarded submission and a charged +/// decode failure consume one unit; the window slides so a caller's budget +/// refills as old charges age out. +#[derive(Debug, Clone, Copy)] +pub struct PoolQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl PoolQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for PoolQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// The guard interposition seam. The router runs this on the adapter-derived +/// header after `derive-header` and before `submit`. The shipped policy is a +/// no-op that allows every egress; the egress-guard epic replaces the +/// installed policy with the real facts-plus-analysers pipeline without the +/// router changing shape. +pub trait GuardPolicy: Send + Sync { + /// Decide whether the derived header may proceed to the adapter's submit. + fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; +} + +/// What the guard sees: who is submitting, to which venue, and the header the +/// adapter derived from the opaque body. The header is the stable ontology +/// policy has teeth on; the raw body never reaches the guard. +pub struct GuardContext<'a> { + /// Namespace of the calling module. + pub caller: &'a str, + /// Venue id the submission is routed to. + pub venue: &'a str, + /// Adapter-derived header for the body. + pub header: &'a IntentHeader, +} + +/// The guard's decision on one egress. +pub enum GuardVerdict { + /// Forward the submission to the adapter. + Allow, + /// Refuse the egress with an operator-facing reason. + Deny(String), +} + +/// The shipped no-op policy: allow every egress. Named so the composition +/// root reads plainly and the egress-guard epic has an obvious thing to swap. +pub struct AllowAllGuard; + +impl GuardPolicy for AllowAllGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Allow + } +} + +/// The per-adapter invocation seam. One installed adapter answers for exactly +/// one venue; the router owns the adapter's `Store` behind an async mutex and +/// reaches it only through this trait, so the router's sequencing and quota +/// logic is testable against a stub that never spins up a wasmtime store. +/// +/// The futures are boxed so the router can hold heterogeneous adapters behind +/// one `dyn` slot without the whole router turning generic over an adapter +/// type it never names. +pub trait VenueInvoker: Send { + /// Project the opaque body onto the stable header the guard runs on. + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result>; + + /// Submit the opaque body to this adapter's venue. + fn submit<'a>(&'a mut self, body: &'a [u8]) + -> BoxFuture<'a, Result>; + + /// Report where a previously submitted intent is in its life. The receipt + /// is owned: it is used once, unlike the body a submission re-decodes. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; +} + +/// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` +/// bindings, refuelled before each guest call. A trap is projected onto +/// `internal-error` rather than propagated: a misbehaving adapter must not be +/// the caller's fault, and it must not unwind through the router into the +/// calling module's store. +pub struct AdapterActor { + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, +} + +impl AdapterActor { + /// Wrap an instantiated adapter store for routing. + pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { + Self { + store, + bindings, + fuel_per_call, + } + } + + /// Refuel the store before a guest call so each invocation starts from a + /// full budget, mirroring the supervisor's per-event refuel. + fn refuel(&mut self) -> Result<(), VenueError> { + self.store + .set_fuel(self.fuel_per_call) + .map_err(|e| VenueError::InternalError(format!("adapter refuel failed: {e}"))) + } +} + +/// Project a wasmtime trap into the venue-error space. The root cause is +/// carried so an operator sees why the adapter died without the wasm frame +/// list leaking to the calling module. +fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { + VenueError::InternalError(format!("adapter trapped: {}", trap.root_cause())) +} + +impl VenueInvoker for AdapterActor { + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_derive_header(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_submit(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_status(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_cancel(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } +} + +/// One installed adapter behind its serialising mutex. +type AdapterSlot = Arc>; + +/// Per-caller charge history, pruned to the quota window on each touch. +#[derive(Default)] +struct QuotaLedger { + per_caller: HashMap>, +} + +/// One receipt the router polls for status transitions. `last` starts +/// `None` so the first successful poll always reports, giving a +/// subscriber the intent's current state without waiting for a change. +struct WatchedIntent { + venue: String, + receipt: Vec, + last: Option, +} + +/// A polled status is terminal when the intent can never change again: +/// the router stops watching the receipt after reporting it. +fn is_terminal(status: &IntentStatus) -> bool { + matches!( + status, + IntentStatus::Settled(_) + | IntentStatus::Failed(_) + | IntentStatus::Expired + | IntentStatus::Cancelled + ) +} + +/// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every +/// module store carries the same handle, so a submission from any module +/// reaches the same adapters and the same quota ledger. +struct PoolRouterInner { + adapters: HashMap, + guard: Arc, + quota: PoolQuota, + ledger: Mutex, + /// Receipts under status watch, appended by accepted submissions and + /// pruned as they reach a terminal status. + watched: Mutex>, +} + +/// The strategy-facing pool router, cheap to clone and shared across every +/// module store. +#[derive(Clone)] +pub struct PoolRouter { + inner: Arc, +} + +impl PoolRouter { + /// An empty router: no adapters, the no-op guard, the default quota. This + /// is what an adapter store (which cannot call pool) and the single-module + /// `just run` path carry. + pub fn empty() -> Self { + PoolRouterBuilder::new(PoolQuota::default()).build() + } + + /// Resolve a venue id to its installed adapter slot. + fn resolve(&self, venue: &str) -> Result { + self.inner + .adapters + .get(venue) + .cloned() + .ok_or(VenueError::UnknownVenue) + } + + /// Whether `caller` has budget left in the current window. Read-only: it + /// prunes aged charges but does not record one. + fn quota_admits(&self, caller: &str) -> bool { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + (history.len() as u32) < self.inner.quota.max_charges + } + + /// Record one charge against `caller`'s budget. + fn charge(&self, caller: &str) { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + history.push_back(Instant::now()); + } + + /// Submit an opaque body to `venue` on behalf of `caller`: resolve the + /// adapter, gate on the caller's quota, derive the header, run the guard + /// seam, then forward to the adapter. A decode failure is charged to the + /// caller before returning, so a caller feeding garbage exhausts its own + /// budget and is stopped at the gate on the next call rather than + /// re-invoking the adapter. + /// + /// Charging is deliberately asymmetric across the two stages. Once the + /// guard admits the header the submission is charged before the adapter + /// call, so a forwarded submission spends one unit regardless of the + /// venue's outcome (the adapter did the work, and a transient venue + /// outage must not become a free retry loop). A derive-stage venue error + /// that is not a decode failure is the venue's fault, not the caller's, + /// so it is left uncharged and the caller may retry. + pub async fn submit( + &self, + caller: &str, + venue: &str, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + // Gate before touching the adapter so a quota-exhausted caller never + // reaches the adapter store or its mutex. + if !self.quota_admits(caller) { + return Err(VenueError::Denied(format!( + "submission quota exhausted for caller {caller}" + ))); + } + let mut adapter = slot.lock().await; + let header = match adapter.derive_header(&body).await { + Ok(header) => header, + Err(e) => { + // Charge decode failures to the caller before the adapter is + // invoked again; other venue errors are not the caller's fault. + if matches!(e, VenueError::InvalidBody(_)) { + self.charge(caller); + } + return Err(e); + } + }; + let ctx = GuardContext { + caller, + venue, + header: &header, + }; + if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { + return Err(VenueError::Denied(reason)); + } + // A forwarded submission consumes one unit of the caller's budget. + self.charge(caller); + let outcome = adapter.submit(&body).await?; + // An accepted receipt goes under status watch so subscribers see + // its transitions; requires-signing has no receipt to watch yet. + if let SubmitOutcome::Accepted(receipt) = &outcome { + self.watch(venue, receipt.clone()); + } + Ok(outcome) + } + + /// Put a `(venue, receipt)` pair under status watch. Idempotent: a + /// re-submitted receipt keeps its existing watch entry. + fn watch(&self, venue: &str, receipt: Vec) { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + if watched + .iter() + .any(|w| w.venue == venue && w.receipt == receipt) + { + return; + } + watched.push(WatchedIntent { + venue: venue.to_owned(), + receipt, + last: None, + }); + } + + /// Number of receipts currently under status watch. + pub fn watched_count(&self) -> usize { + self.inner + .watched + .lock() + .expect("watch list poisoned") + .len() + } + + /// Poll every watched receipt against its adapter's status export and + /// return the transitions: statuses that differ from the last one + /// reported for that receipt (the first successful poll always + /// reports). A terminal status is reported once and the receipt is + /// dropped from the watch; a transport failure leaves the entry + /// untouched for the next cadence, except `invalid-receipt`, which + /// means the venue disowns the receipt, so watching is pointless. + pub async fn poll_status_transitions(&self) -> Vec { + // Snapshot so the std mutex is never held across the guest await. + let snapshot: Vec<(String, Vec)> = { + let watched = self.inner.watched.lock().expect("watch list poisoned"); + watched + .iter() + .map(|w| (w.venue.clone(), w.receipt.clone())) + .collect() + }; + let mut updates = Vec::new(); + for (venue, receipt) in snapshot { + // Installed adapters never leave the router, so a resolve + // failure here is unreachable; skip defensively regardless. + let Ok(slot) = self.resolve(&venue) else { + continue; + }; + let polled = { + let mut adapter = slot.lock().await; + adapter.status(receipt.clone()).await + }; + match polled { + Ok(status) => { + if let Some(update) = self.record_polled_status(&venue, &receipt, status) { + updates.push(update); + } + } + Err(VenueError::InvalidReceipt) => { + warn!(venue = %venue, "venue disowns a watched receipt - dropping it"); + self.unwatch(&venue, &receipt); + } + Err(err) => { + warn!( + venue = %venue, + error = ?err, + "status poll failed - retrying on the next cadence", + ); + } + } + } + updates + } + + /// Fold one polled status into the watch entry: `Some(update)` when it + /// differs from the last reported status, pruning the entry when the + /// status is terminal. `None` also covers an entry that disappeared + /// while the poll was in flight. + fn record_polled_status( + &self, + venue: &str, + receipt: &[u8], + status: IntentStatus, + ) -> Option { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let pos = watched + .iter() + .position(|w| w.venue == venue && w.receipt == receipt)?; + let changed = watched[pos].last.as_ref() != Some(&status); + if is_terminal(&status) { + watched.remove(pos); + } else { + watched[pos].last = Some(status.clone()); + } + changed.then(|| IntentStatusUpdate { + venue: venue.to_owned(), + receipt: receipt.to_vec(), + status, + }) + } + + /// Drop a `(venue, receipt)` pair from the status watch. + fn unwatch(&self, venue: &str, receipt: &[u8]) { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + watched.retain(|w| !(w.venue == venue && w.receipt == receipt)); + } + + /// Report where a previously submitted intent is in its life. Not a + /// submission: no header, no guard, no quota, just the serialised call. + pub async fn status(&self, venue: &str, receipt: Vec) -> Result { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.status(receipt).await + } + + /// Ask the venue to withdraw an intent. Not a submission, so it skips the + /// header, guard, and quota like `status`. + pub async fn cancel(&self, venue: &str, receipt: Vec) -> Result<(), VenueError> { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.cancel(receipt).await + } + + /// Number of installed, routable adapters. + pub fn venue_count(&self) -> usize { + self.inner.adapters.len() + } +} + +/// Drop charge timestamps that have aged out of the window. +fn prune(history: &mut VecDeque, window: Duration) { + let now = Instant::now(); + while let Some(&front) = history.front() { + if now.duration_since(front) > window { + history.pop_front(); + } else { + break; + } + } +} + +/// Assembles a [`PoolRouter`]: adapters install first (at supervisor boot, +/// before any module store carries the built router), then the router +/// freezes. The guard defaults to the no-op [`AllowAllGuard`]; the +/// egress-guard epic overrides it here. +pub struct PoolRouterBuilder { + adapters: HashMap, + guard: Arc, + quota: PoolQuota, +} + +impl PoolRouterBuilder { + /// Start an empty builder with the given quota and the no-op guard. + pub fn new(quota: PoolQuota) -> Self { + Self { + adapters: HashMap::new(), + guard: Arc::new(AllowAllGuard), + quota, + } + } + + /// Override the guard policy. The egress-guard epic wires the real + /// pipeline through here; tests inject a denying policy to prove the seam. + pub fn with_guard(mut self, guard: Arc) -> Self { + self.guard = guard; + self + } + + /// Install an adapter under its venue id. Rejects a duplicate id: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. + pub fn install( + &mut self, + venue: String, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + if self.adapters.contains_key(&venue) { + return Err(DuplicateVenue { venue }); + } + self.adapters + .insert(venue, Arc::new(AsyncMutex::new(invoker))); + Ok(()) + } + + /// Freeze the builder into a shared router. + pub fn build(self) -> PoolRouter { + if self.quota.max_charges == 0 { + // A zero budget would deny every submission; saturate up to one so + // a misconfigured quota still admits a single submission rather + // than bricking every venue. Mirrors the poison-policy clamp. + warn!("pool submission quota max_charges is 0; clamping to 1"); + } + let quota = PoolQuota::new(self.quota.max_charges.max(1), self.quota.window); + PoolRouter { + inner: Arc::new(PoolRouterInner { + adapters: self.adapters, + guard: self.guard, + quota, + ledger: Mutex::new(QuotaLedger::default()), + watched: Mutex::new(Vec::new()), + }), + } + } +} + +/// Two installed adapters claimed the same venue id. +#[derive(Debug, thiserror::Error)] +#[error("venue id {venue:?} is claimed by more than one installed adapter")] +pub struct DuplicateVenue { + /// The colliding venue id. + pub venue: String, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::bindings::nexum::intent::types::UnsignedTx; + use crate::bindings::value_flow::Settlement; + use crate::bindings::{AuthScheme, IntentHeader}; + + use super::*; + + /// A programmable adapter that records call counts and returns canned + /// outcomes, so the router's sequencing, guard seam, and quota are tested + /// without a wasmtime store. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest number of overlapping invocations observed; proves the + /// per-adapter mutex serialises access. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + /// Statuses served front-first by consecutive `status` calls; + /// once drained, every further call reports `open`. + status_script: VecDeque>, + } + + impl StubAdapter { + fn new(calls: Arc) -> Self { + Self { + calls, + derive: Ok(header()), + submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + status_script: VecDeque::new(), + } + } + + fn with_derive(mut self, derive: Result) -> Self { + self.derive = derive; + self + } + + fn with_submit(mut self, submit: Result) -> Self { + self.submit = submit; + self + } + + fn with_status_script( + mut self, + script: impl IntoIterator>, + ) -> Self { + self.status_script = script.into_iter().collect(); + self + } + + async fn enter(&self) { + let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; + self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); + // Yield inside the critical section so any missing serialisation + // would let a second call observe `live == 2`. + tokio::task::yield_now().await; + self.calls.live.fetch_sub(1, Ordering::SeqCst); + } + } + + impl VenueInvoker for StubAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.derive.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.derive.clone() + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.submit.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.submit.clone() + }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls.status.fetch_add(1, Ordering::SeqCst); + self.status_script + .pop_front() + .unwrap_or(Ok(IntentStatus::Open)) + }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.calls.cancel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + /// A guard that refuses every egress with a fixed reason. + struct DenyGuard; + impl GuardPolicy for DenyGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Deny("blocked by test policy".to_owned()) + } + } + + fn header() -> IntentHeader { + IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Unsigned, + } + } + + fn router_with( + quota: PoolQuota, + guard: Option>, + adapter: StubAdapter, + ) -> PoolRouter { + let mut builder = PoolRouterBuilder::new(quota); + if let Some(guard) = guard { + builder = builder.with_guard(guard); + } + builder + .install("cow".to_owned(), adapter) + .expect("install adapter"); + builder.build() + } + + #[tokio::test] + async fn submit_round_trips_through_derive_guard_submit() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + + let outcome = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn unknown_venue_is_rejected_without_touching_an_adapter() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + + let err = router + .submit("mod-a", "unlisted", b"body".to_vec()) + .await + .expect_err("unknown venue rejected"); + + assert!(matches!(err, VenueError::UnknownVenue)); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn guard_deny_blocks_submit_after_deriving_the_header() { + let calls = Arc::new(StubCalls::default()); + let router = router_with( + PoolQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let err = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect_err("guard denies"); + + assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy"))); + // The seam runs on the derived header, then blocks: derive ran, submit + // did not. + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn submission_quota_denies_once_the_budget_is_spent() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(2, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + let err = router + .submit("mod-a", "cow", b"b".to_vec()) + .await + .expect_err("third submit over quota"); + + assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); + // The over-quota call is stopped at the gate, so the adapter saw only + // the two admitted submits. + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn quota_is_per_caller() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + assert!( + router.submit("mod-a", "cow", b"b".to_vec()).await.is_err(), + "mod-a is over its own budget" + ); + // A different caller has its own budget. + assert!( + router.submit("mod-b", "cow", b"b".to_vec()).await.is_ok(), + "mod-b has an independent budget" + ); + } + + #[tokio::test] + async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let adapter = + StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); + let router = router_with(quota, None, adapter); + + // First garbage body: derive fails, the failure is charged. + let first = router.submit("mod-a", "cow", b"junk".to_vec()).await; + assert!(matches!(first, Err(VenueError::InvalidBody(_)))); + // Second: the charge from the decode failure exhausts the budget, so + // the caller is stopped at the gate and the adapter is not re-invoked. + let second = router.submit("mod-a", "cow", b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::Denied(_)))); + assert_eq!( + calls.derive.load(Ordering::SeqCst), + 1, + "adapter derive-header was invoked exactly once", + ); + } + + #[tokio::test] + async fn non_decode_venue_errors_are_not_charged() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let adapter = StubAdapter::new(calls.clone()) + .with_derive(Err(VenueError::Unavailable("rpc down".into()))); + let router = router_with(quota, None, adapter); + + assert!(matches!( + router.submit("mod-a", "cow", b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + // A venue-side failure did not spend the caller's budget: it may try + // again, so derive is reached a second time. + assert!(matches!( + router.submit("mod-a", "cow", b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn status_and_cancel_pass_through_without_quota() { + let calls = Arc::new(StubCalls::default()); + // A spent budget must not block reads: status and cancel are not + // submissions. + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(matches!( + router.status("cow", b"r".to_vec()).await, + Ok(IntentStatus::Open) + )); + assert!(router.cancel("cow", b"r".to_vec()).await.is_ok()); + assert_eq!(calls.status.load(Ordering::SeqCst), 1); + assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_calls_to_one_adapter_are_serialised() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1000, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + let mut handles = Vec::new(); + for _ in 0..8 { + let router = router.clone(); + handles.push(tokio::spawn(async move { + let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; + })); + } + for h in handles { + h.await.expect("task joins"); + } + // The adapter mutex is held across the guest await, so no two calls + // ever overlapped inside the adapter. + assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); + } + + #[test] + fn duplicate_venue_id_is_rejected() { + let mut builder = PoolRouterBuilder::new(PoolQuota::default()); + let a = Arc::new(StubCalls::default()); + let b = Arc::new(StubCalls::default()); + builder + .install("cow".to_owned(), StubAdapter::new(a)) + .expect("first install"); + let err = builder + .install("cow".to_owned(), StubAdapter::new(b)) + .expect_err("second install collides"); + assert_eq!(err.venue, "cow"); + } + + #[test] + fn zero_quota_saturates_to_one() { + let router = PoolRouterBuilder::new(PoolQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(router.inner.quota.max_charges, 1); + } + + // ── status watch + polling ──────────────────────────────────────── + + #[tokio::test] + async fn accepted_submission_goes_under_status_watch() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls)); + + assert_eq!(router.watched_count(), 0); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(router.watched_count(), 1); + + // Re-submitting the same receipt does not double-watch it. + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(router.watched_count(), 1); + } + + #[tokio::test] + async fn requires_signing_outcome_is_not_watched() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain_id: 1, + to: vec![0u8; 20], + value: Vec::new(), + input: Vec::new(), + }))); + let router = router_with(PoolQuota::default(), None, adapter); + + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + // No receipt exists yet, so there is nothing to poll. + assert_eq!(router.watched_count(), 0); + assert!(router.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_reports_the_first_status_then_dedupes_repeats() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + // First poll: `last` is unset, so the current status reports. + let first = router.poll_status_transitions().await; + assert_eq!(first.len(), 1); + assert_eq!(first[0].venue, "cow"); + assert_eq!(first[0].receipt, b"receipt"); + assert_eq!(first[0].status, IntentStatus::Open); + + // Second poll: same status, nothing to report. + assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!(calls.status.load(Ordering::SeqCst), 2); + assert_eq!(router.watched_count(), 1, "open is not terminal"); + } + + #[tokio::test] + async fn poll_reports_each_transition_and_prunes_on_terminal() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Open), + Ok(IntentStatus::Settled(Some(b"tx".to_vec()))), + ]); + let router = router_with(PoolQuota::default(), None, adapter); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + let mut seen = Vec::new(); + for _ in 0..4 { + seen.extend(router.poll_status_transitions().await); + } + let statuses: Vec<&IntentStatus> = seen.iter().map(|u| &u.status).collect(); + assert_eq!( + statuses, + vec![ + &IntentStatus::Pending, + &IntentStatus::Open, + &IntentStatus::Settled(Some(b"tx".to_vec())), + ], + "the repeated pending is deduplicated; each transition reports once", + ); + assert_eq!(router.watched_count(), 0, "settled prunes the watch"); + // A further poll has nothing left to ask the adapter about. + assert!(router.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_failure_keeps_the_watch_for_the_next_cadence() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); + let router = router_with(PoolQuota::default(), None, adapter); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!( + router.watched_count(), + 1, + "transient failure keeps the entry" + ); + + // The venue recovered: the next poll reports the current status. + let updates = router.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].status, IntentStatus::Open); + } + + #[tokio::test] + async fn disowned_receipt_is_dropped_from_the_watch() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([Err(VenueError::InvalidReceipt)]); + let router = router_with(PoolQuota::default(), None, adapter); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!( + router.watched_count(), + 0, + "a receipt the venue disowns is never polled again", + ); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 32723fb8..df3e2801 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -13,6 +13,7 @@ use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; +use super::pool_router::PoolRouter; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -27,6 +28,12 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. pub http_gate: HttpGate, + /// Messaging content topics this store may publish to. Empty means + /// unscoped (the module default and current messaging posture); a + /// venue adapter carries its `[[adapters]].messaging_topics` grant + /// here, so an out-of-scope publish is refused before it reaches the + /// backend. + pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart /// sequence. Tags every captured log record. The namespace identity /// for storage is baked into `store`'s prefix. @@ -41,6 +48,10 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, + /// The intent pool router the `nexum:intent/pool` import dispatches to. + /// Every module store carries the same shared handle; an adapter store, + /// which cannot call pool, carries an empty one. + pub pool_router: PoolRouter, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 6c7126e8..f222b249 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -5,6 +5,12 @@ //! built in, and each runtime extension contributes its own namespace at //! the composition root via [`CapabilityRegistry::register`]. An extension //! interface is enforceable only once its namespace is registered. +//! +//! Components built through `#[nexum_sdk::module]` compile against a +//! per-module world derived from the same manifest, so their imports +//! equal their declarations by construction and this check is a pure +//! backstop for them; it retains its teeth for components built against +//! a wider world by hand, where nothing upstream narrows the imports. use std::collections::HashSet; @@ -28,6 +34,35 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; +/// Capability names under the `nexum:intent/` package a module may import. +/// Only the strategy-facing `pool` interface is a capability; the `types` +/// package is type-only and needs no declaration. +pub const INTENT_CAPABILITIES: &[&str] = &["pool"]; + +/// The intent namespace: the `nexum:intent/pool` import is linked into every +/// module linker, so a module that submits intents declares the `pool` +/// capability the same way it declares a `nexum:host/` one. +pub const INTENT_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "nexum:intent/", + ifaces: INTENT_CAPABILITIES, +}; + +/// The interfaces a `venue-adapter` world links: the scoped transport +/// only. An adapter has no local-store, remote-store, identity, or +/// logging - it moves bytes to and from its venue 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 ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; + +/// The adapter namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating an adapter manifest against +/// a registry built from this namespace rejects a declaration of any core +/// interface an adapter must not reach (e.g. `local-store`) as unknown. +pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "nexum:host/", + ifaces: ADAPTER_CAPABILITIES, +}; + /// Import prefix of the wasi:http package. Every interface under it /// (outgoing-handler, types, ...) is gated by the single /// [`HTTP_CAPABILITY`] declaration. @@ -51,10 +86,22 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with only the core namespace. + /// The registry with the core `nexum:host/` namespace plus the + /// strategy-facing `nexum:intent/pool` import every module linker carries. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], + } + } + + /// The registry a venue adapter validates against: only the scoped + /// transport interfaces plus `http`. An adapter manifest that declares + /// a core-only capability (e.g. `local-store`) fails as unknown here, + /// and the adapter linker withholds the same interfaces so the + /// component cannot instantiate against them either. + pub fn adapter() -> Self { + Self { + namespaces: vec![ADAPTER_NAMESPACE], } } @@ -216,6 +263,15 @@ mod tests { ); } + #[test] + fn intent_pool_is_a_core_capability_but_intent_types_is_not() { + let r = CapabilityRegistry::core(); + assert_eq!(r.wit_import_to_cap("nexum:intent/pool@0.1.0"), Some("pool")); + assert!(r.is_known("pool")); + // The type-only interface is not a capability and needs no declaration. + assert_eq!(r.wit_import_to_cap("nexum:intent/types@0.1.0"), None); + } + #[test] fn wit_import_to_cap_non_http_wasi_is_none() { let r = registry_with_cow(); @@ -313,4 +369,44 @@ mod tests { let r = registry_with_cow(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } + + #[test] + fn adapter_registry_knows_only_scoped_transport() { + // The scoped transport plus http are known; the core-only + // interfaces an adapter must not reach are not, so a manifest + // declaring them fails validation as unknown. + let r = CapabilityRegistry::adapter(); + assert!(r.is_known("chain")); + assert!(r.is_known("messaging")); + assert!(r.is_known("http")); + assert!(!r.is_known("local-store")); + assert!(!r.is_known("remote-store")); + assert!(!r.is_known("identity")); + assert!(!r.is_known("logging")); + } + + #[test] + fn adapter_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::adapter(); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!( + r.wit_import_to_cap("nexum:host/messaging@0.2.0"), + Some("messaging") + ); + assert_eq!( + r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), + Some("http") + ); + // A core-only interface is not a recognised adapter capability. + assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.2.0"), None); + } + + #[test] + fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + // The load path validates declared names against the registry; an + // adapter declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::adapter(); + assert!(!r.is_known("local-store")); + assert!(r.known_names().split(", ").all(|n| n != "local-store")); + } } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index e0b5efb6..d46565a0 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -184,6 +184,30 @@ chain_id = 1 ); } + #[test] + fn load_parses_intent_status_subscription() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "intent-status" + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::IntentStatus { venue: None } + )); + assert!(matches!( + &manifest.subscriptions[1], + Subscription::IntentStatus { venue: Some(v) } if v == "cow" + )); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -258,6 +282,50 @@ enabled = true assert_eq!(config.get("enabled").map(String::as_str), Some("true")); } + #[test] + fn module_kind_defaults_to_event_module() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "plain" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::EventModule); + } + + #[test] + fn module_kind_parses_venue_adapter() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "cow" +kind = "venue-adapter" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + } + + #[test] + fn module_kind_rejects_unknown_variant() { + let err = toml::from_str::( + r#" +[module] +name = "bad" +kind = "gadget" +"#, + ) + .expect_err("unknown kind rejected"); + let msg = err.to_string(); + assert!( + msg.contains("venue-adapter") || msg.contains("event-module"), + "error names the valid kinds: {msg}", + ); + } + #[test] fn host_allowed_exact_and_wildcard() { let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 19d283a4..e4c032f1 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +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(crate) use types::{LoadedManifest, Subscription}; +pub(crate) use types::{LoadedManifest, ModuleKind, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 628641d2..bcd45fea 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -73,7 +73,7 @@ pub enum Subscription { /// durable per-subscription cursor and re-opens the log poller /// from just after the last dispatched block, instead of at the /// current head. Delivery is then at-least-once, so the module must - /// tolerate redelivery (the chassis idempotency journal already + /// tolerate redelivery (the keeper idempotency journal already /// dedups it). #[serde(default)] resume: bool, @@ -93,6 +93,17 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, + /// Router-polled intent status transitions, delivered as + /// `intent-status` events. Fan-out is shared: the router polls each + /// installed adapter once per cadence and every subscribed module + /// receives the transition, filtered by `venue` when set. + #[serde(rename = "intent-status")] + IntentStatus { + /// Restrict delivery to transitions from this venue id. + /// Absent means transitions from every venue. + #[serde(default)] + venue: Option, + }, } #[derive(Debug, Deserialize, Default)] @@ -104,6 +115,27 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, + /// Which component kind this manifest describes. Defaults to + /// `event-module` so every existing `module.toml` keeps its meaning; + /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks + /// the bindgen and the scoped capability set from this discriminator. + #[serde(default)] + pub kind: ModuleKind, +} + +/// The component kind a manifest declares. The runtime carries two: the +/// original event-module over the six core primitives, and the venue +/// adapter over scoped transport only. Defaulting to `event-module` +/// preserves the meaning of every manifest written before adapters +/// existed. +#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ModuleKind { + /// Event-driven automation over the six core primitives. + #[default] + EventModule, + /// A single-venue adapter over scoped chain, messaging, and HTTP. + VenueAdapter, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 1045e79d..ad3fc0c4 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,6 +40,7 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; +use crate::host::pool_router::PoolRouter; use crate::host::provider_pool::ProviderError; use crate::runtime::restart_policy::backoff_for; use crate::runtime::task::{TaskExecutor, TaskExit, TaskSet}; @@ -146,6 +147,40 @@ where streams } +/// Router-driven intent status polling: one task that, on every cadence +/// tick, polls each installed adapter's status export through the shared +/// [`PoolRouter`] and forwards the observed transitions. The task is +/// spawned via `executor` into `tasks` like the reconnect tasks and exits +/// cleanly when the loop's receiver drops. +pub fn open_intent_status_stream( + router: PoolRouter, + cadence: Duration, + executor: &dyn TaskExecutor, + tasks: &mut TaskSet, +) -> IntentStatusStream { + let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); + tasks.push(executor.spawn(Box::pin(status_poll_task(router, cadence, tx)))); + Box::pin(receiver_stream(rx)) +} + +/// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence +/// first so the engine's boot dispatch settles before the first poll. +async fn status_poll_task( + router: PoolRouter, + cadence: Duration, + tx: mpsc::Sender, +) -> TaskExit { + loop { + tokio::time::sleep(cadence).await; + for update in router.poll_status_transitions().await { + if tx.send(update).await.is_err() { + // Receiver dropped -> engine shutting down. + return TaskExit::ReceiverGone; + } + } + } +} + /// Wrap an `mpsc::Receiver` as a `Stream` using /// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just /// for `ReceiverStream`. @@ -457,6 +492,11 @@ pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; +/// Router-observed intent status transitions, fanned to subscribers by the +/// event loop. Infallible items: poll failures are retried inside the poll +/// task on the next cadence rather than surfaced here. +pub type IntentStatusStream = + std::pin::Pin + Send>>; /// Drive the supervisor with events until `shutdown` resolves. /// @@ -469,6 +509,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, + intent_status_stream: Option, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) { @@ -490,9 +531,14 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; + let mut intent_statuses: BoxStream<'_, _> = match intent_status_stream { + Some(stream) => stream, + None => futures::stream::pending().boxed(), + }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; + let mut dispatched_intent_statuses: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -509,6 +555,7 @@ pub async fn run( Box, Option>, ), + IntentStatus(nexum::host::types::IntentStatusUpdate), Shutdown, StreamPanic(&'static str), } @@ -538,6 +585,11 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, + next = intent_statuses.next() => match next { + Some(update) => NextEvent::IntentStatus(update), + // The poll task loops forever; `None` means it exited. + None => NextEvent::StreamPanic("intent-status"), + }, }; match next { @@ -551,6 +603,10 @@ pub async fn run( .await; dispatched_chain_logs += 1; } + NextEvent::IntentStatus(update) => { + supervisor.dispatch_intent_status(update).await; + dispatched_intent_statuses += 1; + } NextEvent::Shutdown => { // Drop the stream-end receivers so the reconnect // tasks observe a closed channel and exit. Then drain @@ -558,10 +614,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); + drop(intent_statuses); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, + dispatched_intent_statuses, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -573,6 +631,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); + drop(intent_statuses); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 0b428d6b..b74bb102 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -37,24 +37,38 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, nexum}; -use crate::engine_config::{EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits}; +use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, +}; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::Extension; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; +use crate::host::pool_router::{AdapterActor, PoolRouter, PoolRouterBuilder}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; +use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ModuleKind, Subscription}; /// Owns every loaded module and exposes the dispatch surface the /// event loop needs. Generic over the [`RuntimeTypes`] lattice binding /// the component seam backends. pub struct Supervisor { modules: Vec>, + /// The intent pool router: every installed venue adapter's serialising + /// store, keyed by venue id. Cached so a module restart rebuilds a store + /// carrying the same shared handle. Adapters boot through the same store, + /// fuel, and memory machinery as modules but carry no subscriptions: + /// modules reach them through this router, not through dispatch. Folding + /// adapters into the restart and poison sweeps is still a later change. + pool_router: PoolRouter, + /// Venue adapters loaded at boot, whether or not `init` succeeded. + adapters_total: usize, + /// Adapters whose `init` succeeded and that are installed for routing. + adapters_alive: usize, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -202,6 +216,21 @@ struct LoadedModule { poisoned: bool, } +/// A venue adapter instantiated into a supervised store, ready to install in +/// the pool router. It boots through the same store, fuel, and memory +/// machinery as a module but carries no subscriptions: modules reach it +/// through the router, not through dispatch. Adapter restart and poison +/// handling are still a later change; an `init` failure leaves `alive` false +/// so the adapter is loaded but not routable. +struct LoadedAdapter { + /// Venue id the adapter answers for (its manifest name). + venue_id: String, + /// The refuelable adapter store, ready to serialise behind a router mutex. + actor: AdapterActor, + /// Whether `init` succeeded; a failed adapter is not installed for routing. + alive: bool, +} + impl Supervisor { /// Compile + instantiate every module declared in /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are @@ -215,6 +244,42 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); + // Adapters instantiate first: the pool router must contain them before + // any module store (which carries the built router) is built. Adapters + // link only their scoped transport, against a dedicated linker built + // from the same core backends, and their own stores carry an empty + // router since an adapter cannot call pool. + let adapter_linker = build_adapter_linker::(engine)?; + let adapter_registry = CapabilityRegistry::adapter(); + let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); + let adapters_total = engine_cfg.adapters.len(); + let mut adapters_alive = 0; + for entry in &engine_cfg.adapters { + let loaded = Self::load_adapter( + engine, + &adapter_linker, + entry, + components, + &engine_cfg.limits, + &adapter_registry, + clocks.as_ref(), + ) + .await + .with_context(|| format!("load adapter {}", entry.path.display()))?; + if loaded.alive { + adapters_alive += 1; + router_builder + .install(loaded.venue_id.clone(), loaded.actor) + .with_context(|| format!("install adapter {}", loaded.venue_id))?; + } else { + warn!( + adapter = %loaded.venue_id, + "adapter init failed - not installed for routing", + ); + } + } + let pool_router = router_builder.build(); + let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -225,15 +290,25 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), + pool_router.clone(), ) .await .with_context(|| format!("load module {}", entry.path.display()))?; modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); - info!(loaded = modules.len(), alive, "supervisor up"); + info!( + loaded = modules.len(), + alive, + adapters = adapters_total, + adapters_alive, + "supervisor up" + ); Ok(Self { modules, + pool_router, + adapters_total, + adapters_alive, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -264,6 +339,10 @@ impl Supervisor { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; + // The single-module override path serves `just run`; adapters are + // configured through `engine.toml`, so the router is empty here and + // every pool call resolves to `unknown-venue`. + let pool_router = PoolRouter::empty(); let loaded = Self::load_one( engine, linker, @@ -272,10 +351,14 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), + pool_router.clone(), ) .await?; Ok(Self { modules: vec![loaded], + pool_router, + adapters_total: 0, + adapters_alive: 0, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -297,9 +380,11 @@ impl Supervisor { run: RunId, http_allowlist: Vec, http_limits: OutboundHttpLimits, + messaging_topics: Vec, memory_limit: usize, fuel: u64, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -347,11 +432,13 @@ impl Supervisor { limits, http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(), http_gate: HttpGate::new(namespace, http_allowlist, http_limits), + messaging_topics, run, log_router: router, ext: components.ext.clone(), chain: components.chain.clone(), store: module_store, + pool_router, }, ); store.limiter(|state| &mut state.limits); @@ -359,6 +446,9 @@ impl Supervisor { Ok(store) } + // One flat argument per shared input threaded onto the store, plus the + // pool router the module's `nexum:intent/pool` import dispatches to. + #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, linker: &Linker>, @@ -367,27 +457,9 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { - // Canonical name is module.toml (ADR-0001). nexum.toml is accepted - // with a deprecation warning during the 0.1→0.2 transition. - let manifest_path = entry.manifest.clone().or_else(|| { - let dir = entry.path.parent()?.to_owned(); - let canonical = dir.join("module.toml"); - if canonical.exists() { - return Some(canonical); - } - let legacy = dir.join("nexum.toml"); - if legacy.exists() { - warn!( - target: "manifest", - path = %legacy.display(), - "nexum.toml is deprecated; rename to module.toml \ - (ADR-0001). Support will be removed in 0.3." - ); - return Some(legacy); - } - None - }); + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { info!(manifest = %p.display(), "loading module manifest"); @@ -434,9 +506,13 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), + // Event modules are unscoped for messaging; only venue + // adapters carry a topic grant. + Vec::new(), limits_cfg.memory(), limits_cfg.fuel(), clocks, + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -513,11 +589,153 @@ impl Supervisor { }) } + /// Load one `[[adapters]]` entry: resolve its manifest, verify it + /// declares the venue-adapter kind, enforce the scoped-transport + /// capability set, build a supervised store carrying the operator's + /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings + /// against the adapter linker, and run `init`. Nothing dispatches to + /// the result yet; it boots so the router can later reach it. + async fn load_adapter( + engine: &Engine, + linker: &Linker>, + entry: &AdapterEntry, + components: &Components, + limits_cfg: &ModuleLimits, + registry: &CapabilityRegistry, + clocks: Option<&WasiClockOverride>, + ) -> Result> { + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); + let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { + Some(p) if p.exists() => { + info!(manifest = %p.display(), "loading adapter manifest"); + manifest::load(p, registry)? + } + _ => { + warn!( + component = %entry.path.display(), + "no module.toml - falling back to anonymous adapter" + ); + manifest::fallback_manifest() + } + }; + + // The manifest kind is the discriminator: an [[adapters]] entry + // whose manifest is (or defaults to) an event-module is a config + // error, caught here before instantiation. A fallback manifest has + // the default event-module kind, so an adapter must ship a + // module.toml that declares the venue-adapter kind explicitly. + let kind = loaded_manifest.manifest.module.kind; + if kind != ModuleKind::VenueAdapter { + return Err(anyhow!( + "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ + a module.toml with [module] kind = \"venue-adapter\"", + entry.path.display(), + )); + } + + info!(component = %entry.path.display(), "compiling adapter component"); + let component = Component::from_file(engine, &entry.path) + .map_err(Error::from) + .with_context(|| format!("compile {}", entry.path.display()))?; + + // Enforce the scoped-transport capability set: `registry` is the + // adapter registry, so a declaration of any core-only interface + // fails at manifest load, and an undeclared transport import fails + // here. The linker withholds the same core-only interfaces, so an + // adapter reaching for one also fails to instantiate below. + manifest::enforce_capabilities( + &loaded_manifest, + component.component_type().imports(engine).map(|(n, _)| n), + registry, + ) + .with_context(|| format!("capability violation in {}", entry.path.display()))?; + + let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "adapter".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + info!( + adapter = %adapter_namespace, + fuel = limits_cfg.fuel(), + memory_bytes = limits_cfg.memory(), + http_allow = entry.http_allow.len(), + messaging_topics = entry.messaging_topics.len(), + "applied adapter resource limits and transport scope", + ); + + let run = RunId::new(adapter_namespace.clone(), 0); + // An adapter store cannot call pool, so it carries an empty router; + // this also keeps the real router out of the adapter's `HostState`, + // so there is no reference cycle back into the router that owns it. + let mut store = Self::build_store( + engine, + components, + run.clone(), + entry.http_allow.clone(), + limits_cfg.http(), + entry.messaging_topics.clone(), + limits_cfg.memory(), + limits_cfg.fuel(), + clocks, + PoolRouter::empty(), + )?; + let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) + .await + .map_err(Error::from) + .with_context(|| format!("instantiate {}", entry.path.display()))?; + + let config: Config = if loaded_manifest.config.is_empty() { + vec![("name".into(), adapter_namespace.clone())] + } else { + loaded_manifest.config.clone() + }; + let init_succeeded = match bindings + .call_init(&mut store, &config) + .await + .map_err(Error::from)? + { + Ok(()) => { + info!(adapter = %adapter_namespace, "adapter init succeeded"); + true + } + Err(e) => { + warn!( + adapter = %adapter_namespace, + kind = crate::host::error::fault_label(&e), + message = crate::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + false + } + }; + // Refuel after init so the first routed call starts with a full budget. + store.set_fuel(limits_cfg.fuel())?; + + Ok(LoadedAdapter { + venue_id: adapter_namespace, + actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), + alive: init_succeeded, + }) + } + /// Number of modules currently loaded. pub fn module_count(&self) -> usize { self.modules.len() } + /// Number of venue adapters loaded at boot, alive or not. + pub fn adapter_count(&self) -> usize { + self.adapters_total + } + + /// Number of adapters whose `init` succeeded and that are installed in the + /// pool router for routing. + #[cfg_attr(not(test), allow(dead_code))] + pub fn adapter_alive_count(&self) -> usize { + self.adapters_alive + } + /// Chains any module asked for block events on. The caller opens /// one shared block subscription per chain and routes through /// `dispatch_block`. Sorted by numeric id and deduped (`Chain` is @@ -620,9 +838,11 @@ impl Supervisor { // against the cached `Engine`. let linker = build_linker::(&self.engine, &self.extensions)?; - // Borrowed before the `&mut self.modules[idx]` reborrow so the - // restart path applies the same clock override as the initial boot. + // Borrowed before the `&mut self.modules[idx]` reborrow so the restart + // path applies the same clock override and the same shared pool router + // as the initial boot. let clocks = self.clocks.clone(); + let pool_router = self.pool_router.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -633,9 +853,11 @@ impl Supervisor { run.clone(), module.http_allowlist.clone(), module.http_limits, + Vec::new(), module.memory_limit, module.fuel_per_event, clocks.as_ref(), + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -702,7 +924,7 @@ impl Supervisor { .collect(); for idx in candidate_indices { if matches!( - self.dispatch_to(idx, chain, "block", block_number, &event) + self.dispatch_to(idx, chain_id, "block", block_number, &event) .await, DispatchOutcome::Ok, ) { @@ -786,7 +1008,7 @@ impl Supervisor { let ok = matches!( self.dispatch_to( idx, - chain, + chain.id(), "chain-log", block_number.unwrap_or_default(), &event @@ -820,20 +1042,86 @@ impl Supervisor { ok } + /// Dispatch a router-observed intent status transition to every module + /// subscribed to `intent-status` events whose venue filter admits the + /// update's venue. Returns the number of modules invoked. Mirrors + /// `dispatch_block`: dead modules past their backoff are restarted + /// first, poisoned modules are skipped. + pub async fn dispatch_intent_status( + &mut self, + update: nexum::host::types::IntentStatusUpdate, + ) -> usize { + let now = std::time::Instant::now(); + let restart_candidates: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + !m.poisoned && !m.alive && m.next_attempt.is_some_and(|t| t <= now) + }) + .collect(); + for idx in restart_candidates { + self.try_restart(idx).await; + } + + let candidate_indices: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + if m.poisoned || !m.alive { + return false; + } + m.subscriptions.iter().any(|s| { + matches!( + s, + Subscription::IntentStatus { venue } + if venue.as_deref().is_none_or(|v| v == update.venue) + ) + }) + }) + .collect(); + let event = nexum::host::types::Event::IntentStatus(update); + let mut dispatched = 0; + for idx in candidate_indices { + // Status transitions are venue-scoped, not chain-scoped: the + // telemetry chain id and block number carry the 0 sentinel. + if matches!( + self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + DispatchOutcome::Ok, + ) { + dispatched += 1; + } + } + dispatched + } + + /// Whether any loaded module subscribes to `intent-status` events. + /// The launcher polls adapter statuses only when this holds: with no + /// subscriber every transition would be dropped on arrival. + pub fn has_intent_status_subscribers(&self) -> bool { + self.modules.iter().any(|m| { + m.subscriptions + .iter() + .any(|s| matches!(s, Subscription::IntentStatus { .. })) + }) + } + + /// The shared intent pool router carried by every module store. + pub fn pool_router(&self) -> PoolRouter { + self.pool_router.clone() + } + /// Shared per-module dispatch path: refuel, call `on_event`, and /// process the three outcomes (ok / fault / trap) with the /// same telemetry + lifecycle bookkeeping. Returns whether the /// guest call succeeded; the caller layers any path-specific /// follow-up (e.g. the progress marker on `dispatch_block`). + /// `chain_id` is telemetry only; chain-less event kinds pass 0. async fn dispatch_to( &mut self, idx: usize, - chain: Chain, + chain_id: u64, event_kind: &'static str, block_number: u64, event: &nexum::host::types::Event, ) -> DispatchOutcome { - let chain_id = chain.id(); let poison_policy = self.poison_policy; // Hoisted before the per-module borrow so the trap arm can // synthesize a panic record without re-borrowing `self`. @@ -1008,6 +1296,13 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; + // The intent pool import is linked into every module linker; it dispatches + // to the shared router carried in each store's `HostState`. Modules that do + // not import it are unaffected. + crate::bindings::pool::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. @@ -1018,6 +1313,60 @@ pub fn build_linker( Ok(linker) } +/// Build a `Linker` for the `venue-adapter` world: only the scoped +/// transport an adapter may reach - `chain`, `messaging`, and the +/// allowlisted `wasi:http` - plus the ambient WASI base. The core +/// `nexum:host` interfaces an adapter must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so an +/// adapter that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into adapters: an +/// adapter speaks its venue's protocol over the standard transport, not a +/// domain extension surface. +pub fn build_adapter_linker( + engine: &Engine, +) -> anyhow::Result>> { + let mut linker = Linker::>::new(engine); + nexum::host::chain::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + Ok(linker) +} + +/// Resolve a component's manifest path: the explicit `manifest` override +/// wins, else a sibling `module.toml`, else the deprecated `nexum.toml` +/// with a rename warning. `None` when neither sibling exists. Shared by the +/// module and adapter load paths. +fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { + if let Some(path) = explicit { + return Some(path.to_path_buf()); + } + // Canonical name is module.toml (ADR-0001). nexum.toml is accepted + // with a deprecation warning during the 0.1->0.2 transition. + let dir = component.parent()?.to_owned(); + let canonical = dir.join("module.toml"); + if canonical.exists() { + return Some(canonical); + } + let legacy = dir.join("nexum.toml"); + if legacy.exists() { + warn!( + target: "manifest", + path = %legacy.display(), + "nexum.toml is deprecated; rename to module.toml \ + (ADR-0001). Support will be removed in 0.3." + ); + return Some(legacy); + } + None +} + /// Assemble the capability registry from the core namespace plus every /// extension's namespace. The result must agree with the linker built from /// the same `extensions`: enforcement recognises an extension import as a diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 5568d259..90d34f85 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -47,6 +47,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), + None, crate::runtime::task::TaskSet::new(), shutdown, ) @@ -86,6 +87,77 @@ fn example_module_toml() -> PathBuf { .join("modules/example/module.toml") } +fn echo_venue_module_toml() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("modules/examples/echo-venue/module.toml") +} + +fn echo_client_module_toml() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("modules/examples/echo-client/module.toml") +} + +/// Path to the pre-built reference venue adapter. Built by +/// `just build-venue`; the import-pinning test skips when absent. +fn echo_venue_wasm() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/wasm32-wasip2/release/echo_venue.wasm") +} + +/// Returns `None` and prints a skip message if the venue fixture isn't +/// built. +fn echo_venue_wasm_or_skip() -> Option { + let p = echo_venue_wasm(); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - run `just build-venue` to enable the venue import test", + p.display() + ); + None + } +} + +/// Path to the pre-built echo-client module, the strategy half of the echo +/// pair. Built by `just build-echo-client`; the round-trip test skips when +/// absent. +fn echo_client_wasm() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/wasm32-wasip2/release/echo_client.wasm") +} + +/// Returns `None` and prints a skip message if the echo-client fixture +/// isn't built. +fn echo_client_wasm_or_skip() -> Option { + let p = echo_client_wasm(); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - run `just build-echo-client` to enable the echo round-trip test", + p.display() + ); + None + } +} + /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -185,6 +257,87 @@ async fn e2e_supervisor_boots_example_module() { assert_eq!(supervisor.alive_count(), 1); } +/// The per-module world contract: the example component's +/// capability-bearing imports are exactly what its manifest declares +/// (`logging`), by construction of the emitted world rather than by +/// the toolchain eliding unused imports of a blanket world. +#[test] +fn e2e_example_component_imports_equal_declared_capabilities() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["logging"]), + "imports were: {imports:?}" + ); + + // No extension interface leaks in either: the blanket cow world is + // gone from modules that never declared it. + assert!( + imports + .iter() + .all(|name| !name.starts_with("shepherd:cow/")), + "imports were: {imports:?}" + ); +} + +/// The per-component venue-adapter world contract: an adapter built +/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// transport its manifest declares (`chain`), by construction of the +/// emitted world. The venue side never depended on toolchain elision; +/// this pins that it does not regress to it. +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = echo_venue_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + /// Boot with a manifest that subscribes to block events; dispatch one /// block event and verify the module was invoked and stayed alive. #[tokio::test] @@ -240,6 +393,354 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the router: accepts every submission with +/// a fixed receipt and serves statuses front-first from a script, falling +/// back to `open` once drained. +struct ScriptedAdapter { + statuses: std::collections::VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> futures::future::BoxFuture< + 'a, + Result, + > { + Box::pin(async move { + Ok(crate::bindings::IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: crate::bindings::value_flow::Settlement::EvmChain(1), + authorisation: crate::bindings::AuthScheme::Unsigned, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> futures::future::BoxFuture< + 'a, + Result, + > { + Box::pin(async move { + Ok(crate::bindings::SubmitOutcome::Accepted( + b"receipt".to_vec(), + )) + }) + } + + fn status( + &mut self, + _receipt: Vec, + ) -> futures::future::BoxFuture< + '_, + Result, + > { + Box::pin(async move { + Ok(self + .statuses + .pop_front() + .unwrap_or(crate::bindings::IntentStatus::Open)) + }) + } + + fn cancel( + &mut self, + _receipt: Vec, + ) -> futures::future::BoxFuture<'_, Result<(), crate::bindings::VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// Build a router with one scripted adapter installed under `cow`. +fn scripted_router(adapter: ScriptedAdapter) -> crate::host::pool_router::PoolRouter { + let mut builder = crate::host::pool_router::PoolRouterBuilder::new( + crate::host::pool_router::PoolQuota::default(), + ); + builder.install("cow".to_owned(), adapter).expect("install"); + builder.build() +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .unwrap(); + manifest +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// the transitions the router observed by polling the adapter's status +/// export, and a transition from a venue outside its filter is not +/// delivered. +#[tokio::test] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + use crate::bindings::IntentStatus; + + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let manifest = intent_status_manifest(dir.path()); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await + .expect("boot_single"); + assert!(supervisor.has_intent_status_subscribers()); + + // The router watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + let router = scripted_router(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Settled(None), + ])); + router + .submit("test-caller", "cow", b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in router.poll_status_transitions().await { + delivered += supervisor.dispatch_intent_status(update).await; + } + } + assert_eq!(delivered, 2, "pending then settled, one subscriber each"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // A venue outside the module's filter is not delivered. + let foreign = crate::bindings::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: IntentStatus::Open, + }; + assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); +} + +/// The event-loop wiring: the poll task's stream drives the supervisor, +/// and the module's handler observably ran (its log line is retained). +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use std::time::Duration; + + use crate::runtime::task::{TaskSet, TokioExecutor}; + + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let manifest = intent_status_manifest(dir.path()); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await + .expect("boot_single"); + + let router = scripted_router(ScriptedAdapter::new([])); + router + .submit("test-caller", "cow", b"body".to_vec()) + .await + .expect("submit"); + + let executor = TokioExecutor; + let mut tasks = TaskSet::new(); + let stream = crate::runtime::event_loop::open_intent_status_stream( + router, + Duration::from_millis(10), + &executor, + &mut tasks, + ); + crate::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + Some(stream), + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .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 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: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// The first-train acceptance path, end to end over two real components: +/// the echo-client module submits through `nexum:intent/pool`, the host +/// router forwards to the installed echo-venue adapter, and the module +/// receives the settled `intent-status` the router polls back. Proves the +/// intent core round-trips module -> host router -> venue adapter with no +/// scripted stand-ins on either side. +#[tokio::test] +async fn e2e_echo_module_router_adapter_round_trip() { + use crate::bindings::IntentStatus; + use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; + use crate::host::component::ChainMethod; + use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; + + let (Some(adapter_wasm), Some(module_wasm)) = + (echo_venue_wasm_or_skip(), echo_client_wasm_or_skip()) + else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = crate::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); + + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(echo_venue_module_toml()), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(echo_client_module_toml()), + }], + ..Default::default() + }; + + let mut supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); + assert!(supervisor.has_intent_status_subscribers()); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared pool router; the router watches the accepted receipt. + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + assert_eq!(supervisor.dispatch_block(block).await, 1); + + // Poll the router the module submitted through and fan its transitions + // back to the module. echo-venue settles instantly, so the first poll + // reports a terminal status and the watch is pruned. + let router = supervisor.pool_router(); + let mut delivered = 0; + for _ in 0..2 { + for update in router.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + assert!( + matches!(update.status, IntentStatus::Settled(_)), + "echo settles instantly; got {:?}", + update.status, + ); + delivered += supervisor.dispatch_intent_status(update).await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it submitted, and it + // received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the pool; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + /// A `ManualClock` override threads through `boot_single` onto the module /// store and is behaviour-neutral: the module boots, dispatches a block, and /// stays alive exactly as it does on the ambient clock. Locks the plumbing so @@ -768,6 +1269,7 @@ chain_id = 1 manifest: Some(example_manifest.clone()), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1312,6 +1814,7 @@ chain_id = 100 manifest: Some(chain_b_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1417,6 +1920,7 @@ chain_id = 100 manifest: Some(example_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1634,3 +2138,101 @@ fn chainlog_cursor_key_differs_by_each_input() { "address presence changes the key", ); } + +// ── venue-adapter boot ──────────────────────────────────────────────── + +/// The venue-adapter linker binds only the scoped transport (chain, +/// messaging, wasi base, allowlisted http) and withholds the core-only +/// interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn adapter_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + crate::supervisor::build_adapter_linker::(&engine) + .expect("adapter linker assembles"); +} + +/// The module-kind discriminator gates the adapter load path: an +/// `[[adapters]]` entry whose manifest is (or defaults to) an event-module +/// is rejected before instantiation with a message naming the required +/// kind. +#[tokio::test] +async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("cow.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("venue-adapter"), + "the kind gate names the required kind: {msg}", + ); +} + +/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// compile step and fails only because the referenced wasm is absent. This +/// proves the discriminator routed the entry to the adapter load path +/// rather than rejecting it on kind. +#[tokio::test] +async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + [capabilities]\nrequired = [\"chain\"]\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("missing-cow.wasm"), + manifest: Some(manifest), + http_allow: vec!["api.cow.fi".into()], + messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("absent adapter wasm must fail the compile step"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("compile") || msg.contains("missing-cow"), + "boot reached the compile step past the kind gate: {msg}", + ); + assert!( + !msg.contains("requires a module.toml"), + "the kind gate passed rather than rejecting: {msg}", + ); +} diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..022c61bd 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -60,9 +60,10 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Write as _}; +use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -191,41 +192,108 @@ impl ChainHost for MockChain { // ---------------------------------------------------------------- local-store -/// In-memory [`LocalStoreHost`] backed by a `HashMap`. Each operation -/// runs in O(1) except `list_keys`, which scans (small N expected for -/// tests). +/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: +/// namespaced views over one shared row map, plus store-wide entry +/// and byte limits. /// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. +/// A fresh store is the root view. [`namespaced`](Self::namespaced) +/// derives a sibling view over the same backing rows - identical key +/// strings in different namespaces never collide, matching the host's +/// per-module key prefixing. Limits sit on the shared backing store, +/// so one namespace's writes can exhaust another's headroom exactly +/// as two modules share one database file. Fault injection via +/// [`fail_on`](Self::fail_on) stays per-view. +/// +/// # Fidelity vs the real `redb` store +/// +/// Two gaps remain (deferred to the `MockRuntime` refactor, #94): +/// - **No transaction semantics** - `redb` wraps each `on_event` in an +/// implicit write transaction (commit on `Ok`, rollback on trap); this +/// mock commits every `set` immediately. +/// - **No concurrent access** - the backing `RefCell` is single-threaded, +/// whereas `redb` uses MVCC. #[derive(Default)] pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, + shared: Rc, + namespace: String, /// Key patterns that trigger injected faults on any operation. error_patterns: RefCell>, } +/// Backing rows and limits shared by every namespaced view. +#[derive(Default)] +struct SharedRows { + /// Rows keyed by `(namespace, key)`. + rows: RefCell>>, + /// Total stored bytes (key + value) across all namespaces. + bytes: Cell, + /// When set, `set` on a new key fails once the store holds this + /// many rows. + max_entries: Cell>, + /// When set, `set` fails once stored bytes would exceed this. + max_bytes: Cell>, +} + impl MockLocalStore { - /// Number of rows currently held. + /// A view over the same backing rows under `namespace`. Views with + /// the same namespace alias the same data (two handles onto one + /// module store); different namespaces are fully isolated even for + /// identical key strings. + /// + /// # Panics + /// + /// On an empty namespace - the runtime rejects those too. + pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { + let namespace = namespace.into(); + assert!( + !namespace.is_empty(), + "MockLocalStore: namespace must not be empty", + ); + MockLocalStore { + shared: Rc::clone(&self.shared), + namespace, + error_patterns: RefCell::new(Vec::new()), + } + } + + /// Number of rows in this view's namespace. pub fn len(&self) -> usize { - self.rows.borrow().len() + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() } - /// Whether the store is empty. + /// Whether this view's namespace holds no rows. pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() + self.len() == 0 } - /// Direct read for assertions - bypasses the trait. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() } - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. + /// Cap the row count across every namespace. Once reached, `set` + /// on a new key fails; overwriting an existing key still succeeds. pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, across every namespace). + /// A `set` that would push the total past the cap fails; deletes + /// and same-key overwrites release the bytes they displace. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); } /// Inject a fault for any operation where the key starts with @@ -250,36 +318,64 @@ impl MockLocalStore { impl LocalStoreHost for MockLocalStore { fn get(&self, key: &str) -> Result>, Fault> { self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .cloned()) } fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { self.check_injected_error(key)?; - if let Some(limit) = *self.max_entries.borrow() { - let rows = self.rows.borrow(); - if rows.len() >= limit && !rows.contains_key(key) { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } + let mut rows = self.shared.rows.borrow_mut(); + let compound = (self.namespace.clone(), key.to_string()); + let existing = rows.get(&compound).map(Vec::len); + if existing.is_none() + && let Some(limit) = self.shared.max_entries.get() + && rows.len() >= limit + { + return Err(Fault::Internal(format!( + "MockLocalStore: max entries ({limit}) reached" + ))); } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); + // Same-key overwrites release the displaced bytes before the + // new row is charged. + let displaced = existing.map_or(0, |len| key.len() + len); + let total = self.shared.bytes.get() - displaced + key.len() + value.len(); + if let Some(budget) = self.shared.max_bytes.get() + && total > budget + { + return Err(Fault::Internal(format!( + "MockLocalStore: max bytes ({budget}) reached" + ))); + } + rows.insert(compound, value.to_vec()); + self.shared.bytes.set(total); Ok(()) } fn delete(&self, key: &str) -> Result<(), Fault> { self.check_injected_error(key)?; - self.rows.borrow_mut().remove(key); + if let Some(value) = self + .shared + .rows + .borrow_mut() + .remove(&(self.namespace.clone(), key.to_string())) + { + self.shared + .bytes + .set(self.shared.bytes.get() - key.len() - value.len()); + } Ok(()) } fn list_keys(&self, prefix: &str) -> Result, Fault> { self.check_injected_error(prefix)?; let mut keys: Vec = self + .shared .rows .borrow() .keys() - .filter(|k| k.starts_with(prefix)) - .cloned() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .map(|(_, key)| key.clone()) .collect(); keys.sort(); Ok(keys) @@ -671,6 +767,77 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn local_store_namespaces_isolate_identical_keys() { + let store = MockLocalStore::default(); + let other = store.namespaced("other-module"); + store.set("watch:a", b"mine").unwrap(); + other.set("watch:a", b"theirs").unwrap(); + + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + assert_eq!( + other.get("watch:a").unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + + // Scans, counts, and snapshots stay view-scoped. + assert_eq!(store.len(), 1); + assert_eq!(other.len(), 1); + assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); + assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); + + // Deletes never reach across the namespace boundary. + other.delete("watch:a").unwrap(); + assert!(other.is_empty()); + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + } + + #[test] + fn local_store_same_namespace_views_alias_the_same_rows() { + let store = MockLocalStore::default(); + let one = store.namespaced("mod"); + let two = store.namespaced("mod"); + one.set("k", b"v").unwrap(); + assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + #[should_panic(expected = "namespace must not be empty")] + fn local_store_empty_namespace_panics() { + let _ = MockLocalStore::default().namespaced(""); + } + + #[test] + fn local_store_entry_limit_spans_namespaces() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + let other = store.namespaced("other-module"); + store.set("a", b"1").unwrap(); + other.set("b", b"2").unwrap(); + // The store is one shared file: a sibling namespace's rows + // consume the same headroom. + let err = store.set("c", b"3").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + } + + #[test] + fn local_store_byte_budget_enforced_and_released() { + let store = MockLocalStore::default(); + store.set_max_bytes(8); + store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget + let err = store.set("x", b"y").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); + + // A same-key overwrite releases the displaced value first. + store.set("abcd", b"12").unwrap(); + store.set("x", b"y").unwrap(); + + // Deleting releases the whole row's bytes. + store.delete("abcd").unwrap(); + store.set("ab", b"12").unwrap(); + assert_eq!(store.len(), 2); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 8410babb..f713c5c1 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -19,6 +19,10 @@ description = "Guest-side SDK for nexum runtime modules: host-neutral helpers us stderr-echo = [] [dependencies] +# Re-exported as `nexum_sdk::module`; the proc-macro emits glue that +# calls back into this crate (`bind_host_via_wit_bindgen!`, the host +# trait seam, the tracing facade). +nexum-macros = { path = "../nexum-macros" } alloy-primitives.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). @@ -39,11 +43,12 @@ tracing-core.workspace = true [dev-dependencies] proptest.workspace = true -# Dev-dependencies are excluded from Cargo's dependency-cycle check, so -# the nexum-sdk -> shepherd-sdk -> shepherd-sdk-test dev-dep chain is a -# normal, supported arrangement: the keeper stores acceptance-test -# against the same composed MockHost the flagship modules use. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +# Dev-dependencies are excluded from Cargo's dependency-cycle check, so the +# nexum-sdk <- nexum-sdk-test dev-dep is a normal, supported arrangement: the +# keeper stores acceptance-test against the composed world-neutral MockHost. +# 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" } # 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/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index f589f96b..57c49b70 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -18,7 +18,7 @@ use alloy_sol_types::{SolCall, sol}; use crate::Level; use crate::chain::{eth_call_params, parse_eth_call_result}; -use crate::host::Host; +use crate::host::{ChainHost, LoggingHost}; sol! { /// Chainlink AggregatorV3Interface - only the function the @@ -45,8 +45,11 @@ sol! { /// /// `domain` is embedded in the log line so a single host log stream /// can disambiguate which module's oracle failed. +// Bounded on the two capabilities it exercises (chain + logging), not +// the full `Host` supertrait, so modules whose worlds omit local-store +// can still call it. #[must_use] -pub fn read_latest_answer( +pub fn read_latest_answer( host: &H, chain_id: u64, oracle: Address, diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 06d1cd9e..637e7985 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -3,6 +3,11 @@ //! alone so they compile for any world and test against the in-memory //! mocks. //! +//! Private, single-tenant instance over the wallet's own authorised +//! orders; it submits intents to the venue and does not settle them. +//! This is not a public keeper network, fee marketplace, or MEV +//! searcher. +//! //! Three stores cover the machinery watcher modules hand-roll: //! //! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` @@ -13,6 +18,14 @@ //! - [`Journal`] - the receipt-keyed idempotency journal of //! `submitted:` / `observed:` presence markers. //! +//! Two pieces drive the stores from the poll loop: +//! +//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! one outcome out, at a given [`Tick`]. Implementations own the +//! transport and the outcome shape. +//! - [`Retrier`] - runs a [`RetryAction`]'s effect through the +//! stores after a failed keeper run attempt. +//! //! [`WatchRef`] ties the first two together: gate keys are derived //! from the exact hex substrings of the stored watch key, and //! [`WatchSet::remove`] drops a watch together with all of its gate @@ -69,6 +82,7 @@ //! ``` use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; use crate::host::{Fault, LocalStoreHost}; @@ -86,6 +100,14 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Canonical watch key for an owner / commitment-hash pair (lowercase +/// `0x`-prefixed hex on both halves). Free-standing because the key +/// shape is a property of the store convention, not of any host. +#[must_use] +pub fn watch_key(owner: &Address, hash: &B256) -> String { + format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") +} + /// Borrowed view of a watch key's two hex halves, parsed from a /// `watch:{owner}:{hash}` row. Gate keys are derived from the exact /// substrings of the stored key, so a parse-then-derive round trip is @@ -152,10 +174,11 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair (lowercase - /// `0x`-prefixed hex on both halves). + /// Canonical key for an owner / commitment-hash pair. Thin + /// delegate kept for discoverability; prefer the free + /// [`watch_key`], which needs no host turbofish. pub fn key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") + watch_key(owner, hash) } /// Insert or overwrite the watch row; returns the key written. @@ -292,3 +315,96 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { .is_some()) } } + +/// One poll dispatch's world view: chain, block height, and the block +/// clock in Unix seconds. Gate checks and backoff arithmetic read the +/// same instant a source is polled at, so a watch can never gate +/// itself against a clock it was not judged by. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Tick { + /// Chain the dispatch targets. + pub chain_id: u64, + /// Block height at the tick. + pub block: u64, + /// Block timestamp, Unix seconds. + pub epoch_s: u64, +} + +/// A source of conditional commitments: poll one watch, produce one +/// outcome. Generic over the host so implementations stay mock- +/// testable; deliberately no venue-transport abstraction - the source +/// owns its own wire (an `eth_call`, an HTTP probe, a stub). +/// +/// A transient failure should surface as a retry-flavoured outcome, +/// not tear down the caller's sweep: `poll` is infallible by contract. +pub trait ConditionalSource { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value (the encoded commitment parameters), passed + /// verbatim so the source owns the decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. + fn label(&self) -> &'static str { + "conditional" + } +} + +/// What the retry ledger should do to a watch after a failed +/// keeper run attempt. +/// +/// `IntoStaticStr` exposes each variant as a snake_case `&'static +/// str` for log and metric labels. `#[non_exhaustive]` so the +/// contract can grow a variant; downstream dispatch should treat an +/// unknown variant as "leave the watch in place" (the conservative +/// choice). +#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum RetryAction { + /// Leave the watch untouched; the next tick re-attempts. + TryNextBlock, + /// Gate the watch until `now + seconds` on the epoch clock. + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// Remove the watch and its gates; no retry can succeed. + Drop, +} + +/// Retry ledger: runs a [`RetryAction`]'s effect through the keeper +/// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; +/// `Drop` delegates to [`WatchSet::remove`], so gates go first and no +/// failure path can orphan one. +pub struct Retrier<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Retrier<'h, H> { + /// Ledger view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Apply `action` to the watch, with `now_epoch_s` as the backoff + /// origin. + pub fn apply( + &self, + watch: WatchRef<'_>, + action: RetryAction, + now_epoch_s: u64, + ) -> Result<(), Fault> { + match action { + RetryAction::TryNextBlock => Ok(()), + RetryAction::Backoff { seconds } => { + Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + } + RetryAction::Drop => WatchSet::new(self.host).remove(watch), + } + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index ac6531fe..bd255a8e 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -28,10 +28,16 @@ //! generates the per-module `WitBindgenHost` adapter over the //! wit-bindgen import shims. //! +//! - [`module`] - attribute macro that generates the whole per-cdylib +//! glue (the wit-bindgen call, the adapter above, the +//! `Guest`/`on-event` dispatch, and `export!`) from an `impl` block of +//! named handlers. +//! //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]). +//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! //! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader @@ -81,6 +87,9 @@ //! [`WatchSet`]: keeper::WatchSet //! [`Gates`]: keeper::Gates //! [`Journal`]: keeper::Journal +//! [`ConditionalSource`]: keeper::ConditionalSource +//! [`Retrier`]: keeper::Retrier +//! [`RetryAction`]: keeper::RetryAction //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer @@ -100,6 +109,11 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_cfg))] +/// Generate the per-cdylib module glue (wit-bindgen, host adapter, +/// `Guest`/`on-event` dispatch, `export!`) from an `impl` block of named +/// handlers. See [`nexum_macros::module`]. +pub use nexum_macros::module; + pub mod address; pub mod chain; pub mod config; diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 3e33c68c..25a91376 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -8,12 +8,17 @@ //! `convert_level`. The code differed across modules in zero places //! that were not bugs. //! -//! The macro assumes the module compiles against a world that -//! includes `nexum:host/event-module` with `wit_bindgen::generate!({ -//! ..., generate_all })`, so the standard wit-bindgen output paths -//! (`nexum::host::chain`, `nexum::host::local_store`, etc., plus the -//! crate-root `Fault`) are in scope at the call site. Modules -//! using a different world need to keep their own adapter for now. +//! The adapter is capability-selected: the `caps: [...]` form emits +//! only the pieces backed by the module's declared capabilities +//! (`#[nexum_sdk::module]` invokes it this way, matching the +//! per-module world it generates), while the zero-argument form emits +//! the full `chain, local_store, logging` set for modules that +//! compile against a blanket world with every core import present. +//! Either way the call site must already have the wit-bindgen output +//! for its world in scope (`wit_bindgen::generate!({ ..., +//! generate_all })`): each selected piece resolves its +//! `nexum::host::*` module, so selecting a capability the world does +//! not import is a compile error. //! //! A domain SDK layers its own interfaces on top by invoking this //! macro and adding trait impls for the same `WitBindgenHost` (the @@ -24,18 +29,23 @@ //! ```ignore //! wit_bindgen::generate!({ /* ... */ }); //! nexum_sdk::bind_host_via_wit_bindgen!(); -//! // `WitBindgenHost`, `convert_chain_err`, `convert_fault`, -//! // `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, and -//! // `install_tracing` are now in -//! // scope, with the wit-bindgen and SDK types tied together through -//! // identifier resolution. Call `install_tracing()` once at the top -//! // of `Guest::init` to route `tracing::info!(...)` to the host. A -//! // `From for nexum_sdk::events::Log` is also emitted so -//! // `on_event` maps a chain-logs batch straight to `Vec`. +//! // or, capability-selected: +//! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); +//! +//! // `WitBindgenHost`, `convert_fault`, and `sdk_fault_into_wit` are +//! // now in scope, plus per selected capability: `convert_chain_err` +//! // (chain), the `LocalStoreHost` impl (local_store), and +//! // `convert_level`, `HostLogSink`, and `install_tracing` +//! // (logging), with the wit-bindgen and SDK types tied together +//! // through identifier resolution. Call `install_tracing()` once at +//! // the top of `Guest::init` to route `tracing::info!(...)` to the +//! // host. A `From for nexum_sdk::events::Log` is also +//! // emitted so `on_event` maps a chain-logs batch straight to +//! // `Vec`. //! ``` -/// Generate `WitBindgenHost` + the core `*Host` trait impls + the -/// error / level converters. See module docs. +/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / +/// level converters for the selected capabilities. See module docs. /// /// Macro hygiene note: `macro_rules!` is not hygienic for type names /// or function items, so the names `WitBindgenHost`, `convert_chain_err`, @@ -43,77 +53,21 @@ /// and `install_tracing` are intentionally visible in the caller's scope. #[macro_export] macro_rules! bind_host_via_wit_bindgen { + // Blanket-world form: every core interface is in scope, emit the + // full adapter. () => { + $crate::bind_host_via_wit_bindgen!(caps: [chain, local_store, logging]); + }; + // Capability-selected form: the base pieces (which need only the + // always-present `nexum:host/types`) plus one block per listed + // capability. + (caps: [$($cap:ident),* $(,)?]) => { /// Wraps the module's per-cdylib wit-bindgen imports so the /// strategy can hold a `&impl Host` instead of dispatching on /// the free functions directly. Generated by /// `nexum_sdk::bind_host_via_wit_bindgen!`. struct WitBindgenHost; - impl $crate::host::ChainHost for WitBindgenHost { - fn request( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { - nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) - } - } - - impl $crate::host::LocalStoreHost for WitBindgenHost { - fn get( - &self, - key: &str, - ) -> ::core::result::Result< - ::core::option::Option<::std::vec::Vec>, - $crate::host::Fault, - > { - nexum::host::local_store::get(key).map_err(convert_fault) - } - fn set( - &self, - key: &str, - value: &[u8], - ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) - } - fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) - } - fn list_keys( - &self, - prefix: &str, - ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> - { - nexum::host::local_store::list_keys(prefix).map_err(convert_fault) - } - } - - impl $crate::host::LoggingHost for WitBindgenHost { - fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); - } - } - - /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into - /// the SDK's host-neutral `ChainError`. Exhaustive on both the - /// `Fault` vocabulary and the `RpcError` shape. - fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { - match e { - nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(convert_fault(f)) - } - nexum::host::chain::ChainError::Rpc(r) => { - $crate::host::ChainError::Rpc($crate::host::RpcError { - code: r.code, - message: r.message, - data: r.data.map(::core::convert::Into::into), - }) - } - } - } - /// Lift the wit-bindgen `types.fault` (per-cdylib) into the /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the /// `rate-limited` backoff record maps field for field. @@ -160,25 +114,6 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Translate a `tracing_core::Level` into the wit-bindgen - /// `logging::Level` wire enum. `Level` is a set of associated - /// consts, not a matchable enum, so compare rather than match; - /// the five tiers are total, so the final arm is `Trace`. - fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { - use $crate::Level; - if level == Level::ERROR { - nexum::host::logging::Level::Error - } else if level == Level::WARN { - nexum::host::logging::Level::Warn - } else if level == Level::INFO { - nexum::host::logging::Level::Info - } else if level == Level::DEBUG { - nexum::host::logging::Level::Debug - } else { - nexum::host::logging::Level::Trace - } - } - /// Rebuild the native alloy log from the per-cdylib wit-bindgen /// `chain-log` record. The one conversion home for the guest WIT /// edge: strategies receive `nexum_sdk::events::Log`, never the @@ -201,6 +136,101 @@ macro_rules! bind_host_via_wit_bindgen { } } + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* + }; +} + +/// One capability's slice of the `WitBindgenHost` adapter. Invoked by +/// [`bind_host_via_wit_bindgen!`]; not part of the public surface. +#[doc(hidden)] +#[macro_export] +macro_rules! __bind_host_cap_via_wit_bindgen { + (chain) => { + impl $crate::host::ChainHost for WitBindgenHost { + fn request( + &self, + chain_id: u64, + method: &str, + params: &str, + ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { + nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) + } + } + + /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into + /// the SDK's host-neutral `ChainError`. Exhaustive on both the + /// `Fault` vocabulary and the `RpcError` shape. + fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { + match e { + nexum::host::chain::ChainError::Fault(f) => { + $crate::host::ChainError::Fault(convert_fault(f)) + } + nexum::host::chain::ChainError::Rpc(r) => { + $crate::host::ChainError::Rpc($crate::host::RpcError { + code: r.code, + message: r.message, + data: r.data.map(::core::convert::Into::into), + }) + } + } + } + }; + (local_store) => { + impl $crate::host::LocalStoreHost for WitBindgenHost { + fn get( + &self, + key: &str, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::local_store::get(key).map_err(convert_fault) + } + fn set( + &self, + key: &str, + value: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::set(key, value).map_err(convert_fault) + } + fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::delete(key).map_err(convert_fault) + } + fn list_keys( + &self, + prefix: &str, + ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> + { + nexum::host::local_store::list_keys(prefix).map_err(convert_fault) + } + } + }; + (logging) => { + impl $crate::host::LoggingHost for WitBindgenHost { + fn log(&self, level: $crate::Level, message: &str) { + nexum::host::logging::log(convert_level(level), message); + } + } + + /// Translate a `tracing_core::Level` into the wit-bindgen + /// `logging::Level` wire enum. `Level` is a set of associated + /// consts, not a matchable enum, so compare rather than match; + /// the five tiers are total, so the final arm is `Trace`. + fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { + use $crate::Level; + if level == Level::ERROR { + nexum::host::logging::Level::Error + } else if level == Level::WARN { + nexum::host::logging::Level::Warn + } else if level == Level::INFO { + nexum::host::logging::Level::Info + } else if level == Level::DEBUG { + nexum::host::logging::Level::Debug + } else { + nexum::host::logging::Level::Trace + } + } + /// Routes guest `tracing` events to the bound host logging call. struct HostLogSink; diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 5ac54c87..e271fa41 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,15 +1,17 @@ -//! Keeper-store acceptance tests against the composed CoW -//! `shepherd_sdk_test::MockHost` - the same host the flagship modules -//! test with. These live as an integration test (not `#[cfg(test)]`) -//! because the mock crate links `nexum-sdk` externally, and the -//! external and unit-test copies of the host traits are distinct types. +//! Keeper-store acceptance tests against the composed +//! `nexum_sdk_test::MockHost` - the keeper touches only the local +//! store, so the world-neutral mock is the whole seam. These live as +//! an integration test (not `#[cfg(test)]`) because the mock crate +//! links `nexum-sdk` externally, and the external and unit-test +//! copies of the host traits are distinct types. use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, WATCH_PREFIX, WatchRef, WatchSet, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, + Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; -use shepherd_sdk_test::MockHost; +use nexum_sdk_test::MockHost; fn sample_owner() -> Address { address!("00112233445566778899aabbccddeeff00112233") @@ -23,7 +25,7 @@ fn sample_hash() -> B256 { #[test] fn watch_key_is_lowercase_prefixed_hex() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); assert_eq!( key, concat!( @@ -35,7 +37,7 @@ fn watch_key_is_lowercase_prefixed_hex() { #[test] fn watch_key_round_trips_via_parse() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).expect("parse"); assert_eq!( watch.owner_hex().parse::
().unwrap(), @@ -112,7 +114,7 @@ fn put_overwrites_in_place() { fn get_absent_watch_is_none() { let host = MockHost::new(); let watches = WatchSet::new(&host); - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).unwrap(); assert_eq!(watches.get(watch).unwrap(), None); } @@ -351,3 +353,138 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(snapshot.contains_key("submitted:0xuid")); assert!(!snapshot.contains_key("observed:0xuid")); } + +// ---- retry ledger ---- + +fn seeded_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +#[test] +fn ledger_try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let before = host.store.snapshot(); + + Retrier::new(&host) + .apply( + WatchRef::parse(&key).unwrap(), + RetryAction::TryNextBlock, + 1_000, + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); +} + +#[test] +fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .unwrap(); + + let gates = Gates::new(&host); + assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_030_u64.to_le_bytes().to_vec(), + ); + assert!( + host.store.snapshot().contains_key(&key), + "backoff must keep the watch", + ); +} + +#[test] +fn ledger_backoff_saturates_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &u64::MAX.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 500).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Drop, 1_000) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn retry_action_labels_are_stable_snake_case() { + let cases: [(RetryAction, &str); 3] = [ + (RetryAction::TryNextBlock, "try_next_block"), + (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::Drop, "drop"), + ]; + for (action, label) in cases { + assert_eq!(<&'static str>::from(action), label); + } +} + +// ---- conditional source ---- + +/// A source is generic over the host and owns its outcome shape; the +/// keeper passes the stored params verbatim and the tick it judged +/// the gates by. +#[test] +fn conditional_source_sees_params_and_tick_verbatim() { + struct EchoSource; + impl ConditionalSource for EchoSource { + type Outcome = (usize, u64, u64, u64, String); + fn poll( + &self, + _host: &H, + watch: WatchRef<'_>, + params: &[u8], + tick: &Tick, + ) -> Self::Outcome { + ( + params.len(), + tick.chain_id, + tick.block, + tick.epoch_s, + watch.key(), + ) + } + } + + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tick = Tick { + chain_id: 1, + block: 42, + epoch_s: 1_700_000_000, + }; + + let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); + assert_eq!(len, b"params".len()); + assert_eq!(chain_id, 1); + assert_eq!(block, 42); + assert_eq!(epoch_s, 1_700_000_000); + assert_eq!(echoed, key); +} diff --git a/crates/nexum-venue-sdk/Cargo.toml b/crates/nexum-venue-sdk/Cargo.toml new file mode 100644 index 00000000..f3fd250a --- /dev/null +++ b/crates/nexum-venue-sdk/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nexum-venue-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Guest-side SDK for venue adapters: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, and typed wrappers over the scoped transport imports." + +[lib] +# Plain library - adapters link this and emit their own cdylib for the +# WASM Component. Building on the host target is also supported so the +# codec, client core, and conversions are unit-testable without a wasm +# toolchain (the wit-bindgen import shims compile to unreachable stubs +# off-wasm). + +[lints] +workspace = true + +[dependencies] +# Backs the `IntentBody` wire codec; re-exported (`body::__private`) for +# the derive's generated code so an adapter crate needs no direct borsh +# declaration unless its payloads derive the borsh traits themselves. +borsh.workspace = true +# Source of the `IntentBody` derive, re-exported at the crate root next +# to the trait it implements. +nexum-macros = { path = "../nexum-macros" } +# Host-neutral SDK layer this crate builds on: the `ChainHost` seam the +# chain wrapper implements, the shared `Fault` vocabulary, and the +# wasi:http `fetch` surface re-exported as `transport::http`. +nexum-sdk = { path = "../nexum-sdk" } +strum.workspace = true +thiserror.workspace = true +wit-bindgen.workspace = true diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs new file mode 100644 index 00000000..58fc8ffc --- /dev/null +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -0,0 +1,97 @@ +//! The [`VenueAdapter`] trait and the export glue that turns an impl of +//! 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 four intent functions from `nexum:intent/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. + +use crate::{Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; + +/// One venue's protocol speaker: the guest-side face of the +/// `venue-adapter` world. Implement it on a unit struct and hand that to +/// [`export_venue_adapter!`](crate::export_venue_adapter); bodies and +/// receipts arrive as the opaque bytes the wire carries, and impls +/// recover typing through [`IntentBody`](crate::IntentBody) (whose +/// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). +pub trait VenueAdapter { + /// Configure the adapter from its `[config]` table before any + /// submission. Mirrors the event-module `init`, so the supervisor + /// boots both component kinds through the same machinery. + fn init(config: Config) -> Result<(), Fault>; + + /// 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 + /// submit. + fn derive_header(body: Vec) -> Result; + + /// Submit an opaque intent body to this adapter's venue. Success is + /// either the venue's receipt or `requires-signing`: a transaction + /// the host must sign and send before the intent exists. + fn submit(body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(receipt: Vec) -> Result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can + /// no longer win the race. + fn cancel(receipt: Vec) -> Result<(), VenueError>; +} + +/// Export a [`VenueAdapter`] impl as the crate's `venue-adapter` world. +/// +/// Invoke once at the top level of the adapter's cdylib crate. Emits a +/// hidden shim type wiring the world's `Guest` traits to the adapter's +/// associated functions, then the wit-bindgen export glue; the linker +/// rejects a second invocation in one component (duplicate export +/// symbols), matching the one-adapter-per-component contract. +#[macro_export] +macro_rules! export_venue_adapter { + ($adapter:ty) => { + #[doc(hidden)] + struct __NexumVenueAdapterExport; + + impl $crate::bindings::Guest for __NexumVenueAdapterExport { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), $crate::Fault> { + <$adapter as $crate::VenueAdapter>::init(config) + } + } + + impl $crate::bindings::exports::nexum::intent::adapter::Guest + for __NexumVenueAdapterExport + { + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::derive_header(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::cancel(receipt) + } + } + + $crate::bindings::__export_venue_adapter_world!( + __NexumVenueAdapterExport with_types_in $crate::bindings + ); + }; +} diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs new file mode 100644 index 00000000..3e88fff2 --- /dev/null +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -0,0 +1,26 @@ +//! Guest bindings for the `nexum:adapter/venue-adapter` world. +//! +//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, +//! the venue SDK generates the adapter world's bindings once, here: the +//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport +//! wrappers, and the intent client core are all expressed over these +//! types, and [`export_venue_adapter!`](crate::export_venue_adapter) +//! emits the component export glue into the adapter's own cdylib via the +//! generated (hidden) export macro. Downstream bindgens wanting type +//! identity with this crate remap `nexum:intent/types` and +//! `nexum:value-flow/types` onto these modules with `with`. + +wit_bindgen::generate!({ + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/venue-adapter", + generate_all, + pub_export_macro: true, + export_macro_name: "__export_venue_adapter_world", + default_bindings_module: "nexum_venue_sdk::bindings", + additional_derives: [PartialEq], +}); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs new file mode 100644 index 00000000..d542aca2 --- /dev/null +++ b/crates/nexum-venue-sdk/src/body.rs @@ -0,0 +1,94 @@ +//! The versioned intent-body codec: [`IntentBody`] and its typed +//! [`BodyError`]. +//! +//! An intent body crosses the pool and adapter boundaries as opaque +//! bytes; typing is recovered guest-side against the venue's published +//! schema. That schema is an outer version enum whose wire form is the +//! borsh enum layout: a one-byte version tag (the variant's declaration +//! index) followed by the borsh-encoded payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) implements the codec over such an +//! enum and is the intended way to get an impl; the derive owns the tag +//! handling, so an unknown version fails as the typed +//! [`BodyError::UnknownVersion`] instead of a stringly borsh error. +//! +//! The one non-obvious invariant: the tag order is the schema. Venues +//! append new versions at the end and never reorder or remove variants. + +use strum::IntoStaticStr; + +use crate::VenueError; + +/// The codec between a venue's typed body enum and the opaque bytes the +/// pool and adapter boundaries carry. Implement via +/// `#[derive(IntentBody)]` on the outer version enum. +pub trait IntentBody: Sized { + /// Encode as the one-byte version tag plus the borsh payload. + fn to_bytes(&self) -> Result, BodyError>; + + /// Decode, failing typedly on an empty body, an unknown version + /// tag, or a payload that does not parse as the tagged version + /// (including trailing bytes). + fn from_bytes(bytes: &[u8]) -> Result; +} + +/// Why a body failed to cross the [`IntentBody`] codec. +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum BodyError { + /// No bytes at all: not even a version tag. + #[error("empty body: missing the version tag")] + Empty, + /// The version tag names no published version of this body. The + /// decodable-future-versions story lives here: a v1 adapter handed + /// a v2 body reports the exact unknown tag instead of garbling the + /// payload. + #[error("unknown body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, + /// A payload failed to encode. Only reachable through a fallible + /// custom `BorshSerialize` impl; derived payloads encode + /// infallibly. + #[error("version {version} payload failed to encode: {detail}")] + Encode { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's encode failure detail. + detail: String, + }, +} + +/// Fold a codec failure into the wire error an adapter returns: decode +/// failures are the caller's malformed body (`invalid-body`, whose WIT +/// contract names exactly these two causes), an encode failure is the +/// adapter's own bug (`internal-error`). +impl From for VenueError { + fn from(err: BodyError) -> Self { + match err { + BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { + VenueError::InvalidBody(err.to_string()) + } + BodyError::Encode { .. } => VenueError::InternalError(err.to_string()), + } + } +} + +/// Re-exports for `#[derive(IntentBody)]` generated code only; not a +/// public surface. +#[doc(hidden)] +pub mod __private { + pub use borsh; +} diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs new file mode 100644 index 00000000..edfc8dd7 --- /dev/null +++ b/crates/nexum-venue-sdk/src/client.rs @@ -0,0 +1,93 @@ +//! The typed intent client core: [`IntentClient`] over the byte-level +//! [`IntentPool`] seam. +//! +//! The pool boundary carries opaque bodies; this module is where a +//! typed body meets it. [`IntentClient`] binds one venue and encodes +//! through [`IntentBody`] before submission, so strategy code never +//! handles wire bytes. The seam is byte-level on purpose: the +//! strategy-module SDK implements [`IntentPool`] over its own +//! `nexum:intent/pool` import shims, tests implement it in memory +//! (an in-process adapter works directly), and the typed layer above is +//! shared by both. + +use strum::IntoStaticStr; + +use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; + +/// Byte-level access to the strategy-facing `nexum:intent/pool` +/// interface, venue named per call as on the wire. +pub trait IntentPool { + /// Submit an opaque intent body to the named venue. + fn submit(&self, venue: &str, body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(&self, venue: &str, receipt: &[u8]) -> Result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can + /// no longer win the race. + fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError>; +} + +/// A typed intent client bound to one venue: encodes an [`IntentBody`] +/// to wire bytes and forwards through the [`IntentPool`] seam. +#[derive(Clone, Debug)] +pub struct IntentClient

{ + pool: P, + venue: String, +} + +impl IntentClient

{ + /// Bind a pool handle to the venue id the router resolves. + pub fn new(pool: P, venue: impl Into) -> Self { + Self { + pool, + venue: venue.into(), + } + } + + /// The venue id every call on this client routes to. + pub fn venue(&self) -> &str { + &self.venue + } + + /// Encode a typed body and submit it to the bound venue. + pub fn submit(&self, body: &B) -> Result { + let bytes = body.to_bytes()?; + self.pool + .submit(&self.venue, bytes) + .map_err(ClientError::Venue) + } + + /// Report where a previously submitted intent is in its life. + pub fn status(&self, receipt: &[u8]) -> Result { + self.pool + .status(&self.venue, receipt) + .map_err(ClientError::Venue) + } + + /// Ask the bound venue to withdraw an intent. + pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + self.pool + .cancel(&self.venue, receipt) + .map_err(ClientError::Venue) + } +} + +/// Why a typed intent call failed: before the wire (the body failed to +/// encode) or beyond it (the pool or venue refused). +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[derive(Clone, Debug, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum ClientError { + /// The typed body failed to encode; nothing reached the pool. + #[error(transparent)] + Body(#[from] BodyError), + /// The pool or the venue behind it failed the call. The payload is + /// the wire `venue-error`, which carries no `Display`; format via + /// `Debug`. + #[error("venue error: {0:?}")] + Venue(VenueError), +} diff --git a/crates/nexum-venue-sdk/src/faults.rs b/crates/nexum-venue-sdk/src/faults.rs new file mode 100644 index 00000000..9a0e3a1e --- /dev/null +++ b/crates/nexum-venue-sdk/src/faults.rs @@ -0,0 +1,148 @@ +//! Conversions between the three failure vocabularies an adapter +//! touches: the wire [`Fault`] its exports return, the SDK-neutral +//! [`host::Fault`] the transport seams speak, and the [`VenueError`] the +//! intent face reports. +//! +//! Every conversion here is lossy only downward (a structured case folds +//! to a payload-bearing string case, never the reverse), so `?` in an +//! adapter always preserves the most structured form the target +//! vocabulary can carry. + +use nexum_sdk::host; + +use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; +use crate::{Fault, VenueError}; + +/// Lift the wire fault into the SDK-neutral vocabulary the transport +/// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is +/// this crate's own bindgen, so a new WIT case fails here first. +pub fn fault_into_sdk(fault: Fault) -> host::Fault { + match fault { + Fault::Unsupported(s) => host::Fault::Unsupported(s), + Fault::Unavailable(s) => host::Fault::Unavailable(s), + Fault::Denied(s) => host::Fault::Denied(s), + Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + Fault::Timeout => host::Fault::Timeout, + Fault::InvalidInput(s) => host::Fault::InvalidInput(s), + Fault::Internal(s) => host::Fault::Internal(s), + } +} + +/// Lower the SDK-neutral fault back into the wire fault an adapter's +/// `init` returns, so a helper's `host::Fault` propagates with `?`. +/// +/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: +/// a future SDK case lands as `internal` carrying its `Display` detail. +impl From for Fault { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Unsupported(s) => Fault::Unsupported(s), + host::Fault::Unavailable(s) => Fault::Unavailable(s), + host::Fault::Denied(s) => Fault::Denied(s), + host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => Fault::Timeout, + host::Fault::InvalidInput(s) => Fault::InvalidInput(s), + host::Fault::Internal(s) => Fault::Internal(s), + other => Fault::Internal(other.to_string()), + } + } +} + +/// Fold a transport fault into the venue error an intent function +/// returns: a policy refusal stays `denied`, retryable transport states +/// (`unavailable`, `rate-limited`, `timeout`) fold to `unavailable`, +/// `unsupported` passes through, and the caller-shaped cases +/// (`invalid-input`, `internal`) become `internal-error` because inside +/// an intent function the transport's caller is the adapter itself. +impl From for VenueError { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Denied(s) => VenueError::Denied(s), + host::Fault::Unsupported(s) => VenueError::Unsupported(s), + host::Fault::Unavailable(_) | host::Fault::RateLimited(_) | host::Fault::Timeout => { + VenueError::Unavailable(fault.to_string()) + } + other => VenueError::InternalError(other.to_string()), + } + } +} + +/// Fold a wasi:http fetch failure into the venue error an intent +/// function returns: an allowlist refusal stays `denied`, timeouts and +/// transport failures are retryable `unavailable`, and a request the +/// adapter itself malformed is `internal-error`. +impl From for VenueError { + fn from(err: nexum_sdk::http::FetchError) -> Self { + use nexum_sdk::http::FetchError; + match err { + FetchError::Denied => VenueError::Denied(err.to_string()), + FetchError::Timeout(_) | FetchError::Transport(_) => { + VenueError::Unavailable(err.to_string()) + } + FetchError::InvalidRequest(_) => VenueError::InternalError(err.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host; + + use crate::{Fault, VenueError}; + + #[test] + fn wire_fault_round_trips_through_sdk() { + let cases = [ + Fault::Unsupported("u".into()), + Fault::Unavailable("u".into()), + Fault::Denied("d".into()), + Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { + retry_after_ms: Some(250), + }), + Fault::Timeout, + Fault::InvalidInput("i".into()), + Fault::Internal("i".into()), + ]; + for case in cases { + let there = super::fault_into_sdk(case.clone()); + assert_eq!(Fault::from(there), case); + } + } + + #[test] + fn transport_fault_folds_to_venue_error_by_shape() { + assert_eq!( + VenueError::from(host::Fault::Denied("nope".into())), + VenueError::Denied("nope".into()), + ); + assert!(matches!( + VenueError::from(host::Fault::Timeout), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(host::Fault::InvalidInput("bug".into())), + VenueError::InternalError(_) + )); + } + + #[test] + fn fetch_error_folds_to_venue_error_by_shape() { + use nexum_sdk::http::FetchError; + assert!(matches!( + VenueError::from(FetchError::Denied), + VenueError::Denied(_) + )); + assert!(matches!( + VenueError::from(FetchError::Transport("reset".into())), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(FetchError::InvalidRequest("bad url".into())), + VenueError::InternalError(_) + )); + } +} diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs new file mode 100644 index 00000000..45a5dc43 --- /dev/null +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -0,0 +1,90 @@ +//! # nexum-venue-sdk +//! +//! Guest-side SDK for venue adapters: the second component kind, one +//! venue's protocol speaker exporting the `venue-adapter` world. Where +//! `nexum-sdk` serves the strategy-module persona, this crate serves the +//! venue author. +//! +//! ## What lives here +//! +//! - [`VenueAdapter`] - the trait mirroring the world's export face +//! (`init` plus the four intent functions), and +//! [`export_venue_adapter!`] which turns an impl into the component's +//! export glue. +//! +//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh +//! codec over the outer per-venue version enum. The wire form is a +//! one-byte version tag plus the borsh payload; an unknown tag fails +//! typedly rather than as a stringly decode error. +//! +//! - [`client`] - the typed intent client core: [`IntentClient`] binds a +//! venue and encodes through [`IntentBody`] before the byte-level +//! [`IntentPool`] seam. Lives here (not in the strategy SDK) so the +//! codec and the client that speaks it version together. +//! +//! - [`transport`] - typed wrappers over the world's scoped imports: +//! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] +//! seam (plus batch), [`HostMessaging`](transport::HostMessaging) +//! behind [`MessagingHost`](transport::MessagingHost), and the +//! wasi:http surface re-exported as [`transport::http`]. +//! +//! - [`faults`] - the conversions that make `?` work across the wire +//! fault, the SDK-neutral fault, and [`VenueError`]. +//! +//! ## Why the bindgen lives in this crate +//! +//! Unlike event modules (per-cdylib `wit_bindgen::generate!`), the +//! adapter world's bindings generate once, in [`bindings`]: the trait, +//! wrappers, and client core are all typed over them, and the export +//! macro reaches back in via `with_types_in`. An adapter crate therefore +//! needs no wit-bindgen dependency and no world knowledge of its own. +//! +//! [`ChainHost`]: nexum_sdk::host::ChainHost +//! [`IntentClient`]: client::IntentClient +//! [`IntentPool`]: client::IntentPool + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[allow(missing_docs)] +pub mod bindings; + +pub mod adapter; +pub mod body; +pub mod client; +pub mod faults; +pub mod transport; + +pub use adapter::VenueAdapter; +pub use body::{BodyError, IntentBody}; +pub use client::{ClientError, IntentClient, IntentPool}; +/// Derive [`IntentBody`] on the outer per-venue version enum. See +/// [`nexum_macros::IntentBody`]. +pub use nexum_macros::IntentBody; +/// Emit the per-cdylib export glue and per-component world for a venue +/// adapter. Apply to an inherent `impl` of the adapter face +/// (`derive_header`, `submit`, `status`, `cancel`, plus an optional +/// `init`); the built component imports exactly the manifest's declared +/// scoped transport. See [`nexum_macros::venue`]. +/// +/// The self-contained per-cdylib alternative to +/// [`export_venue_adapter!`]: that macro exports through this crate's +/// shared blanket-world bindgen (chain and messaging always imported, +/// relying on toolchain elision), whereas `#[venue]` derives a narrowed +/// world from the manifest and generates its own bindings. +pub use nexum_macros::venue; + +/// The intent ontology at its plain spellings: the types the +/// [`VenueAdapter`] face and the client core speak. +pub use bindings::nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +}; +/// The value-flow vocabulary intent headers are expressed in. +pub use bindings::nexum::value_flow::types as value_flow; + +/// The wire config table (`nexum:host/types.config`) `init` receives. +pub use bindings::nexum::host::types::Config; +/// The wire fault (`nexum:host/types.fault`) `init` returns. Transport +/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the +/// [`faults`] conversions bridge the two. +pub use bindings::nexum::host::types::Fault; diff --git a/crates/nexum-venue-sdk/src/transport.rs b/crates/nexum-venue-sdk/src/transport.rs new file mode 100644 index 00000000..a6b8f905 --- /dev/null +++ b/crates/nexum-venue-sdk/src/transport.rs @@ -0,0 +1,158 @@ +//! Typed wrappers over the adapter world's scoped transport imports: +//! chain RPC, messaging, and outbound wasi:http. +//! +//! Each wrapper adapts this crate's bindgen import shims to the +//! SDK-neutral vocabulary (`nexum_sdk::host`), so adapter logic written +//! against the seams is unit-testable host-free and reuses the +//! `nexum-sdk` chain helpers unchanged. The wrappers only translate; +//! scoping is the host's: chain methods pass through the host's +//! permitted read-only surface, messaging is confined to the adapter's +//! `messaging_topics`, and HTTP to its `http_allow` list, each refusal +//! surfacing as a typed `denied`. + +use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; + +use crate::bindings::nexum::host::{chain, messaging}; +use crate::faults::fault_into_sdk; + +/// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. +/// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` +/// crate's request/response types; an off-allowlist request fails as +/// [`FetchError::Denied`](nexum_sdk::http::FetchError::Denied), which +/// converts into [`VenueError`](crate::VenueError) via `?`. +pub use nexum_sdk::http; + +/// The adapter's `nexum:host/chain` import behind the SDK's +/// [`ChainHost`] seam. Unit-struct handle: hold it where strategy logic +/// takes `&impl ChainHost` and slot a mock in host-side tests. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostChain; + +impl ChainHost for HostChain { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + chain::request(chain_id, method, params).map_err(chain_error_into_sdk) + } +} + +impl HostChain { + /// Execute several JSON-RPC requests against one chain in a single + /// round trip where the host transport supports it. Entries are + /// independent: the outer error is the batch failing to execute at + /// all, the per-entry results carry each call's own outcome, in + /// request order. + pub fn request_batch( + &self, + chain_id: u64, + requests: &[RpcRequest], + ) -> Result>, ChainError> { + let wire: Vec = requests + .iter() + .map(|req| chain::RpcRequest { + method: req.method.clone(), + params: req.params.clone(), + }) + .collect(); + let results = chain::request_batch(chain_id, &wire).map_err(chain_error_into_sdk)?; + Ok(results + .into_iter() + .map(|result| match result { + chain::RpcResult::Ok(value) => Ok(value), + chain::RpcResult::Err(err) => Err(chain_error_into_sdk(err)), + }) + .collect()) + } +} + +/// One JSON-RPC call inside a [`HostChain::request_batch`], mirrored +/// from `nexum:host/chain.rpc-request`. `method` carries its namespace +/// prefix (`eth_call`); `params` is the JSON-encoded positional array. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RpcRequest { + /// JSON-RPC method, namespace prefix included. + pub method: String, + /// JSON-encoded params array. + pub params: String, +} + +/// Lift the wire chain error into the SDK-neutral [`ChainError`]. +/// Exhaustive on both the fault vocabulary and the rpc-error shape. +fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { + match err { + chain::ChainError::Fault(fault) => ChainError::Fault(fault_into_sdk(fault)), + chain::ChainError::Rpc(rpc) => ChainError::Rpc(RpcError { + code: rpc.code, + message: rpc.message, + data: rpc.data.map(Into::into), + }), + } +} + +/// `nexum:host/messaging` - publish to and query the venue's content +/// topics. The seam between adapter logic and the messaging transport; +/// [`HostMessaging`] is the bound impl. +pub trait MessagingHost { + /// Publish a payload to a content topic + /// (`////`). A topic outside the + /// adapter's `messaging_topics` scope fails as [`Fault::Denied`]. + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; + + /// Query historical messages from the store protocol, newest window + /// bounded by the optional `start_time` / `end_time` (ms since the + /// Unix epoch, UTC) and `limit`. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault>; +} + +/// The adapter's `nexum:host/messaging` import behind the +/// [`MessagingHost`] seam. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostMessaging; + +impl MessagingHost for HostMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + messaging::publish(content_topic, payload).map_err(fault_into_sdk) + } + + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + let messages = + messaging::query(content_topic, start_time, end_time, limit).map_err(fault_into_sdk)?; + Ok(messages.into_iter().map(Message::from).collect()) + } +} + +/// One delivered message, mirrored from `nexum:host/types.message` so +/// the [`MessagingHost`] seam stays mockable without naming bindgen +/// types. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + /// Content topic the message arrived on. + pub content_topic: String, + /// Opaque payload bytes. + pub payload: Vec, + /// Delivery timestamp, ms since the Unix epoch, UTC. + pub timestamp: u64, + /// Optional sender identity (protocol-dependent). + pub sender: Option>, +} + +impl From for Message { + fn from(message: crate::bindings::nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } +} diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs new file mode 100644 index 00000000..c6afd9ca --- /dev/null +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -0,0 +1,238 @@ +//! Acceptance surface for the venue SDK: a hand-written adapter +//! compiles against [`VenueAdapter`], exports through +//! `export_venue_adapter!`, and round-trips a versioned body through +//! `#[derive(IntentBody)]` - including the typed unknown-version +//! failure and the typed client core driving the adapter through the +//! [`IntentPool`] seam. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{ + AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, + IntentPool, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, +}; + +/// First published body version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV1 { + amount_wei: u64, + memo: String, +} + +/// Second published version: v1 plus an expiry. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV2 { + amount_wei: u64, + memo: String, + valid_until_ms: Option, +} + +/// The outer per-venue version enum: the schema the demo venue +/// publishes. Tag order is the schema; versions append. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +enum QuoteBody { + V1(QuoteV1), + V2(QuoteV2), +} + +/// The hand-written adapter: enough venue to exercise every trait +/// function without a live transport. +struct DemoAdapter; + +/// The receipt the demo venue issues for every accepted intent. +const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; + +impl DemoAdapter { + fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { + // `BodyError` converts through `?`: malformed and + // unknown-version bodies surface as `invalid-body`. + let body = QuoteBody::from_bytes(body)?; + Ok(match body { + QuoteBody::V1(quote) => (quote.amount_wei, None), + QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), + }) + } +} + +impl VenueAdapter for DemoAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + let (amount_wei, valid_until) = Self::decode(&body)?; + Ok(IntentHeader { + gives: vec![AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(1)), + amount: amount_wei.to_be_bytes().to_vec(), + }], + wants: Vec::new(), + valid_until, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }) + } + + fn submit(body: Vec) -> Result { + Self::decode(&body)?; + Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) + } + + fn status(receipt: Vec) -> Result { + if receipt == RECEIPT { + Ok(IntentStatus::Open) + } else { + Err(VenueError::InvalidReceipt) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + Self::status(receipt).map(|_| ()) + } +} + +// The acceptance gate proper: the hand-written adapter exports as the +// venue-adapter world. +nexum_venue_sdk::export_venue_adapter!(DemoAdapter); + +/// In-process pool: routes the demo venue id straight into the adapter, +/// standing in for the host router the strategy-side seam will bind. +struct InProcessPool; + +impl IntentPool for InProcessPool { + fn submit(&self, venue: &str, body: Vec) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::submit(body) + } + + fn status(&self, venue: &str, receipt: &[u8]) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::status(receipt.to_vec()) + } + + fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError> { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::cancel(receipt.to_vec()) + } +} + +fn v2_body() -> QuoteBody { + QuoteBody::V2(QuoteV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + }) +} + +#[test] +fn versioned_body_round_trips_through_the_derive() { + for body in [ + QuoteBody::V1(QuoteV1 { + amount_wei: 42, + memo: "one".to_owned(), + }), + v2_body(), + ] { + let bytes = body.to_bytes().expect("derived payloads encode"); + assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); + } +} + +#[test] +fn wire_tag_is_the_declaration_index() { + let v1 = QuoteBody::V1(QuoteV1 { + amount_wei: 1, + memo: String::new(), + }) + .to_bytes() + .unwrap(); + let v2 = v2_body().to_bytes().unwrap(); + assert_eq!(v1[0], 0); + assert_eq!(v2[0], 1); +} + +#[test] +fn unknown_version_fails_typedly() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 9; + assert_eq!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::UnknownVersion { version: 9 }) + ); +} + +#[test] +fn empty_and_malformed_bodies_fail_typedly() { + assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); + + // A known tag with a truncated payload. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.truncate(bytes.len() - 1); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); + + // A known tag with trailing bytes: borsh requires full consumption. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.push(0); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); +} + +#[test] +fn adapter_projects_the_header_from_a_versioned_body() { + let bytes = v2_body().to_bytes().unwrap(); + let header = DemoAdapter::derive_header(bytes).unwrap(); + assert_eq!(header.gives.len(), 1); + assert_eq!(header.gives[0].amount, 1_000_000u64.to_be_bytes().to_vec()); + assert_eq!(header.valid_until, Some(1_700_000_000_000)); + assert_eq!(header.authorisation, AuthScheme::Eip712); +} + +#[test] +fn adapter_reports_an_unknown_version_as_invalid_body() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 7; + let err = DemoAdapter::derive_header(bytes).unwrap_err(); + match err { + VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), + other => panic!("expected invalid-body, got {other:?}"), + } +} + +#[test] +fn typed_client_round_trips_through_the_pool_seam() { + let client = IntentClient::new(InProcessPool, "demo"); + + let outcome = client.submit(&v2_body()).unwrap(); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("demo venue always accepts"); + }; + assert_eq!(receipt, RECEIPT.to_vec()); + + assert_eq!(client.status(&receipt).unwrap(), IntentStatus::Open); + client.cancel(&receipt).unwrap(); + + assert!(matches!( + client.status(&[0, 1]).unwrap_err(), + ClientError::Venue(VenueError::InvalidReceipt) + )); +} + +#[test] +fn unbound_venue_is_unknown_at_the_pool() { + let client = IntentClient::new(InProcessPool, "nowhere"); + assert!(matches!( + client.submit(&v2_body()).unwrap_err(), + ClientError::Venue(VenueError::UnknownVenue) + )); +} diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/nexum-venue-test/Cargo.toml new file mode 100644 index 00000000..67eb2e3c --- /dev/null +++ b/crates/nexum-venue-test/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "nexum-venue-test" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Conformance kit for venue adapters: file-published borsh codec round-trip vectors, header-derivation golden fixtures, and an in-memory MockTransport for adapter unit tests." + +[lib] +# Plain library, host-only - adapter crates list this under +# [dev-dependencies] so it never ships in the wasm bundle. + +[lints] +workspace = true + +[dependencies] +# The reference schema's payload structs derive the borsh traits, the +# same way a real venue's payload types do. +borsh.workspace = true +# Vector and golden files carry byte fields as lowercase hex so a +# non-Rust author can read them without a borsh decoder. +hex.workspace = true +# `MockFetch` speaks the standard `http` request/response types the +# SDK's `Fetch` seam is expressed in. +http.workspace = true +# Transport seam vocabulary: `ChainHost`, `Fault`, and the `Fetch` seam +# the mocks implement. +nexum-sdk = { path = "../nexum-sdk" } +# `MockChain` is composed rather than reimplemented: one chain mock +# serves both personas. +nexum-sdk-test = { path = "../nexum-sdk-test" } +# The contract under test: `IntentBody`, `BodyError`, the intent +# header types, and the `MessagingHost` seam. +nexum-venue-sdk = { path = "../nexum-venue-sdk" } +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json new file mode 100644 index 00000000..980751be --- /dev/null +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -0,0 +1,60 @@ +{ + "venue": "nexum-venue-test/reference", + "goldens": [ + { + "name": "v1-small", + "body": "00010000000000000002000000676d", + "header": { + "gives": [ + { + "asset": { + "native-token": { + "evm-chain": 1 + } + }, + "amount": "01" + } + ], + "wants": [], + "settlement": { + "evm-chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "gives chain-1 native token, minimal big-endian amount" + }, + { + "name": "v2-full", + "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "header": { + "gives": [ + { + "asset": { + "native-token": { + "evm-chain": 1 + } + }, + "amount": "0f4240" + } + ], + "wants": [ + { + "asset": { + "erc20": { + "chain-id": 1, + "address": "0102030405060708090a0b0c0d0e0f1011121314" + } + }, + "amount": "0f4240" + } + ], + "valid-until": 1700000000000, + "settlement": { + "evm-chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "v2 adds the expiry and an erc20 want at the recipient address" + } + ] +} diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs new file mode 100644 index 00000000..3041326e --- /dev/null +++ b/crates/nexum-venue-test/src/codec.rs @@ -0,0 +1,346 @@ +//! Codec conformance vectors: the file format that publishes a venue's +//! `IntentBody` wire bytes, and the check that holds a codec to them. +//! +//! A vector file is the venue's codec contract in portable form: JSON, +//! bytes as lowercase hex, one entry per published body. A non-Rust +//! adapter author proves byte-exactness by decoding and re-encoding +//! each `round-trip` vector in their own language and comparing bytes; +//! a Rust author runs [`CodecVectors::assert_conforms`] against the +//! derived enum. The failure vectors pin the typed error contract: +//! empty, unknown-version, and malformed bodies must fail exactly as +//! [`BodyError`] names them, not garble into a decoded value. + +use std::path::Path; + +use nexum_venue_sdk::{BodyError, IntentBody}; +use serde::{Deserialize, Serialize}; + +use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::report::{ConformanceReport, Violation, settle}; + +/// A published set of codec vectors for one venue body schema. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodecVectors { + /// Name of the body schema the vectors bind, e.g. + /// `acme-dex/order-body`. Informational: the check never reads it. + pub schema: String, + /// The vectors, in publication order. + pub vectors: Vec, +} + +/// One published wire body and the outcome its bytes must produce. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodecVector { + /// Stable name a violation is reported under. + pub name: String, + /// The wire bytes, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub bytes: Vec, + /// What a conforming codec does with the bytes. + pub expect: Expectation, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// The outcome a vector demands of a conforming codec. The failure +/// cases mirror [`BodyError`] minus its free-text detail: the detail +/// wording is the Rust implementation's, not part of the contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Expectation { + /// The bytes decode, and re-encoding the decoded value reproduces + /// them exactly. + RoundTrip, + /// Decoding fails: no version tag at all. + Empty, + /// Decoding fails: the tag names no published version. + UnknownVersion { + /// The unknown wire tag. + version: u8, + }, + /// Decoding fails: a known tag whose payload does not parse. + Malformed { + /// The wire tag whose payload is broken. + version: u8, + }, +} + +impl std::fmt::Display for Expectation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Expectation::RoundTrip => f.write_str("round-trip"), + Expectation::Empty => f.write_str("empty"), + Expectation::UnknownVersion { version } => write!(f, "unknown-version {version}"), + Expectation::Malformed { version } => write!(f, "malformed {version}"), + } + } +} + +impl CodecVectors { + /// An empty vector set for `schema`. + pub fn new(schema: impl Into) -> Self { + Self { + schema: schema.into(), + vectors: Vec::new(), + } + } + + /// Append a round-trip vector by encoding `body` through the codec + /// under publication. Returns the pushed vector so the caller can + /// attach [`notes`](CodecVector::notes). + pub fn push_round_trip( + &mut self, + name: impl Into, + body: &B, + ) -> Result<&mut CodecVector, BodyError> { + let bytes = body.to_bytes()?; + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect: Expectation::RoundTrip, + notes: None, + }); + Ok(self.vectors.last_mut().expect("vector was just pushed")) + } + + /// Append a failure vector: raw bytes plus the typed decode error + /// they must produce. + /// + /// # Panics + /// + /// On [`Expectation::RoundTrip`]; round-trip vectors are encoded + /// from a typed body via [`push_round_trip`](Self::push_round_trip) + /// so their bytes are canonical by construction. + pub fn push_failure( + &mut self, + name: impl Into, + bytes: Vec, + expect: Expectation, + ) -> &mut CodecVector { + assert!( + expect != Expectation::RoundTrip, + "push_failure takes a failure expectation; use push_round_trip", + ); + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect, + notes: None, + }); + self.vectors.last_mut().expect("vector was just pushed") + } + + /// Parse a vector set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a vector file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the vector file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check a codec against every vector, collecting all violations + /// rather than stopping at the first. + /// + /// A `round-trip` vector must decode and re-encode to the exact + /// published bytes; a failure vector must produce the matching + /// [`BodyError`] case (the free-text detail is not compared). + pub fn check(&self) -> Result<(), ConformanceReport> { + let mut violations = Vec::new(); + for vector in &self.vectors { + if let Err(detail) = vector.check::() { + violations.push(Violation { + vector: vector.name.clone(), + detail, + }); + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any + /// violation. The assertion form for adapter test suites. + pub fn assert_conforms(&self) { + if let Err(report) = self.check::() { + panic!("codec does not conform to {}:\n{report}", self.schema); + } + } +} + +impl CodecVector { + /// Check one vector, returning the violation detail on divergence. + fn check(&self) -> Result<(), String> { + let decoded = B::from_bytes(&self.bytes); + match (&self.expect, decoded) { + (Expectation::RoundTrip, Ok(body)) => { + let reencoded = body + .to_bytes() + .map_err(|err| format!("re-encode failed: {err}"))?; + if reencoded == self.bytes { + Ok(()) + } else { + Err(format!( + "re-encoded bytes diverge from the published vector: published {}, re-encoded {}", + hex::encode(&self.bytes), + hex::encode(&reencoded), + )) + } + } + (Expectation::RoundTrip, Err(err)) => { + Err(format!("expected a round trip, decode failed: {err}")) + } + (expect, Ok(_)) => Err(format!("expected {expect}, decode succeeded")), + (expect, Err(err)) => { + let matches = match (expect, &err) { + (Expectation::Empty, BodyError::Empty) => true, + ( + Expectation::UnknownVersion { version }, + BodyError::UnknownVersion { version: got }, + ) => version == got, + ( + Expectation::Malformed { version }, + BodyError::Malformed { version: got, .. }, + ) => version == got, + _ => false, + }; + if matches { + Ok(()) + } else { + Err(format!("expected {expect}, got: {err}")) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use borsh::{BorshDeserialize, BorshSerialize}; + use nexum_venue_sdk::IntentBody; + + use super::*; + + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct PayloadV1 { + amount: u64, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum Body { + V1(PayloadV1), + } + + /// A codec with a diverging payload layout for the same tag. + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct NarrowPayload { + amount: u32, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum NarrowBody { + V1(NarrowPayload), + } + + fn published() -> CodecVectors { + let mut vectors = CodecVectors::new("test/body"); + vectors + .push_round_trip( + "v1", + &Body::V1(PayloadV1 { + amount: 7, + memo: "gm".to_owned(), + }), + ) + .unwrap(); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + vectors.push_failure( + "unknown-version", + vec![9, 0, 0], + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure( + "truncated", + vec![0, 7], + Expectation::Malformed { version: 0 }, + ); + vectors + } + + #[test] + fn conforming_codec_passes_every_vector() { + published().check::().unwrap(); + } + + #[test] + fn diverging_codec_fails_with_named_vectors() { + let report = published().check::().unwrap_err(); + // The v1 payload no longer parses (u32 vs u64 layout); the + // failure vectors still fail as published, so the report names + // exactly the diverging vector. + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, "v1"); + assert!(report.violations[0].detail.contains("decode failed")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_panics_with_the_report() { + published().assert_conforms::(); + } + + #[test] + #[should_panic(expected = "push_failure takes a failure expectation")] + fn push_failure_rejects_round_trip() { + CodecVectors::new("test/body").push_failure("bad", Vec::new(), Expectation::RoundTrip); + } + + #[test] + fn json_form_is_stable_and_round_trips() { + let mut vectors = published(); + vectors.vectors[0].notes = Some("first published body".to_owned()); + let json = vectors.to_json(); + assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); + // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"round-trip\"")); + assert!(json.contains("\"unknown-version\"")); + assert!(json.contains("\"notes\": \"first published body\"")); + assert!(!json.contains("null"), "absent notes are omitted: {json}"); + } + + #[test] + fn files_round_trip_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vectors.json"); + let vectors = published(); + vectors.write(&path).unwrap(); + assert_eq!(CodecVectors::load(&path).unwrap(), vectors); + } + + #[test] + fn malformed_file_fails_typedly() { + assert!(matches!( + CodecVectors::from_json("{"), + Err(FixtureError::Format(_)), + )); + assert!(matches!( + CodecVectors::load("/nonexistent/vectors.json"), + Err(FixtureError::Read { .. }), + )); + } +} diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/nexum-venue-test/src/fixture.rs new file mode 100644 index 00000000..7174573c --- /dev/null +++ b/crates/nexum-venue-test/src/fixture.rs @@ -0,0 +1,82 @@ +//! The shared fixture-file plumbing: JSON on disk, byte fields as +//! lowercase hex, and the typed [`FixtureError`] both file formats +//! load and save through. + +use std::path::Path; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Why a fixture file failed to load or save. The JSON case carries +/// serde's rendered detail rather than the error value so the type +/// stays independent of `serde_json`'s feature set. +#[derive(Debug, thiserror::Error)] +pub enum FixtureError { + /// The file could not be read. + #[error("failed to read {path}: {source}")] + Read { + /// Path the read targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The file could not be written. + #[error("failed to write {path}: {source}")] + Write { + /// Path the write targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The content did not parse as the fixture format. + #[error("malformed fixture json: {0}")] + Format(String), +} + +/// Render a fixture as its canonical published form: pretty-printed +/// JSON with a trailing newline, so a regenerated file diffs cleanly. +pub(crate) fn to_json(value: &T) -> String { + let mut json = serde_json::to_string_pretty(value).expect("fixture types serialize infallibly"); + json.push('\n'); + json +} + +/// Parse a fixture from its JSON text. +pub(crate) fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|err| FixtureError::Format(err.to_string())) +} + +/// Load a fixture file from disk. +pub(crate) fn load(path: &Path) -> Result { + let json = std::fs::read_to_string(path).map_err(|source| FixtureError::Read { + path: path.display().to_string(), + source, + })?; + from_json(&json) +} + +/// Write a fixture file in its canonical published form. +pub(crate) fn write(path: &Path, value: &T) -> Result<(), FixtureError> { + std::fs::write(path, to_json(value)).map_err(|source| FixtureError::Write { + path: path.display().to_string(), + source, + }) +} + +/// Serde codec for byte fields: lowercase hex, no prefix, so the file +/// is legible without a borsh decoder. +pub(crate) mod hex_bytes { + use serde::de::Error as _; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&hex::encode(bytes)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let text = String::deserialize(deserializer)?; + hex::decode(&text).map_err(D::Error::custom) + } +} diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs new file mode 100644 index 00000000..bee21583 --- /dev/null +++ b/crates/nexum-venue-test/src/header.rs @@ -0,0 +1,459 @@ +//! Header-derivation goldens: the file format that publishes what +//! `derive-header` must project from each published body, and the +//! check that holds an adapter to it. +//! +//! A golden file pairs wire bodies with the intent header a conforming +//! adapter derives from them, spelled in the golden mirror types below +//! (JSON, kebab-case case names matching the WIT, bytes as lowercase +//! hex). The mirrors exist because wit-bindgen types carry no serde; +//! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a +//! macro-built adapter whose bindgen mints its own header type bridges +//! with a field-for-field `From` impl on its crate boundary, the same +//! pattern `nexum-sdk-test` documents for `Fault`. + +use std::fmt; +use std::path::Path; + +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{AuthScheme, IntentHeader}; +use serde::{Deserialize, Serialize}; + +use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::report::{ConformanceReport, Violation, settle}; + +/// A published set of header goldens for one venue. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderGoldens { + /// The venue the goldens bind. Informational: the check never + /// reads it. + pub venue: String, + /// The goldens, in publication order. + pub goldens: Vec, +} + +/// One wire body and the header a conforming adapter derives from it. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderGolden { + /// Stable name a violation is reported under. + pub name: String, + /// The intent body, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub body: Vec, + /// The header `derive-header` must produce for the body. + pub header: GoldenHeader, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Serde mirror of the wire `intent-header`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct GoldenHeader { + /// Value leaving the user's control. + pub gives: Vec, + /// Value expected in return. + pub wants: Vec, + /// Expiry in milliseconds since the Unix epoch, UTC. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_until: Option, + /// Where the deal settles. + pub settlement: GoldenSettlement, + /// How the venue authorises the intent. + pub authorisation: GoldenAuthScheme, +} + +/// Serde mirror of the wire `asset-amount`. `amount` is big-endian +/// unsigned, hex in the file; an empty string is zero. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenAssetAmount { + /// The asset moving. + pub asset: GoldenAsset, + /// Big-endian unsigned amount bytes. + #[serde(with = "hex_bytes")] + pub amount: Vec, +} + +/// Serde mirror of the wire `settlement`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum GoldenSettlement { + /// Settles on an EVM chain, by chain id. + EvmChain(u64), + /// Settles off-chain in the named domain. + Offchain(String), +} + +/// Serde mirror of the wire `asset`. Token addresses and ids are hex +/// in the file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + rename_all = "kebab-case", + rename_all_fields = "kebab-case", + deny_unknown_fields +)] +pub enum GoldenAsset { + /// The settlement domain's own gas token. + NativeToken(GoldenSettlement), + /// An ERC-20 token. + Erc20 { + /// Chain the token lives on. + chain_id: u64, + /// 20-byte contract address. + #[serde(with = "hex_bytes")] + address: Vec, + }, + /// An ERC-721 NFT. + Erc721 { + /// Chain the token lives on. + chain_id: u64, + /// 20-byte contract address. + #[serde(with = "hex_bytes")] + address: Vec, + /// Token id, big-endian, arbitrary width. + #[serde(with = "hex_bytes")] + token_id: Vec, + }, + /// An ERC-1155 token. + Erc1155 { + /// Chain the token lives on. + chain_id: u64, + /// 20-byte contract address. + #[serde(with = "hex_bytes")] + address: Vec, + /// Token id, big-endian, arbitrary width. + #[serde(with = "hex_bytes")] + token_id: Vec, + }, + /// A non-token service obligation. + Service { + /// Namespaced service kind, e.g. `swarm:postage`. + kind: String, + /// Human-readable description for the consent sheet. + summary: String, + }, + /// A real-world asset settled off-chain. + Offchain { + /// Jurisdiction or registry domain. + domain: String, + /// Human-readable description for the consent sheet. + summary: String, + }, +} + +/// Serde mirror of the wire `auth-scheme`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GoldenAuthScheme { + /// EIP-712 typed-data signature by host-held keys. + Eip712, + /// EIP-1271 contract signature. + Eip1271, + /// Pre-signed authorisation at the settlement contract. + Presign, + /// Venue-defined off-chain signature scheme. + OffchainSig, + /// No authorisation travels with the body. + Unsigned, +} + +impl From for GoldenHeader { + fn from(header: IntentHeader) -> Self { + Self { + gives: header.gives.into_iter().map(Into::into).collect(), + wants: header.wants.into_iter().map(Into::into).collect(), + valid_until: header.valid_until, + settlement: header.settlement.into(), + authorisation: header.authorisation.into(), + } + } +} + +impl From for GoldenAssetAmount { + fn from(amount: AssetAmount) -> Self { + Self { + asset: amount.asset.into(), + amount: amount.amount, + } + } +} + +impl From for GoldenSettlement { + fn from(settlement: Settlement) -> Self { + match settlement { + Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), + Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), + } + } +} + +impl From for GoldenAsset { + fn from(asset: Asset) -> Self { + match asset { + Asset::NativeToken(settlement) => GoldenAsset::NativeToken(settlement.into()), + Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, + Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { + chain_id, + address, + token_id, + }, + Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { + chain_id, + address, + token_id, + }, + Asset::Service(desc) => GoldenAsset::Service { + kind: desc.kind, + summary: desc.summary, + }, + Asset::Offchain(desc) => GoldenAsset::Offchain { + domain: desc.domain, + summary: desc.summary, + }, + } + } +} + +impl From for GoldenAuthScheme { + fn from(scheme: AuthScheme) -> Self { + match scheme { + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, + AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, + AuthScheme::Presign => GoldenAuthScheme::Presign, + AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, + AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + } + } +} + +impl HeaderGoldens { + /// An empty golden set for `venue`. + pub fn new(venue: impl Into) -> Self { + Self { + venue: venue.into(), + goldens: Vec::new(), + } + } + + /// Append a golden by running the publishing adapter's own + /// `derive-header` on `body`. Returns the pushed golden so the + /// caller can attach [`notes`](HeaderGolden::notes). + pub fn record( + &mut self, + name: impl Into, + body: Vec, + derive: impl FnOnce(Vec) -> Result, + ) -> Result<&mut HeaderGolden, E> + where + H: Into, + { + let header = derive(body.clone())?.into(); + self.goldens.push(HeaderGolden { + name: name.into(), + body, + header, + notes: None, + }); + Ok(self.goldens.last_mut().expect("golden was just pushed")) + } + + /// Parse a golden set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a golden file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the golden file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check an adapter's `derive-header` against every golden, + /// collecting all violations rather than stopping at the first. + /// + /// `derive` is the adapter's derivation; a trait-based adapter + /// passes `MyAdapter::derive_header` directly. + pub fn check( + &self, + mut derive: impl FnMut(Vec) -> Result, + ) -> Result<(), ConformanceReport> + where + H: Into, + E: fmt::Debug, + { + let mut violations = Vec::new(); + for golden in &self.goldens { + match derive(golden.body.clone()) { + Ok(header) => { + let derived: GoldenHeader = header.into(); + if derived != golden.header { + violations.push(Violation { + vector: golden.name.clone(), + detail: format!( + "derived header diverges from the golden: expected {:?}, derived {derived:?}", + golden.header, + ), + }); + } + } + Err(err) => violations.push(Violation { + vector: golden.name.clone(), + detail: format!("derive-header failed: {err:?}"), + }), + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any + /// violation. The assertion form for adapter test suites. + pub fn assert_conforms(&self, derive: impl FnMut(Vec) -> Result) + where + H: Into, + E: fmt::Debug, + { + if let Err(report) = self.check(derive) { + panic!( + "derive-header does not conform to the {} goldens:\n{report}", + self.venue, + ); + } + } +} + +#[cfg(test)] +mod tests { + use nexum_venue_sdk::VenueError; + use nexum_venue_sdk::value_flow::{OffchainDesc, ServiceDesc}; + + use super::*; + + fn wire_header() -> IntentHeader { + IntentHeader { + gives: vec![ + AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(100)), + amount: vec![0x0d, 0xe0, 0xb6], + }, + AssetAmount { + asset: Asset::Erc20((1, vec![0xAA; 20])), + amount: vec![1, 0], + }, + AssetAmount { + asset: Asset::Erc721((1, vec![0xBB; 20], vec![7])), + amount: vec![1], + }, + AssetAmount { + asset: Asset::Erc1155((1, vec![0xCC; 20], vec![8])), + amount: vec![2], + }, + AssetAmount { + asset: Asset::Service(ServiceDesc { + kind: "swarm:postage".to_owned(), + summary: "storage for 30 days".to_owned(), + }), + amount: Vec::new(), + }, + ], + wants: vec![AssetAmount { + asset: Asset::Offchain(OffchainDesc { + domain: "iso:AU".to_owned(), + summary: "a deed".to_owned(), + }), + amount: Vec::new(), + }], + valid_until: Some(1_700_000_000_000), + settlement: Settlement::Offchain("acme".to_owned()), + authorisation: AuthScheme::OffchainSig, + } + } + + #[test] + fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { + let golden: GoldenHeader = wire_header().into(); + let goldens = HeaderGoldens { + venue: "acme".to_owned(), + goldens: vec![HeaderGolden { + name: "kitchen-sink".to_owned(), + body: vec![0], + header: golden, + notes: None, + }], + }; + let json = goldens.to_json(); + assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); + // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"native-token\"")); + assert!(json.contains("\"chain-id\"")); + assert!(json.contains("\"token-id\"")); + assert!(json.contains("\"valid-until\"")); + assert!(json.contains("\"offchain-sig\"")); + assert!(json.contains("\"evm-chain\"")); + } + + #[test] + fn conforming_derivation_passes() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("kitchen-sink", vec![1, 2, 3], |_| { + Ok::<_, VenueError>(wire_header()) + }) + .unwrap(); + goldens + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + } + + #[test] + fn diverging_derivation_and_failure_are_both_violations() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens + .record("b", vec![2], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + + let mut calls = 0; + let report = goldens + .check(|_| { + calls += 1; + if calls == 1 { + let mut header = wire_header(); + header.valid_until = None; + Ok(header) + } else { + Err(VenueError::InvalidBody("nope".to_owned())) + } + }) + .unwrap_err(); + + assert_eq!(report.violations.len(), 2); + assert_eq!(report.violations[0].vector, "a"); + assert!(report.violations[0].detail.contains("diverges")); + assert_eq!(report.violations[1].vector, "b"); + assert!(report.violations[1].detail.contains("derive-header failed")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_panics_with_the_report() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens.assert_conforms(|_| Err::(VenueError::InvalidReceipt)); + } +} diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs new file mode 100644 index 00000000..e8c372e3 --- /dev/null +++ b/crates/nexum-venue-test/src/lib.rs @@ -0,0 +1,81 @@ +//! # nexum-venue-test +//! +//! Conformance kit for venue adapters: file-published codec vectors, +//! header-derivation goldens, and an in-memory transport mock, so an +//! adapter proves its wire behaviour against fixtures any +//! implementation language can read. +//! +//! ## The three pieces +//! +//! - [`CodecVectors`] - the venue's `IntentBody` wire bytes as a JSON +//! file (bytes as lowercase hex). A Rust adapter checks its derived +//! enum with [`CodecVectors::assert_conforms`]; a non-Rust author +//! reads the same file and proves byte-exactness without linking +//! Rust. +//! - [`HeaderGoldens`] - published bodies paired with the header a +//! conforming `derive-header` projects from them, spelled in the +//! [`GoldenHeader`] mirror types. +//! - [`MockTransport`] - the three transports an adapter is granted +//! (chain, messaging, outbound HTTP) as programmable in-memory mocks +//! behind the SDK's own seams. +//! +//! ## Usage +//! +//! Add as a dev-dep on the adapter crate: +//! +//! ```toml +//! [dev-dependencies] +//! nexum-venue-test = { path = "../../crates/nexum-venue-test" } +//! ``` +//! +//! Hold the adapter to its published fixtures: +//! +//! ```rust +//! use nexum_venue_test::reference::{ +//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +//! }; +//! use nexum_venue_test::{CodecVectors, HeaderGoldens}; +//! +//! // In a real adapter test these load the venue's own published +//! // files; the kit's reference venue stands in here. +//! let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); +//! vectors.assert_conforms::(); +//! +//! let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); +//! goldens.assert_conforms(derive_reference_header); +//! ``` +//! +//! Publishing works through the same types: build the fixtures with +//! [`CodecVectors::push_round_trip`] / [`HeaderGoldens::record`] and +//! [`write`](CodecVectors::write) them next to the venue's schema +//! documentation. +//! +//! ## Macro-built adapters +//! +//! `#[nexum::venue]` adapters mint their own bindgen header type. The +//! codec check is unaffected (bodies are plain Rust types); for the +//! golden check, bridge with a field-for-field `From for +//! GoldenHeader` impl on the adapter crate's boundary, the same +//! trivial-converter pattern `nexum-sdk-test` documents for `Fault`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod codec; +pub mod fixture; +pub mod header; +pub mod reference; +pub mod report; +pub mod transport; + +pub use codec::{CodecVector, CodecVectors, Expectation}; +pub use fixture::FixtureError; +pub use header::{ + GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, + HeaderGoldens, +}; +pub use report::{ConformanceReport, Violation}; +pub use transport::{ + ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, + PublishRecord, RecordedRequest, +}; diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs new file mode 100644 index 00000000..74ce00b2 --- /dev/null +++ b/crates/nexum-venue-test/src/reference.rs @@ -0,0 +1,271 @@ +//! The kit's reference venue: a published body schema, its codec +//! vector file, and its header golden file. +//! +//! The reference exists so the fixture formats ship with a worked, +//! machine-checked example. Its payloads exercise every borsh +//! primitive a body schema is likely to carry (fixed-width integers, +//! length-prefixed strings and byte vectors, options, bools), so a +//! non-Rust author can prove their borsh implementation byte-exact +//! against [`CODEC_VECTORS_JSON`] before touching their own schema. +//! The published files are pinned by this crate's tests: regeneration +//! must reproduce them byte for byte. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, VenueError}; + +/// The published codec vector file, verbatim. +pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); + +/// The published header golden file, verbatim. +pub const HEADER_GOLDENS_JSON: &str = include_str!("../goldens/reference-header.json"); + +/// First published version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV1 { + /// Amount in wei; borsh encodes it as 8 little-endian bytes. + pub amount_wei: u64, + /// Free text; borsh encodes a u32 little-endian byte length then + /// the UTF-8 bytes. + pub memo: String, +} + +/// Second published version: v1 plus an expiry, a recipient, and a +/// priority flag. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV2 { + /// Amount in wei. + pub amount_wei: u64, + /// Free text. + pub memo: String, + /// Expiry in ms since the Unix epoch, UTC; borsh encodes a one-byte + /// presence tag (0 absent, 1 present) then the payload. + pub valid_until_ms: Option, + /// 20-byte recipient address; borsh encodes a u32 little-endian + /// element count then the bytes. + pub recipient: Vec, + /// Priority flag; borsh encodes one byte (0 false, 1 true). + pub urgent: bool, +} + +/// The reference venue's outer version enum. Tag order is the schema: +/// versions append, never reorder. +#[derive(IntentBody, Clone, Debug, Eq, PartialEq)] +pub enum ReferenceBody { + /// Version 1, wire tag 0. + V1(ReferenceV1), + /// Version 2, wire tag 1. + V2(ReferenceV2), +} + +/// The reference venue's pure header derivation, the subject the +/// published goldens pin. Gives the amount as chain-1 native token, +/// wants (for v2) the same amount as an ERC-20 at the recipient +/// address, and authorises via EIP-712. +pub fn derive_reference_header(body: Vec) -> Result { + let (amount_wei, valid_until, wants) = match ReferenceBody::from_bytes(&body)? { + ReferenceBody::V1(quote) => (quote.amount_wei, None, Vec::new()), + ReferenceBody::V2(quote) => ( + quote.amount_wei, + quote.valid_until_ms, + vec![AssetAmount { + asset: Asset::Erc20((1, quote.recipient)), + amount: minimal_be(quote.amount_wei), + }], + ), + }; + Ok(IntentHeader { + gives: vec![AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(1)), + amount: minimal_be(amount_wei), + }], + wants, + valid_until, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }) +} + +/// Big-endian bytes with leading zeros trimmed: the minimal spelling +/// of a wire amount, where an empty list is zero. +fn minimal_be(value: u64) -> Vec { + let bytes = value.to_be_bytes(); + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::codec::{CodecVectors, Expectation}; + use crate::header::HeaderGoldens; + + use super::*; + + fn v1_small() -> ReferenceBody { + ReferenceBody::V1(ReferenceV1 { + amount_wei: 1, + memo: "gm".to_owned(), + }) + } + + fn v2_full() -> ReferenceBody { + ReferenceBody::V2(ReferenceV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + recipient: (1..=20).collect(), + urgent: true, + }) + } + + /// Rebuild the published codec vectors from the reference schema. + fn build_codec_vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("nexum-venue-test/reference-body"); + + vectors + .push_round_trip("v1-small", &v1_small()) + .unwrap() + .notes = Some( + "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 \ + little-endian length then utf-8 bytes" + .to_owned(), + ); + vectors + .push_round_trip( + "v1-zero-and-empty", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: 0, + memo: String::new(), + }), + ) + .unwrap() + .notes = Some("zero integer and zero-length string".to_owned()); + vectors + .push_round_trip( + "v1-max-amount", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: u64::MAX, + memo: "max".to_owned(), + }), + ) + .unwrap() + .notes = Some("endianness proof: u64::MAX is eight 0xff bytes".to_owned()); + vectors + .push_round_trip("v2-full", &v2_full()) + .unwrap() + .notes = Some( + "tag 0x01; option present is 0x01 then the payload, vec is u32 \ + little-endian element count then bytes, bool true is 0x01" + .to_owned(), + ); + vectors + .push_round_trip( + "v2-no-expiry", + &ReferenceBody::V2(ReferenceV2 { + amount_wei: 5, + memo: "later".to_owned(), + valid_until_ms: None, + recipient: vec![0xAA; 20], + urgent: false, + }), + ) + .unwrap() + .notes = Some("option absent is a bare 0x00, bool false is 0x00".to_owned()); + + vectors + .push_failure("empty-body", Vec::new(), Expectation::Empty) + .notes = Some("no version tag at all".to_owned()); + let mut unknown = v1_small().to_bytes().unwrap(); + unknown[0] = 9; + vectors + .push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ) + .notes = Some("tag 0x09 names no published version".to_owned()); + let mut truncated = v2_full().to_bytes().unwrap(); + truncated.truncate(truncated.len() - 1); + vectors + .push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 1 }, + ) + .notes = Some("known tag, payload cut one byte short".to_owned()); + let mut trailing = v1_small().to_bytes().unwrap(); + trailing.push(0); + vectors + .push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ) + .notes = Some("decoding must consume the payload exactly".to_owned()); + + vectors + } + + /// Rebuild the published header goldens from the reference + /// derivation. + fn build_header_goldens() -> HeaderGoldens { + let mut goldens = HeaderGoldens::new("nexum-venue-test/reference"); + goldens + .record( + "v1-small", + v1_small().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = Some("gives chain-1 native token, minimal big-endian amount".to_owned()); + goldens + .record( + "v2-full", + v2_full().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = + Some("v2 adds the expiry and an erc20 want at the recipient address".to_owned()); + goldens + } + + #[test] + fn published_codec_vectors_match_regeneration() { + assert_eq!( + CODEC_VECTORS_JSON, + build_codec_vectors().to_json(), + "vectors/reference-body.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + #[test] + fn published_header_goldens_match_regeneration() { + assert_eq!( + HEADER_GOLDENS_JSON, + build_header_goldens().to_json(), + "goldens/reference-header.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + /// Rewrite the published files from the reference schema. Run with + /// `cargo test -p nexum-venue-test -- --ignored regenerate` after a + /// deliberate schema change, then commit the diff; the tests above + /// compare against the compiled-in copy, so they go green on the + /// next build. + #[test] + #[ignore = "writes the published fixture files in place"] + fn regenerate_reference_fixtures() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + build_codec_vectors() + .write(root.join("vectors/reference-body.json")) + .unwrap(); + build_header_goldens() + .write(root.join("goldens/reference-header.json")) + .unwrap(); + } +} diff --git a/crates/nexum-venue-test/src/report.rs b/crates/nexum-venue-test/src/report.rs new file mode 100644 index 00000000..97f56b97 --- /dev/null +++ b/crates/nexum-venue-test/src/report.rs @@ -0,0 +1,51 @@ +//! The conformance verdict: every check in this crate either passes or +//! returns a [`ConformanceReport`] naming each vector that failed. + +use std::error::Error; +use std::fmt; + +/// One vector or golden the subject under test failed, with enough +/// detail to fix the divergence without re-running under a debugger. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Violation { + /// The `name` of the failing vector or golden. + pub vector: String, + /// What diverged: the expected and observed outcome. + pub detail: String, +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.vector, self.detail) + } +} + +/// Every violation a conformance check found, one entry per failing +/// vector. A check never stops at the first failure: the report is the +/// whole distance between the subject and the published fixtures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConformanceReport { + /// The violations, in vector order. + pub violations: Vec, +} + +impl fmt::Display for ConformanceReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{} conformance violation(s):", self.violations.len())?; + for violation in &self.violations { + writeln!(f, " {violation}")?; + } + Ok(()) + } +} + +impl Error for ConformanceReport {} + +/// Fold collected violations into the check's verdict. +pub(crate) fn settle(violations: Vec) -> Result<(), ConformanceReport> { + if violations.is_empty() { + Ok(()) + } else { + Err(ConformanceReport { violations }) + } +} diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs new file mode 100644 index 00000000..7ec823f3 --- /dev/null +++ b/crates/nexum-venue-test/src/transport.rs @@ -0,0 +1,473 @@ +//! In-memory mocks for the three transports a venue adapter is +//! granted: chain RPC, messaging, and outbound HTTP. +//! +//! [`MockTransport`] composes the three behind the same seams the SDK +//! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so +//! adapter logic written against `&impl Seam` runs unchanged in unit +//! tests. Scoping mirrors the host's: [`MockMessaging::scope_topics`] +//! plays the adapter's `messaging_topics` grant and refuses off-scope +//! topics as a typed `denied`, exactly as the host would. + +use std::cell::RefCell; +use std::collections::HashMap; + +use nexum_sdk::host::{ChainError, ChainHost, Fault}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; +pub use nexum_sdk_test::{ChainCall, MockChain}; +pub use nexum_venue_sdk::transport::{Message, MessagingHost}; + +/// Composed in-memory transport. Each field exposes the per-seam mock +/// so tests can program responses and assert on calls. +#[derive(Default)] +pub struct MockTransport { + /// `nexum:host/chain` mock. + pub chain: MockChain, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, + /// Outbound wasi:http mock. + pub http: MockFetch, +} + +impl MockTransport { + /// Fresh empty transport. Equivalent to `Default::default`. + pub fn new() -> Self { + Self::default() + } +} + +impl ChainHost for MockTransport { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + self.chain.request(chain_id, method, params) + } +} + +impl MessagingHost for MockTransport { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + +impl Fetch for MockTransport { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.http.fetch_with(request, options) + } +} + +// ------------------------------------------------------------ messaging + +/// One recorded [`MessagingHost::publish`] invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishRecord { + /// Content topic the adapter published to. + pub content_topic: String, + /// Payload bytes, verbatim. + pub payload: Vec, +} + +/// In-memory [`MessagingHost`]. Seeded messages answer queries, +/// publishes are recorded for assertion, and an optional topic scope +/// mirrors the host's `messaging_topics` grant. Seeded history and +/// published records are deliberately separate stores: a query answers +/// from what the test seeded, never from what the adapter published. +#[derive(Default)] +pub struct MockMessaging { + history: RefCell>, + published: RefCell>, + scope: RefCell>>, + faults: RefCell>, +} + +impl MockMessaging { + /// Seed one message into the queryable history. + pub fn seed(&self, message: Message) { + self.history.borrow_mut().push(message); + } + + /// Seed a payload on `content_topic` at `timestamp` (ms since the + /// Unix epoch, UTC), with no sender. + pub fn seed_payload( + &self, + content_topic: impl Into, + payload: impl Into>, + timestamp: u64, + ) { + self.seed(Message { + content_topic: content_topic.into(), + payload: payload.into(), + timestamp, + sender: None, + }); + } + + /// Confine the mock to `topics`, mirroring the adapter's + /// `messaging_topics` grant: any other topic fails as + /// [`Fault::Denied`]. Untouched, every topic is allowed. + pub fn scope_topics(&self, topics: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); + } + + /// Inject a fault for any operation on a topic starting with + /// `prefix`. Multiple patterns can be registered; the first + /// matching one fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.faults.borrow_mut().push((prefix.into(), fault)); + } + + /// All publishes received, in arrival order. + pub fn published(&self) -> Vec { + self.published.borrow().clone() + } + + /// Last publish received, if any. + pub fn last_published(&self) -> Option { + self.published.borrow().last().cloned() + } + + /// Total publish count. + pub fn publish_count(&self) -> usize { + self.published.borrow().len() + } + + fn admit(&self, content_topic: &str) -> Result<(), Fault> { + for (prefix, fault) in self.faults.borrow().iter() { + if content_topic.starts_with(prefix.as_str()) { + return Err(fault.clone()); + } + } + if let Some(scope) = self.scope.borrow().as_ref() + && !scope.iter().any(|topic| topic == content_topic) + { + return Err(Fault::Denied(format!( + "MockMessaging: {content_topic} is outside the scoped topics" + ))); + } + Ok(()) + } +} + +impl MessagingHost for MockMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.admit(content_topic)?; + self.published.borrow_mut().push(PublishRecord { + content_topic: content_topic.to_owned(), + payload: payload.to_vec(), + }); + Ok(()) + } + + /// Answer from the seeded history: exact-topic matches whose + /// timestamp lies within the inclusive `start_time..=end_time` + /// window, in seed order. Seed order is delivery order, so a + /// `limit` keeps the newest matches: the tail. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.admit(content_topic)?; + let mut matches: Vec = self + .history + .borrow() + .iter() + .filter(|message| { + message.content_topic == content_topic + && start_time.is_none_or(|start| message.timestamp >= start) + && end_time.is_none_or(|end| message.timestamp <= end) + }) + .cloned() + .collect(); + if let Some(limit) = limit { + let keep = usize::try_from(limit).unwrap_or(usize::MAX); + if matches.len() > keep { + matches.drain(..matches.len() - keep); + } + } + Ok(matches) + } +} + +// ------------------------------------------------------------ http + +/// One recorded [`Fetch::fetch_with`] invocation. +#[derive(Clone, Debug)] +pub struct RecordedRequest { + /// HTTP method. + pub method: http::Method, + /// Full request URI, verbatim. + pub uri: String, + /// Request body bytes. + pub body: Vec, + /// The per-phase timeouts the caller applied. + pub options: FetchOptions, +} + +/// A programmed response, rebuilt into an `http::Response` per call +/// because the standard response type is not `Clone`. +#[derive(Clone, Debug)] +struct StoredResponse { + status: http::StatusCode, + body: Vec, +} + +/// In-memory [`Fetch`] backed by a `(method, uri)` -> response map. +/// Records every request so tests can assert dispatch shape; an +/// allowlist refusal is programmed as [`FetchError::Denied`] via +/// [`fail_with`](Self::fail_with). +#[derive(Default)] +pub struct MockFetch { + responses: RefCell>>, + requests: RefCell>, +} + +impl MockFetch { + /// Program a response for the `(method, uri)` pair. Overwrites any + /// prior entry. + /// + /// # Panics + /// + /// On a `status` outside the valid HTTP range. + pub fn respond_to( + &self, + method: http::Method, + uri: impl Into, + status: u16, + body: impl Into>, + ) { + let status = + http::StatusCode::from_u16(status).expect("MockFetch: status must be a valid code"); + self.responses.borrow_mut().insert( + (method, uri.into()), + Ok(StoredResponse { + status, + body: body.into(), + }), + ); + } + + /// Program a failure for the `(method, uri)` pair. Overwrites any + /// prior entry. + pub fn fail_with(&self, method: http::Method, uri: impl Into, error: FetchError) { + self.responses + .borrow_mut() + .insert((method, uri.into()), Err(error)); + } + + /// All requests received, in arrival order. + pub fn requests(&self) -> Vec { + self.requests.borrow().clone() + } + + /// Last request received, if any. + pub fn last_request(&self) -> Option { + self.requests.borrow().last().cloned() + } + + /// Total request count. + pub fn request_count(&self) -> usize { + self.requests.borrow().len() + } +} + +impl Fetch for MockFetch { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + let method = request.method().clone(); + let uri = request.uri().to_string(); + self.requests.borrow_mut().push(RecordedRequest { + method: method.clone(), + uri: uri.clone(), + body: request.body().clone(), + options, + }); + match self.responses.borrow().get(&(method.clone(), uri.clone())) { + Some(Ok(stored)) => Ok(http::Response::builder() + .status(stored.status) + .body(stored.body.clone()) + .expect("a stored response always rebuilds")), + Some(Err(err)) => Err(err.clone()), + None => Err(FetchError::Transport(format!( + "MockFetch: no response configured for {method} {uri}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messaging_records_publishes_and_answers_from_seeds() { + let messaging = MockMessaging::default(); + messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); + messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); + messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); + + messaging.publish("/acme/1/orders/proto", b"out").unwrap(); + assert_eq!(messaging.publish_count(), 1); + assert_eq!( + messaging.last_published().unwrap(), + PublishRecord { + content_topic: "/acme/1/orders/proto".to_owned(), + payload: b"out".to_vec(), + }, + ); + + // Publishes never leak into query results. + let all = messaging + .query("/acme/1/orders/proto", None, None, None) + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].payload, b"one"); + assert_eq!(all[1].payload, b"two"); + } + + #[test] + fn messaging_query_applies_bounds_and_limit() { + let messaging = MockMessaging::default(); + for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { + messaging.seed_payload("/t", payload.to_vec(), ts); + } + + let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); + assert_eq!(window.len(), 2); + assert_eq!(window[0].payload, b"b"); + + // A limit keeps the newest matches: the tail of the window. + let limited = messaging.query("/t", None, None, Some(2)).unwrap(); + assert_eq!(limited.len(), 2); + assert_eq!(limited[0].payload, b"c"); + assert_eq!(limited[1].payload, b"d"); + } + + #[test] + fn messaging_scope_denies_off_grant_topics() { + let messaging = MockMessaging::default(); + messaging.scope_topics(["/acme/1/orders/proto"]); + + messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); + let err = messaging.publish("/other", b"no").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + let err = messaging.query("/other", None, None, None).unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused publish was never recorded. + assert_eq!(messaging.publish_count(), 1); + } + + #[test] + fn messaging_fault_injection_fires_by_prefix() { + let messaging = MockMessaging::default(); + messaging.fail_on("/flaky", Fault::Timeout); + assert!(matches!( + messaging.publish("/flaky/topic", b"x").unwrap_err(), + Fault::Timeout, + )); + messaging.publish("/steady", b"x").unwrap(); + } + + #[test] + fn fetch_returns_programmed_response_and_records_the_request() { + let fetch = MockFetch::default(); + fetch.respond_to( + http::Method::GET, + "https://venue.example/api/v1/quote", + 200, + br#"{"price":"1"}"#.to_vec(), + ); + + let request = http::Request::builder() + .method(http::Method::GET) + .uri("https://venue.example/api/v1/quote") + .body(Vec::new()) + .unwrap(); + let response = fetch.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!(response.body(), br#"{"price":"1"}"#); + + assert_eq!(fetch.request_count(), 1); + let recorded = fetch.last_request().unwrap(); + assert_eq!(recorded.method, http::Method::GET); + assert_eq!(recorded.uri, "https://venue.example/api/v1/quote"); + assert_eq!(recorded.options, FetchOptions::default()); + } + + #[test] + fn fetch_unconfigured_and_programmed_failures() { + let fetch = MockFetch::default(); + fetch.fail_with( + http::Method::POST, + "https://venue.example/api/v1/orders", + FetchError::Denied, + ); + + let denied = http::Request::builder() + .method(http::Method::POST) + .uri("https://venue.example/api/v1/orders") + .body(b"order".to_vec()) + .unwrap(); + assert_eq!(fetch.fetch(denied).unwrap_err(), FetchError::Denied); + + let stray = http::Request::builder() + .uri("https://nowhere.example/") + .body(Vec::new()) + .unwrap(); + let err = fetch.fetch(stray).unwrap_err(); + assert!(matches!(err, FetchError::Transport(msg) if msg.contains("MockFetch"))); + // Refused and unconfigured requests are still recorded. + assert_eq!(fetch.request_count(), 2); + } + + #[test] + fn transport_dispatches_through_every_seam() { + let transport = MockTransport::new(); + transport + .chain + .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".to_owned())); + transport.messaging.seed_payload("/t", b"m".to_vec(), 1); + transport + .http + .respond_to(http::Method::GET, "https://venue.example/", 204, Vec::new()); + + // Through the seams an adapter's logic is written against. + let chain: &dyn ChainHost = &transport; + assert_eq!( + chain.request(1, "eth_blockNumber", "[]").unwrap(), + "\"0x1\"" + ); + + let messaging: &dyn MessagingHost = &transport; + messaging.publish("/t", b"out").unwrap(); + assert_eq!(messaging.query("/t", None, None, None).unwrap().len(), 1); + + let request = http::Request::builder() + .uri("https://venue.example/") + .body(Vec::new()) + .unwrap(); + let response = transport.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + + assert_eq!(transport.chain.call_count(), 1); + assert_eq!(transport.messaging.publish_count(), 1); + assert_eq!(transport.http.request_count(), 1); + } +} diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs new file mode 100644 index 00000000..290c2ef4 --- /dev/null +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -0,0 +1,133 @@ +//! Acceptance surface for the conformance kit: an adapter written +//! against `nexum-venue-sdk` is held to the published vector and +//! golden files, and a deliberately divergent adapter is caught by +//! them. + +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, +}; +use nexum_venue_test::reference::{ + CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +}; +use nexum_venue_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; + +/// An adapter under test: the reference venue implemented through the +/// SDK trait, transport injected through the seams so the kit's mocks +/// drive it. +struct ReferenceAdapter; + +impl VenueAdapter for ReferenceAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + derive_reference_header(body) + } + + fn submit(body: Vec) -> Result { + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +#[test] +fn adapter_codec_conforms_to_the_published_vectors() { + CodecVectors::from_json(CODEC_VECTORS_JSON) + .expect("the published vector file parses") + .assert_conforms::(); +} + +#[test] +fn adapter_derive_header_conforms_to_the_published_goldens() { + HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .expect("the published golden file parses") + .assert_conforms(ReferenceAdapter::derive_header); +} + +#[test] +fn divergent_derivation_is_caught_by_the_published_goldens() { + // The classic byte-order bug: little-endian amounts. + let derive = |body: Vec| -> Result { + let mut header = derive_reference_header(body)?; + for give in &mut header.gives { + give.amount.reverse(); + } + Ok(header) + }; + let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .unwrap() + .check(derive) + .unwrap_err(); + assert!(!report.violations.is_empty()); + assert!(report.violations[0].detail.contains("diverges")); +} + +#[test] +fn mock_transport_drives_seam_shaped_adapter_logic() { + // A slice of adapter logic written against the seams: announce a + // submission over messaging, confirm via the venue's HTTP API. + fn announce(messaging: &M, receipt: &[u8]) -> Result<(), VenueError> { + messaging + .publish("/reference/1/receipts/proto", receipt) + .map_err(VenueError::from) + } + + let transport = MockTransport::new(); + transport + .messaging + .scope_topics(["/reference/1/receipts/proto"]); + + let SubmitOutcome::Accepted(receipt) = ReferenceAdapter::submit(vec![1, 2, 3]).unwrap() else { + panic!("the reference venue accepts directly"); + }; + announce(&transport, &receipt).unwrap(); + assert_eq!( + transport.messaging.last_published().unwrap().payload, + receipt, + ); + + // An off-scope topic surfaces as the typed policy refusal. + let denied = transport + .messaging + .publish("/elsewhere", &receipt) + .map_err(VenueError::from) + .unwrap_err(); + assert!(matches!(denied, VenueError::Denied(_))); +} + +#[test] +fn published_files_document_the_wire_format_in_hex() { + // Non-Rust authors consume the files directly: every byte field is + // lowercase hex, and the first round-trip vector carries prose. + let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); + assert!(vectors.vectors.iter().any(|vector| vector.notes.is_some())); + + let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); + let golden = &goldens.goldens[0]; + // The golden's body is a codec vector's bytes: the two files pin + // the same wire form from both sides. + assert!( + vectors + .vectors + .iter() + .any(|vector| vector.bytes == golden.body), + "header goldens reuse published codec bodies", + ); + // And the expected header speaks the value-flow vocabulary. + let derived = derive_reference_header(golden.body.clone()).unwrap(); + assert_eq!( + derived.gives[0].asset, + Asset::NativeToken(Settlement::EvmChain(1)), + ); + assert_eq!(derived.authorisation, AuthScheme::Eip712); + let _: &AssetAmount = &derived.gives[0]; +} diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/nexum-venue-test/vectors/reference-body.json new file mode 100644 index 00000000..297d32b3 --- /dev/null +++ b/crates/nexum-venue-test/vectors/reference-body.json @@ -0,0 +1,71 @@ +{ + "schema": "nexum-venue-test/reference-body", + "vectors": [ + { + "name": "v1-small", + "bytes": "00010000000000000002000000676d", + "expect": "round-trip", + "notes": "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 little-endian length then utf-8 bytes" + }, + { + "name": "v1-zero-and-empty", + "bytes": "00000000000000000000000000", + "expect": "round-trip", + "notes": "zero integer and zero-length string" + }, + { + "name": "v1-max-amount", + "bytes": "00ffffffffffffffff030000006d6178", + "expect": "round-trip", + "notes": "endianness proof: u64::MAX is eight 0xff bytes" + }, + { + "name": "v2-full", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "expect": "round-trip", + "notes": "tag 0x01; option present is 0x01 then the payload, vec is u32 little-endian element count then bytes, bool true is 0x01" + }, + { + "name": "v2-no-expiry", + "bytes": "010500000000000000050000006c617465720014000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", + "expect": "round-trip", + "notes": "option absent is a bare 0x00, bool false is 0x00" + }, + { + "name": "empty-body", + "bytes": "", + "expect": "empty", + "notes": "no version tag at all" + }, + { + "name": "unknown-version", + "bytes": "09010000000000000002000000676d", + "expect": { + "unknown-version": { + "version": 9 + } + }, + "notes": "tag 0x09 names no published version" + }, + { + "name": "truncated-payload", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f1011121314", + "expect": { + "malformed": { + "version": 1 + } + }, + "notes": "known tag, payload cut one byte short" + }, + { + "name": "trailing-bytes", + "bytes": "00010000000000000002000000676d00", + "expect": { + "malformed": { + "version": 0 + } + }, + "notes": "decoding must consume the payload exactly" + } + ] +} diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2bc9f4b1..2ec14aa6 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -16,7 +16,6 @@ path = "src/main.rs" # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk = { path = "../shepherd-sdk" } shepherd-sdk-test = { path = "../shepherd-sdk-test" } anyhow.workspace = true diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 1ba6e44f..60d8f28e 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -2,11 +2,12 @@ //! //! Each [`EthFlowFixture`] is driven through the production strategy //! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, the resolved `app_data` JSON is programmed as the -//! `GET /api/v1/app_data/{hash}` response, the -//! `cow_api.submit_order` response is programmed to echo the -//! fixture's pre-derived UID, and `strategy::on_chain_logs` is invoked -//! with an alloy `Log` reconstructed from the raw `eth_getLogs` payload. +//! constructed, the `cow_api.submit_order` response is programmed to +//! echo the fixture's pre-derived UID, and `strategy::on_chain_logs` +//! is invoked with an alloy `Log` reconstructed from the raw +//! `eth_getLogs` payload. Submission is appData-hash-only: no +//! `GET /api/v1/app_data/{hash}` resolution runs first, so the +//! fixture's collected `app_data_resolved` document is schema-only. //! //! The classification falls into one of the four buckets defined in //! the issue: @@ -15,8 +16,7 @@ //! `OrderCreation` body. The body is captured for downstream //! validation (Phase 2B / orderbook quote round-trip). //! - `RejectedExpected`: the strategy returned without submitting in -//! a documented case - e.g. the app_data hash didn't resolve -//! (documented skip path), or dedup already saw the UID. +//! a documented case - e.g. dedup already saw the UID. //! - `RejectedUnexpected`: the strategy returned without submitting //! in a path we don't recognise; a follow-up should be //! filed before the report closes. @@ -24,7 +24,6 @@ //! bug or an `unreachable!` we want to investigate. use ethflow_watcher::strategy; -use shepherd_sdk::cow::{CowApiError, HttpFailure}; use shepherd_sdk_test::MockHost; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -87,22 +86,6 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { // re-run the orderbook itself. host.cow_api.respond(Ok(fx.uid.clone())); - // Program the `app_data` resolution path. If the - // collector captured a resolved document, hand it back verbatim; - // if the hash 404'd at collection time, return a host-side - // `Unavailable` so the strategy hits its documented "appData - // hash not mirrored" branch. - let app_data_path = format!("/api/v1/app_data/{}", fx.app_data_hash); - let app_data_response = match &fx.app_data_resolved { - Some(doc) => Ok(serde_json::to_string(doc).expect("re-serialise app_data")), - None => Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - })), - }; - host.cow_api - .respond_to_request_for("GET", app_data_path, app_data_response); - // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow // owner the fixture pins. @@ -179,7 +162,11 @@ fn classify_ok(host: &MockHost, fx: &EthFlowFixture, log_lines: &[String]) -> Cl return Classification::Submitted; } // The strategy returned Ok without submitting. Distinguish the - // documented branches from anomalies. + // documented branches from anomalies. NOTE: the "not mirrored" + // skip path was retired with hash-only submission; this + // classification is kept for historical report comparability + // until the harness is reworked around the observer-only + // strategy (follow-up). if fx.app_data_resolved.is_none() { return Classification::RejectedExpected( "app_data hash not mirrored (documented skip path)".into(), diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index 55336bf5..018adaba 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -25,7 +25,12 @@ use crate::cow_orderbook::{CowApiError, OrderBookPool}; mod bindings { wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/cow-ext", imports: { default: async }, with: { "nexum:host/types": nexum_runtime::bindings::nexum::host::types }, diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index a09c388f..83a3525c 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -15,3 +15,9 @@ nexum-sdk = { path = "../nexum-sdk" } nexum-sdk-test = { path = "../nexum-sdk-test" } shepherd-sdk = { path = "../shepherd-sdk" } serde_json = { workspace = true, features = ["std"] } + +[dev-dependencies] +# Order construction for the MockVenue acceptance tests that drive +# the keeper run end to end. +alloy-primitives.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index e1289cd0..2e08b74f 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -23,6 +23,23 @@ //! assert_eq!(host.cow_api.call_count(), 1); //! ``` //! +//! Per-call venue scripting - outcome queues, status sequences, fault +//! injection - goes through [`MockVenue`] on the same seam: +//! +//! ```rust +//! use nexum_sdk::host::Fault; +//! use shepherd_sdk::cow::{CowApiError, CowApiHost as _}; +//! use shepherd_sdk_test::MockHost; +//! +//! let host = MockHost::with_venue(); +//! host.cow_api +//! .enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); +//! host.cow_api.enqueue_submit(Ok("0xuid".into())); +//! +//! assert!(host.submit_order(1, b"{}").is_err()); +//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); +//! ``` +//! //! Modules that never touch the orderbook use `nexum-sdk-test`'s //! `MockHost` directly instead. @@ -30,6 +47,7 @@ #![warn(missing_docs)] use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; @@ -37,16 +55,18 @@ use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus [`MockCowApi`]. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. +/// mocks plus a venue mock on the `shepherd:cow/cow-api` seam - +/// [`MockCowApi`] by default, [`MockVenue`] via +/// [`with_venue`](MockHost::with_venue). Each field exposes the +/// per-trait mock so tests can program responses and assert on calls. #[derive(Default)] -pub struct MockHost { +pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, /// `nexum:host/local-store` mock. pub store: MockLocalStore, /// `shepherd:cow/cow-api` mock. - pub cow_api: MockCowApi, + pub cow_api: V, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -58,13 +78,20 @@ impl MockHost { } } -impl ChainHost for MockHost { +impl MockHost { + /// Fresh empty host with [`MockVenue`] on the cow-api seam. + pub fn with_venue() -> Self { + Self::default() + } +} + +impl ChainHost for MockHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { self.chain.request(chain_id, method, params) } } -impl LocalStoreHost for MockHost { +impl LocalStoreHost for MockHost { fn get(&self, key: &str) -> Result>, Fault> { self.store.get(key) } @@ -79,7 +106,7 @@ impl LocalStoreHost for MockHost { } } -impl CowApiHost for MockHost { +impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) } @@ -94,7 +121,7 @@ impl CowApiHost for MockHost { } } -impl LoggingHost for MockHost { +impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); } @@ -240,6 +267,181 @@ impl CowApiHost for MockCowApi { } } +// ---------------------------------------------------------------- venue + +/// Scripted in-memory venue on the [`CowApiHost`] seam: programmable +/// per-call behaviour, unlike [`MockCowApi`]'s single replayed +/// response. Compose it with the generic mocks via +/// [`MockHost::with_venue`]. +/// +/// The two queue disciplines differ deliberately. Submissions are +/// discrete effects, so the submit queue strictly drains - one outcome +/// per call, then the configured fallback (default: an `Unsupported` +/// fault), so a test that scripts N outcomes catches an unexpected +/// N+1th submit. Responses are observations, so a `(method, path)` +/// sequence advances per call and its final entry replays forever - a +/// terminal order status persists no matter how often it is re-polled. +/// An injected fault overrides both (without consuming the queues) +/// until cleared, modelling a venue outage. +#[derive(Default)] +pub struct MockVenue { + submit_queue: RefCell>, + submit_fallback: RefCell>, + response_sequences: RefCell>>, + response_fallback: RefCell>, + fault: RefCell>, + calls: RefCell>, + request_calls: RefCell>, +} + +/// One scripted venue reply: the body / UID on success, a typed +/// [`CowApiError`] otherwise. +type VenueOutcome = Result; + +impl MockVenue { + /// Append one `submit_order` outcome to the queue; each call + /// consumes one, in order. + pub fn enqueue_submit(&self, outcome: Result) { + self.submit_queue.borrow_mut().push_back(outcome); + } + + /// Steady-state `submit_order` response once the queue is drained. + /// Unset, a drained queue yields an `Unsupported` fault. + pub fn set_submit_fallback(&self, outcome: Result) { + *self.submit_fallback.borrow_mut() = Some(outcome); + } + + /// Append one outcome to the `(method, path)` response sequence. + /// Each matching `cow_api_request` call advances the sequence; the + /// final entry sticks. + pub fn enqueue_response( + &self, + method: impl Into, + path: impl Into, + outcome: Result, + ) { + self.response_sequences + .borrow_mut() + .entry((method.into(), path.into())) + .or_default() + .push_back(outcome); + } + + /// Append one status-probe outcome for the order, keyed on the + /// orderbook's `GET /api/v1/orders/{uid}` route. + pub fn enqueue_order_status(&self, uid: &str, outcome: Result) { + self.enqueue_response("GET", format!("/api/v1/orders/{uid}"), outcome); + } + + /// Catch-all `cow_api_request` response for calls with no + /// programmed sequence. Unset, those yield an `Unsupported` fault. + pub fn set_response_fallback(&self, outcome: Result) { + *self.response_fallback.borrow_mut() = Some(outcome); + } + + /// Fail every venue call with `err` until + /// [`clear_fault`](Self::clear_fault) - a scripted outage. Queued + /// outcomes are not consumed while the fault is active. + pub fn inject_fault(&self, err: CowApiError) { + *self.fault.borrow_mut() = Some(err); + } + + /// Lift an injected fault; queued outcomes resume where they left + /// off. + pub fn clear_fault(&self) { + *self.fault.borrow_mut() = None; + } + + /// All submissions, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last submission, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Convenience: parse the most recent submission body as JSON. + pub fn last_body_as_json(&self) -> Option { + self.last_call() + .and_then(|c| serde_json::from_slice(&c.body).ok()) + } + + /// Count of submissions (failed and injected-fault calls included). + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + /// All `cow_api_request` invocations, in arrival order. + pub fn request_calls(&self) -> Vec { + self.request_calls.borrow().clone() + } + + /// Scripted submit outcomes not yet consumed - assert `0` to prove + /// a scenario played out in full. + pub fn pending_submits(&self) -> usize { + self.submit_queue.borrow().len() + } +} + +impl CowApiHost for MockVenue { + fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { + self.calls.borrow_mut().push(SubmitCall { + chain_id, + body: body.to_vec(), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(outcome) = self.submit_queue.borrow_mut().pop_front() { + return outcome; + } + self.submit_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: submit queue exhausted and no fallback configured".to_string(), + ))) + }) + } + + fn cow_api_request( + &self, + chain_id: u64, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + self.request_calls.borrow_mut().push(RequestCall { + chain_id, + method: method.to_string(), + path: path.to_string(), + body: body.map(str::to_string), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(sequence) = self + .response_sequences + .borrow_mut() + .get_mut(&(method.to_string(), path.to_string())) + { + // Advance until one entry remains, then replay it: the + // sequence's final state persists. + if sequence.len() > 1 { + return sequence.pop_front().expect("length checked above"); + } + if let Some(last) = sequence.front() { + return last.clone(); + } + } + self.response_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: no response programmed for this request".to_string(), + ))) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -266,6 +468,119 @@ mod tests { ); } + // ---- MockVenue ---- + + #[test] + fn venue_submit_queue_drains_in_order_then_falls_back() { + let venue = MockVenue::default(); + venue.enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); + venue.enqueue_submit(Ok("0xuid".into())); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Timeout)), + )); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(venue.pending_submits(), 0); + + // A drained queue is unsupported by default: an unscripted + // extra submit fails loudly. + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + + venue.set_submit_fallback(Ok("0xsteady".into())); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xsteady"); + assert_eq!(venue.call_count(), 4, "every call is recorded"); + } + + #[test] + fn venue_records_submissions_like_the_single_shot_mock() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.submit_order(7, b"{\"x\":1}").unwrap(); + + let last = venue.last_call().unwrap(); + assert_eq!(last.chain_id, 7); + assert_eq!(last.body, b"{\"x\":1}"); + assert_eq!(venue.last_body_as_json().unwrap()["x"], 1); + } + + #[test] + fn venue_fault_injection_overrides_queues_until_cleared() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.enqueue_response("GET", "/api/v1/orders/0x1", Ok("{}".into())); + venue.inject_fault(CowApiError::Fault(Fault::Unavailable("down".into()))); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unavailable(_))), + )); + assert!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .is_err() + ); + + // The outage consumed nothing: outcomes resume on recovery. + venue.clear_fault(); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .unwrap(), + "{}", + ); + assert_eq!(venue.call_count(), 2); + assert_eq!(venue.request_calls().len(), 2); + } + + #[test] + fn venue_response_sequence_advances_and_final_entry_sticks() { + let venue = MockVenue::default(); + for body in ["\"open\"", "\"open\"", "\"fulfilled\""] { + venue.enqueue_order_status("0xuid", Ok(body.into())); + } + let probe = || { + venue + .cow_api_request(1, "GET", "/api/v1/orders/0xuid", None) + .unwrap() + }; + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"fulfilled\""); + // The terminal entry replays for any later re-poll. + assert_eq!(probe(), "\"fulfilled\""); + } + + #[test] + fn venue_unscripted_request_uses_the_fallback_then_defaults() { + let venue = MockVenue::default(); + assert!(matches!( + venue.cow_api_request(1, "GET", "/api/v1/anything", None), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + venue.set_response_fallback(Ok("catch-all".into())); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/anything", None) + .unwrap(), + "catch-all", + ); + } + + #[test] + fn mock_host_with_venue_dispatches_through_cow_host_bound() { + let host = MockHost::with_venue(); + host.cow_api.enqueue_submit(Ok("0xuid".into())); + + let _: &dyn shepherd_sdk::cow::CowHost = &host; + assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn mock_host_dispatches_through_cow_host_bound() { let host = MockHost::new(); diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs new file mode 100644 index 00000000..e9b3bef0 --- /dev/null +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -0,0 +1,351 @@ +//! MockVenue acceptance tests: the scripted venue driving the keeper +//! run (multi-tick retry, backoff, and outage scenarios the +//! single-replayed-response mock cannot express) and module-shaped +//! strategy code polling the venue directly. + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk_test::{MockHost, MockVenue}; + +const SEPOLIA: u64 = 11_155_111; + +type VenueHost = MockHost; + +/// Closure-backed source so each test scripts its own outcome. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + type Outcome = Verdict; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: 0, + } +} + +fn ready_source( + order: &GPv2OrderData, +) -> FnSource, &[u8], &Tick) -> Verdict> { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) +} + +fn seed_watch(host: &VenueHost) -> String { + WatchSet::new(host) + .put( + &sample_owner(), + &keccak256(b"conditional order params"), + b"params", + ) + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +// ---- keeper use ---- + +/// A transient rejection on the first tick keeps the watch alive; the +/// next tick's scripted success is journalled. Per-call scripting is +/// the point: one venue plays a different outcome on each tick. +#[test] +fn keeper_retries_a_transient_rejection_then_submits() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InsufficientFee"))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let source = ready_source(&order); + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + assert!(host.store.snapshot().contains_key(&key), "watch survives"); + assert!( + !Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + assert_eq!( + host.cow_api.pending_submits(), + 0, + "scenario played out in full" + ); +} + +/// A rate-limit with server guidance gates the watch on the epoch +/// clock; the venue is only reached again once the gate clears, and +/// the queued success then lands. +#[test] +fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let t0 = sample_tick(); + let source = ready_source(&order); + run(&host, &source, &t0).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // 2500ms rounds up to a 3s epoch gate: a tick inside it never + // reaches the venue. + let gated = Tick { + epoch_s: t0.epoch_s + 2, + ..t0 + }; + run(&host, &source, &gated).unwrap(); + assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); + + let clear = Tick { + epoch_s: t0.epoch_s + 3, + ..t0 + }; + run(&host, &source, &clear).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A venue outage is transient: the watch stays, nothing is gated, and +/// the first tick after recovery submits the queued outcome. +#[test] +fn keeper_survives_a_venue_outage_and_submits_on_recovery() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); + + let source = ready_source(&order); + run(&host, &source, &sample_tick()).unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert_eq!(host.cow_api.call_count(), 1); + + host.cow_api.clear_fault(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A scripted permanent rejection drops the watch through the ledger. +#[test] +fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InvalidSignature"))); + + run(&host, &ready_source(&order), &sample_tick()).unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// Keeper rows written through the composed host stay invisible to a +/// sibling store namespace, and a decoy watch planted there never +/// reaches the sweep - the store-fidelity seam under the venue tests. +#[test] +fn keeper_sweep_ignores_sibling_namespace_watches() { + let host = MockHost::with_venue(); + seed_watch(&host); + let sibling = host.store.namespaced("other-module"); + assert!(sibling.is_empty(), "keeper rows must not leak across"); + sibling + .set( + "watch:0x00112233445566778899aabbccddeeff00112233:0xdead", + b"decoy", + ) + .unwrap(); + + let order = submittable_order(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + let polls = std::cell::Cell::new(0_u32); + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "only this module's watch is swept"); + assert_eq!(host.cow_api.call_count(), 1); +} + +// ---- module use ---- + +/// Module-shaped fill tracker: probe the orderbook status route and +/// journal an `observed:` receipt once the order reports fulfilled. +/// Generic over [`CowHost`] exactly like production strategy code. +fn record_fill(host: &H, chain_id: u64, uid: &str) -> Result { + let path = format!("/api/v1/orders/{uid}"); + let Ok(body) = host.cow_api_request(chain_id, "GET", &path, None) else { + return Ok(false); + }; + let fulfilled = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("status").and_then(|s| s.as_str().map(str::to_owned))) + .is_some_and(|status| status == "fulfilled"); + if fulfilled { + Journal::observed(host).record(uid)?; + } + Ok(fulfilled) +} + +/// The status sequence advances one entry per module poll and its +/// terminal entry persists across any number of re-polls. +#[test] +fn module_tracks_a_fill_through_a_status_sequence() { + let host = MockHost::with_venue(); + for body in [ + r#"{"status":"open"}"#, + r#"{"status":"open"}"#, + r#"{"status":"fulfilled"}"#, + ] { + host.cow_api.enqueue_order_status("0xuid", Ok(body.into())); + } + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + // Terminal status sticks: an over-eager re-poll sees it again. + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + assert!(Journal::observed(&host).contains("0xuid").unwrap()); + assert_eq!(host.cow_api.request_calls().len(), 4); + assert_eq!(host.cow_api.request_calls()[0].path, "/api/v1/orders/0xuid"); +} + +/// An outage mid-sequence surfaces to the module as a failed probe and +/// consumes nothing: the sequence resumes where it left off. +#[test] +fn module_probe_rides_out_an_injected_outage() { + let host = MockHost::with_venue(); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"open"}"#.into())); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"fulfilled"}"#.into())); + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Timeout)); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api.clear_fault(); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(Journal::observed(&host).contains("0xuid").unwrap()); +} + +/// The free `watch_key` helper produces exactly the key +/// `WatchSet::put` writes through the venue host, so a test can seed +/// or assert rows without a host turbofish. +#[test] +fn watch_key_helper_unifies_with_the_venue_host() { + let host = MockHost::with_venue(); + let hash: B256 = keccak256(b"conditional order params"); + let written = WatchSet::new(&host) + .put(&sample_owner(), &hash, b"params") + .unwrap(); + assert_eq!(watch_key(&sample_owner(), &hash), written); +} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index fe8e1af3..c4ef8455 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -12,12 +12,28 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # supported so the helpers are unit-testable without a wasm toolchain. [dependencies] +# Re-export shim: the venue-neutral intent body types now live in the +# cow-venue default slice; this crate re-exports them under `cow` until +# the module ports move off the legacy path. The `client` slice carries +# the table-driven retry classification the cow error surface delegates +# to. +cow-venue = { path = "../cow-venue", features = ["client"] } nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true alloy-sol-types.workspace = true +serde_json.workspace = true strum.workspace = true thiserror.workspace = true +tracing.workspace = true [dev-dependencies] +# `capture_tracing` observes the keeper run's diagnostics in the +# acceptance tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true +# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, +# and the keeper run acceptance-tests against the composed MockHost +# as an integration test - the mock crate links shepherd-sdk +# externally, so the unit-test copy of the traits would not unify. +shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md index a93d3dfb..13a9ae0c 100644 --- a/crates/shepherd-sdk/README.md +++ b/crates/shepherd-sdk/README.md @@ -23,7 +23,7 @@ use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; | `prelude` | One-liner `use ::*` for cowprotocol order / signing / orderbook surface (alloy primitives come from `nexum_sdk::prelude`). | | `cow` | `CowApiHost` trait for `shepherd:cow/cow-api` + the `CowHost` bound over the core `nexum_sdk::host::Host`. | | `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | -| `cow::composable` | `sol! IConditionalOrder` errors + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | +| `cow::composable` | `sol! IConditionalOrder` errors + `Verdict` + `LegacyRevertAdapter`. | | `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | | `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | @@ -62,7 +62,7 @@ crates/shepherd-sdk/ │ ├── cow/ │ │ ├── mod.rs CowApiHost + CowHost │ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) +│ │ ├── composable.rs IConditionalOrder + Verdict + LegacyRevertAdapter │ │ └── error.rs RetryAction + classify_api_error │ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! └── README.md you are here diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 212e1240..a7a4674e 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -1,10 +1,21 @@ -//! ComposableCoW poll-revert decoding. +//! ComposableCoW poll seam: the structured [`Verdict`] and the +//! quarantined [`LegacyRevertAdapter`]. //! -//! `ComposableCoW.getTradeableOrderWithSignature` reverts with one of -//! five custom errors when the conditional order is not ready, expired, -//! or otherwise non-tradeable. This module mirrors that error surface -//! and maps each revert to the typed [`PollOutcome`] every TWAP / -//! strategy module dispatches on. +//! Every strategy poll resolves to a [`Verdict`] - the structured +//! outcome mirroring the composable-cow fork's structured generator. +//! The keeper run and each strategy module +//! dispatch on the `Verdict` variants alone; nothing downstream knows +//! how the outcome was produced. +//! +//! The deployed ComposableCoW 1.x contract does not speak that +//! structured vocabulary. Its `getTradeableOrderWithSignature` reverts +//! with one of five custom errors when the conditional order is not +//! ready, expired, or otherwise non-tradeable. That reverting wire is +//! frozen: this module keeps decoding it, but the decode is quarantined +//! behind [`LegacyRevertAdapter`], which maps each legacy revert onto a +//! [`Verdict`]. When the fork's structured generator ships, the adapter +//! is the single seam that retires - the `Verdict` surface and every +//! dispatch site stay put. //! //! Source for the Solidity errors: //! `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. @@ -12,11 +23,13 @@ use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolError, sol}; use cowprotocol::GPv2OrderData; +use nexum_sdk::host::ChainError; sol! { - /// Five custom errors `IConditionalOrder.verify` reverts with. - /// Selector source for [`decode_revert`]. The wire shape mirrors - /// the Solidity definitions verbatim so the four-byte selectors + /// Five custom errors `IConditionalOrder.verify` reverts with - + /// the deployed ComposableCoW 1.x error surface. Selector source + /// for [`LegacyRevertAdapter::decode`]. The wire shape mirrors the + /// Solidity definitions verbatim so the four-byte selectors /// computed here match what the contract emits. #[derive(Debug)] interface IConditionalOrder { @@ -36,72 +49,140 @@ sol! { } } -/// Outcome of a single watch poll. Mirrors the enum shape: -/// `Ready` carries the materials the submit path needs; the other -/// variants drive the lifecycle handler. +/// Structured outcome of a single watch poll, mirroring the +/// composable-cow fork's structured generator. /// -/// `Ready` is intentionally never produced by [`decode_revert`] - it -/// only comes from the successful return path the poll module -/// constructs at the call site. +/// Every variant except `Post` carries a `reason`: the raw 4-byte +/// selector the outcome was derived from, for logging only - no +/// behaviour keys off it. It is `[0; 4]` when the outcome is synthetic +/// (no selector available, e.g. a transport fault). `Post` is the only +/// variant [`LegacyRevertAdapter`] never produces; it comes from the +/// successful return path each strategy constructs at the call site. #[derive(Debug)] -pub enum PollOutcome { +pub enum Verdict { /// Conditional order is tradeable now; submit `order` with the - /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed - /// to keep the enum cache-friendly (~300 bytes vs. ~8 for the - /// other variants). - Ready { + /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed to + /// keep the enum cache-friendly (~300 bytes vs. a few for the other + /// variants). + Post { /// The 12-field order ready to submit. order: Box, /// EIP-1271 wire-form signature (raw verifier bytes; the /// orderbook prepends `from` before settlement). signature: Bytes, + /// Advisory Unix timestamp (seconds) the fork's generator hints + /// the next poll at. `0` when synthetic - the legacy adapter + /// has no such hint, so the submit path ignores it. + next_poll_timestamp: u64, + }, + /// Retry once the wall clock (Unix seconds, UTC) reaches + /// `wait_until`. + WaitTimestamp { + /// Unix timestamp (seconds) to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry once the block number reaches `wait_until`. + WaitBlock { + /// Block number to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], }, /// Retry on the very next block - typical for time-sliced TWAP /// schedules and other handlers that re-check on every tick. - TryNextBlock, - /// Retry once block number reaches the embedded value. - TryOnBlock(u64), - /// Retry once the wall clock (Unix seconds, UTC) reaches the - /// embedded value. - TryAtEpoch(u64), - /// Order is dead - drop the watch. Aggregates `OrderNotValid` and - /// `PollNever` reverts; the original reason string is dropped - /// because the lifecycle handler does not key off it today. - DontTryAgain, + TryNextBlock { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Order is dead - drop the watch. Aggregates the legacy + /// `OrderNotValid` and `PollNever` reverts and any unrecognised + /// contract-level rejection. + Invalid { + /// Source selector, log only. + reason: [u8; 4], + }, + /// The generator needs off-chain input before it can produce an + /// order. Never produced by [`LegacyRevertAdapter`]; the + /// keeper run parks the watch untouched. + NeedsInput { + /// Source selector, log only. + reason: [u8; 4], + }, } -/// Decode a `getTradeableOrderWithSignature` revert payload into a -/// [`PollOutcome`]. -/// -/// Returns `None` when the selector is not one of the five -/// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. Callers should treat that as `TryNextBlock` (the -/// safe default) so a transient RPC blip does not drop a still-valid -/// watch. -#[must_use] -pub fn decode_revert(data: &[u8]) -> Option { - if data.len() < 4 { - return None; - } - let selector: [u8; 4] = data[..4].try_into().ok()?; - let body = &data[4..]; - match selector { - s if s == IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), - s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => Some(PollOutcome::TryNextBlock), - s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryOnBlock(u256_to_u64_saturating( - decoded.blockNumber, - ))) +/// Quarantined decoder for the deployed ComposableCoW 1.x reverting +/// wire. Maps each legacy `getTradeableOrderWithSignature` revert onto +/// a [`Verdict`]; this is the single seam that retires when the fork's +/// structured generator ships. +#[derive(Debug, Clone, Copy)] +pub struct LegacyRevertAdapter; + +impl LegacyRevertAdapter { + /// Decode a `getTradeableOrderWithSignature` revert payload into a + /// [`Verdict`]. + /// + /// Returns `None` when the selector is not one of the five + /// [`IConditionalOrder`] errors - including a bare `Error(string)` + /// require-revert. [`classify`](Self::classify) is the lifecycle + /// policy on top: it treats any such foreign selector as a + /// permanent contract-level rejection. + #[must_use] + pub fn decode(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + let reason: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match reason { + s if s == IConditionalOrder::OrderNotValid::SELECTOR => { + Some(Verdict::Invalid { reason }) + } + s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => { + Some(Verdict::TryNextBlock { reason }) + } + s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(Verdict::WaitBlock { + wait_until: u256_to_u64_saturating(decoded.blockNumber), + reason, + }) + } + s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(Verdict::WaitTimestamp { + wait_until: u256_to_u64_saturating(decoded.timestamp), + reason, + }) + } + s if s == IConditionalOrder::PollNever::SELECTOR => Some(Verdict::Invalid { reason }), + _ => None, } - s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( - decoded.timestamp, - ))) + } + + /// Classify a failed poll `eth_call` into a [`Verdict`] - the one + /// policy for what a poll failure means to the watch lifecycle. + /// + /// A revert payload big enough to carry a selector that + /// [`decode`](Self::decode) does not recognise maps to `Invalid`: + /// it is a contract-level rejection outside the `IConditionalOrder` + /// vocabulary (a handler-specific error, typically permanent), and + /// retrying it on every block loops forever. Only payload-free + /// failures - transport faults and reverts whose `data` is absent + /// or shorter than a selector - stay `TryNextBlock`. + #[must_use] + pub fn classify(err: &ChainError) -> Verdict { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + let reason: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]); + Self::decode(data).unwrap_or(Verdict::Invalid { reason }) + } + _ => Verdict::TryNextBlock { reason: [0; 4] }, + }, + ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, } - s if s == IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), - _ => None, } } @@ -114,24 +195,24 @@ mod tests { use super::*; #[test] - fn order_not_valid_maps_to_drop() { + fn order_not_valid_maps_to_invalid() { let err = IConditionalOrder::OrderNotValid { reason: "expired".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } #[test] - fn poll_never_maps_to_drop() { + fn poll_never_maps_to_invalid() { let err = IConditionalOrder::PollNever { reason: "cancelled".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } @@ -141,8 +222,8 @@ mod tests { reason: "noop".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryNextBlock) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::TryNextBlock { .. }) )); } @@ -153,8 +234,11 @@ mod tests { reason: "wait".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryOnBlock(12_345_678)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitBlock { + wait_until: 12_345_678, + .. + }) )); } @@ -165,21 +249,36 @@ mod tests { reason: "soon".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryAtEpoch(1_700_000_000)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitTimestamp { + wait_until: 1_700_000_000, + .. + }) )); } + #[test] + fn decoded_reason_carries_the_selector() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + let Some(Verdict::TryNextBlock { reason }) = LegacyRevertAdapter::decode(&err.abi_encode()) + else { + panic!("expected TryNextBlock"); + }; + assert_eq!(reason, IConditionalOrder::PollTryNextBlock::SELECTOR); + } + #[test] fn unknown_selector_returns_none() { let mut data = vec![0xde, 0xad, 0xbe, 0xef]; data.extend_from_slice(&[0u8; 32]); - assert!(decode_revert(&data).is_none()); + assert!(LegacyRevertAdapter::decode(&data).is_none()); } #[test] fn truncated_returns_none() { - assert!(decode_revert(&[0x01, 0x02]).is_none()); + assert!(LegacyRevertAdapter::decode(&[0x01, 0x02]).is_none()); } #[test] @@ -187,4 +286,71 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } + + // ---- LegacyRevertAdapter::classify ---- + + use nexum_sdk::host::{Fault, RpcError}; + + fn rpc(data: Option>) -> ChainError { + ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: data.map(Into::into), + }) + } + + #[test] + fn classify_dispatches_a_recognised_selector() { + let revert = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(777_u64), + reason: "wait".to_string(), + } + .abi_encode(); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(revert))), + Verdict::WaitBlock { + wait_until: 777, + .. + } + )); + } + + /// A handler-specific selector outside the `IConditionalOrder` + /// vocabulary is a permanent contract-level rejection: it must map + /// to `Invalid`, not re-poll every block forever. + #[test] + fn classify_unrecognised_selector_is_invalid() { + let mut data = vec![0x7a, 0x93, 0x32, 0x34]; + data.extend_from_slice(&[0u8; 32]); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(data))), + Verdict::Invalid { .. } + )); + // A bare 4-byte selector with no body classifies the same way. + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + Verdict::Invalid { .. } + )); + } + + #[test] + fn classify_payload_free_failures_stay_try_next_block() { + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(None)), + Verdict::TryNextBlock { .. } + )); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(Vec::new()))), + Verdict::TryNextBlock { .. } + )); + // Sub-selector payloads cannot name a contract error. + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(vec![0x01, 0x02]))), + Verdict::TryNextBlock { .. } + )); + assert!(matches!( + LegacyRevertAdapter::classify(&ChainError::Fault(Fault::Timeout)), + Verdict::TryNextBlock { .. } + )); + } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 23360273..b626ab94 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -7,12 +7,16 @@ //! envelope. The guest dispatches on the variant directly, so no //! second JSON decode of a failure body happens strategy-side. //! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into a -//! [`RetryAction`] the lifecycle layer dispatches on. +//! [`classify_api_error`] maps a decoded [`OrderRejection`] into the +//! keeper [`RetryAction`] the retry ledger dispatches on; +//! [`classify_submit_error`] widens the table to the whole +//! [`CowApiError`] surface. use nexum_sdk::host::{Fault, HostFault}; use strum::IntoStaticStr; +pub use nexum_sdk::keeper::RetryAction; + /// A non-2xx orderbook reply with no typed rejection envelope. `body` /// is the raw response text, foreign orderbook JSON kept verbatim: a /// caller matches on `status` and reads `body` only for diagnostics. @@ -79,49 +83,15 @@ impl HostFault for CowApiError { } } -/// What the lifecycle layer should do after a failed submission. -/// -/// Mirrors the retry contract: `TryNextBlock` / -/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer -/// today because the retry classifier is bool-only; the -/// variant is kept so dispatch can grow into it once a server -/// `Retry-After` hint shows up. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the dispatch layer can record -/// `shepherd_cow_api_retry_total{action=...}` and surface the action -/// in `tracing::info!(retry_action = ...)` without an ad-hoc match -/// ladder. -#[derive(Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch / placement in place; the next event will - /// re-attempt. - TryNextBlock, - /// Persist `next_attempt = now + seconds`. Reserved - no producer - /// today (kept so the dispatch contract is stable). - #[allow(dead_code)] - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Remove the watch / mark as terminally rejected. The orderbook - /// will not accept this body on a retry. - Drop, -} - -/// Classify a decoded orderbook [`OrderRejection`] into a -/// [`RetryAction`]. -/// -/// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, -/// `PriceExceedsMarketPrice`) -> `TryNextBlock`. -/// - Every other (including unrecognised) kind -> `Drop`. +/// Classify a decoded orderbook [`OrderRejection`] into the keeper +/// [`RetryAction`] via the shipped CoW classification table +/// ([`cow_venue::classify`]): the `errorType` drives the action - +/// transient types retry next block, throttle types back off, permanent +/// types drop. The one invariant the table enforces: an `errorType` +/// absent from the data is permanent, never retried every block forever. /// -/// Non-`Rejected` failures (transport faults, raw HTTP errors) carry -/// no `error_type` and are not classified here; the caller treats them -/// as transient (leave the watch in place) so a flaky orderbook does -/// not poison a still-valid order. +/// Non-`Rejected` failures carry no `error_type`; classify those with +/// [`classify_submit_error`]. /// /// # Example /// @@ -147,22 +117,40 @@ pub enum RetryAction { /// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); /// ``` pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_retriable(&rejection.error_type) { - RetryAction::TryNextBlock - } else { - RetryAction::Drop - } + cow_venue::classify(&rejection.error_type) } -/// Orderbook `errorType` values the protocol treats as transient: a -/// fresh submission on a later block may succeed. Everything else -/// (including unrecognised types) is permanent. Mirrors the upstream -/// order-post retry classifier. -fn is_retriable(error_type: &str) -> bool { - matches!( - error_type, - "InsufficientFee" | "TooManyLimitOrders" | "PriceExceedsMarketPrice" - ) +/// Whether the rejection says the orderbook already holds this exact +/// order, per the classification table's `already-submitted` flag +/// (`DuplicatedOrder`, plus the `DuplicateOrder` spelling older +/// deployments emit). Already-submitted is success wearing an error +/// status - dropping the watch on it would kill every future tranche of +/// a TWAP - so the caller records the `submitted:` receipt and keeps the +/// watch. +pub fn is_already_submitted(rejection: &OrderRejection) -> bool { + cow_venue::is_already_submitted(&rejection.error_type) +} + +/// Classify a whole [`CowApiError`] from a submission into the keeper +/// [`RetryAction`]. +/// +/// A typed rejection dispatches through [`classify_api_error`]; a +/// rate-limit fault with server guidance becomes `Backoff` (hint +/// rounded up to whole seconds, minimum one). Everything else +/// (transport faults, raw HTTP errors, unguided rate limits) is +/// transient -> `TryNextBlock`, so a flaky orderbook never poisons a +/// still-valid order. +pub fn classify_submit_error(err: &CowApiError) -> RetryAction { + match err { + CowApiError::Rejected(rejection) => classify_api_error(rejection), + CowApiError::Fault(Fault::RateLimited(limit)) => match limit.retry_after_ms { + Some(ms) => RetryAction::Backoff { + seconds: ms.div_ceil(1000).max(1), + }, + None => RetryAction::TryNextBlock, + }, + _ => RetryAction::TryNextBlock, + } } #[cfg(test)] @@ -181,11 +169,7 @@ mod tests { #[test] fn retriable_kinds_yield_try_next_block() { - for kind in [ - "InsufficientFee", - "TooManyLimitOrders", - "PriceExceedsMarketPrice", - ] { + for kind in ["InsufficientFee", "PriceExceedsMarketPrice"] { assert_eq!( classify_api_error(&rejection(kind)), RetryAction::TryNextBlock, @@ -194,15 +178,25 @@ mod tests { } } + /// A throttle errorType backs off rather than retrying next block, + /// so the table reaches every retry arm - the `Backoff` producer the + /// hand-coded classifier lacked. + #[test] + fn throttle_kind_yields_backoff() { + assert_eq!( + classify_api_error(&rejection("TooManyLimitOrders")), + RetryAction::Backoff { seconds: 30 }, + ); + } + #[test] fn permanent_kinds_yield_drop() { for kind in [ "InvalidSignature", "WrongOwner", - "DuplicateOrder", "UnsupportedToken", "InvalidAppData", - "InvalidErc1271Signature", + "InvalidEip1271Signature", ] { assert_eq!( classify_api_error(&rejection(kind)), @@ -220,6 +214,69 @@ mod tests { ); } + /// Both spellings pin: the orderbook emits `DuplicatedOrder`, the + /// older `DuplicateOrder` form must classify identically. Neither + /// may drop the watch - that would kill every future tranche. + #[test] + fn duplicated_order_is_already_submitted_and_never_drops() { + for kind in ["DuplicatedOrder", "DuplicateOrder"] { + assert!(is_already_submitted(&rejection(kind)), "{kind}"); + assert_eq!( + classify_api_error(&rejection(kind)), + RetryAction::TryNextBlock, + "{kind}", + ); + } + assert!(!is_already_submitted(&rejection("InsufficientFee"))); + assert!(!is_already_submitted(&rejection("InvalidSignature"))); + } + + #[test] + fn submit_error_rejection_routes_through_the_table() { + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InvalidSignature"))), + RetryAction::Drop, + ); + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InsufficientFee"))), + RetryAction::TryNextBlock, + ); + } + + #[test] + fn submit_error_rate_limit_hint_becomes_backoff_in_whole_seconds() { + let limited = |ms| CowApiError::Fault(Fault::RateLimited(RateLimit { retry_after_ms: ms })); + assert_eq!( + classify_submit_error(&limited(Some(2_500))), + RetryAction::Backoff { seconds: 3 }, + ); + // Sub-second hints round up to a full second, never to zero. + assert_eq!( + classify_submit_error(&limited(Some(1))), + RetryAction::Backoff { seconds: 1 }, + ); + // No guidance -> plain next-block retry. + assert_eq!( + classify_submit_error(&limited(None)), + RetryAction::TryNextBlock + ); + } + + #[test] + fn submit_error_transient_shapes_stay_try_next_block() { + assert_eq!( + classify_submit_error(&CowApiError::Fault(Fault::Timeout)), + RetryAction::TryNextBlock, + ); + assert_eq!( + classify_submit_error(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + RetryAction::TryNextBlock, + ); + } + #[test] fn fault_case_recovers_embedded_fault_and_label() { let err = CowApiError::Fault(Fault::Timeout); diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 36cbdcca..1d216924 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,20 +3,39 @@ //! Type conversions and ABI decoding helpers that translate between //! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, //! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`). +//! `Verdict`, `RetryAction`), plus [`run()`] - the +//! poll/submit composition over the keeper stores. //! -//! Each submodule stays purely host-neutral: helpers take primitive -//! arguments (`&[u8]`, `Option<&str>`, slices) so they can be unit- -//! tested without wit-bindgen scaffolding and re-used unchanged by -//! TWAP, EthFlow, and future strategy modules. +//! The poll seam is the structured [`Verdict`]; the deployed +//! ComposableCoW 1.x reverting wire is decoded behind the quarantined +//! [`LegacyRevertAdapter`]. +//! +//! The codec submodules stay purely host-neutral: helpers take +//! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can +//! be unit-tested without wit-bindgen scaffolding and re-used +//! unchanged by TWAP, EthFlow, and future strategy modules. The +//! keeper run is generic over the host traits alone. pub mod composable; pub mod error; pub mod order; +pub mod run; + +pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +pub use error::{ + CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, + classify_submit_error, is_already_submitted, +}; +pub use order::{gpv2_to_order_data, order_uid_hex}; +pub use run::run; -pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; -pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; -pub use order::gpv2_to_order_data; +/// The venue-neutral intent body types and their borsh `IntentBody` +/// 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, +}; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d983d649..d0a79bfb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -7,11 +7,12 @@ //! into Rust enums. [`gpv2_to_order_data`] is the bridge. use alloy_primitives::Address; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource}; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, +}; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` -/// expects. +/// typed [`OrderData`] shape `OrderCreation::new` expects. /// /// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride /// the wire as `bytes32` markers (the `keccak256` of the lowercase @@ -20,10 +21,10 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, Sell /// chain payload carries a marker the SDK doesn't recognise - the /// caller skips the order rather than ship a malformed body. /// -/// `receiver = Address::ZERO` is normalised to `None`; `OrderCreation:: -/// from_signed_order_data` does the same downstream, but doing it here +/// `receiver = Address::ZERO` is normalised to `None`; +/// `OrderCreation::new` does the same downstream, but doing it here /// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// helper later. +/// constructor later. /// /// # Example /// @@ -71,6 +72,26 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { }) } +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker. Only the unknown-marker case also +/// stops the submit path downstream ([`gpv2_to_order_data`] fails the +/// same way there); an unsupported chain id does not, so a caller +/// keying idempotency on this value alone re-submits until `validTo` +/// on such a chain - bounded, but callers adding new chains should +/// teach `cowprotocol::Chain` about them first. +#[must_use] +pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let domain = chain.settlement_domain(); + let order_data = gpv2_to_order_data(order)?; + Some(format!("{}", order_data.uid(&domain, owner))) +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +158,34 @@ mod tests { g.buyTokenBalance = B256::repeat_byte(0x55); assert!(gpv2_to_order_data(&g).is_none()); } + + // ---- order_uid_hex ---- + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } } diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs new file mode 100644 index 00000000..dbabd179 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -0,0 +1,213 @@ +//! Keeper run: the poll-loop composition conditional- +//! commitment modules share. +//! +//! [`run`] walks the keeper watch set, polls each gate-ready +//! watch through a [`ConditionalSource`], and runs the +//! [`Verdict`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Post` drives one submission through the +//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` +//! journal as the idempotency guard and the keeper [`Retrier`] +//! as the failure dispatch. +//! +//! Store faults abort the sweep (the next tick replays it); +//! submission failures never do - they classify into a +//! [`RetryAction`], the ledger applies the effect, and the sweep +//! moves on. Diagnostics go through the guest `tracing` facade - +//! the same channel strategy code logs on - so module tests observe +//! the composed behaviour with one capture. + +use alloy_primitives::{Address, Bytes}; +use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; + +use super::{ + CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, + order_uid_hex, +}; + +/// Poll every gate-ready watch once at `tick` and run each outcome's +/// effect. One source poll per ready watch; a `Post` outcome makes at +/// most one `submit_order` call. +pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: CowHost, + S: ConditionalSource, +{ + let watches = WatchSet::new(host); + let gates = Gates::new(host); + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + continue; + } + let Some(params) = watches.get(watch)? else { + continue; + }; + match source.poll(host, watch, ¶ms, tick) { + Verdict::Post { + order, signature, .. + } => { + submit_ready(host, watch, &order, signature, tick, source.label())?; + } + Verdict::TryNextBlock { .. } => {} + Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, + Verdict::WaitTimestamp { wait_until, .. } => gates.set_next_epoch(watch, wait_until)?, + Verdict::Invalid { .. } => { + // The removal is permanent; leave a trace of it even + // for sources that do not log their own outcomes. + tracing::info!("{} dropped watch {}", source.label(), watch.key()); + watches.remove(watch)?; + } + Verdict::NeedsInput { .. } => { + tracing::info!("watch {} parked awaiting input", watch.key()); + } + } + } + Ok(()) +} + +/// Submit one freshly-polled `Ready` order, guarding on the +/// `submitted:` journal and dispatching any failure through the retry +/// ledger. +/// +/// The UID is deterministic from on-chain inputs, so the idempotency +/// check runs before any network work; the same value keys the journal +/// marker after, so the read and write paths agree. +fn submit_ready( + host: &H, + watch: WatchRef<'_>, + order: &GPv2OrderData, + signature: Bytes, + tick: &Tick, + label: &str, +) -> Result<(), Fault> { + let Ok(owner) = watch.owner_hex().parse::

() else { + tracing::warn!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), + ); + return Ok(()); + }; + + let journal = Journal::submitted(host); + let client_uid = order_uid_hex(tick.chain_id, order, owner); + if let Some(uid) = client_uid.as_deref() + && journal.contains(uid)? + { + tracing::info!("{label} {uid} already submitted; skipping re-submit"); + return Ok(()); + } + + let Some(order_data) = gpv2_to_order_data(order) else { + // An unknown enum marker means the SDK cannot express this + // payload yet; skip rather than drop so an SDK upgrade can + // still pick the watch up. + tracing::warn!( + "{label} submit skipped for {owner:#x}: GPv2OrderData carried an unknown enum marker" + ); + return Ok(()); + }; + let creation = match build_order_creation(&order_data, signature, owner) { + Ok(creation) => creation, + Err(err) => { + // A constructor rejection (zero `from`, `validTo` beyond + // the client-side max horizon) is deterministic for this + // polled payload: keeping the watch would re-poll and + // re-warn on every block forever. Drop through the ledger + // - the same net effect as the pre-keeper flow, where + // the orderbook rejected the shipped body and the + // classifier dropped the watch. + tracing::warn!("{label} submit dropped watch for {owner:#x}: {err}"); + Retrier::new(host).apply(watch, RetryAction::Drop, tick.epoch_s)?; + return Ok(()); + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(body) => body, + Err(e) => { + tracing::error!("OrderCreation JSON encode failed: {e}"); + return Ok(()); + } + }; + + match host.submit_order(tick.chain_id, &body) { + Ok(server_uid) => { + // Prefer the client-computed UID so the guard above reads + // what this writes; a divergence would be a protocol bug + // worth a warning, never a silently split keyspace. + let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + // The submit already succeeded; a journal-store fault here + // must not abort the sweep or unwind the accepted order. + // Log and carry on - the already-submitted arm keeps the + // next tick's re-post idempotent. + if let Err(fault) = journal.record(marker) { + tracing::error!("submitted {marker} but journal write failed: {fault}"); + } + if let Some(client) = client_uid.as_deref() + && client != server_uid + { + tracing::warn!( + "{label} UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" + ); + } + tracing::info!("submitted {marker}"); + } + Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { + // Success wearing an error status: the orderbook already + // holds this order. Record the receipt and keep the watch + // so the next tick short-circuits instead of re-posting. + // As above, a journal fault post-submit only forfeits the + // short-circuit; it must not abort the sweep. + if let Some(uid) = client_uid.as_deref() + && let Err(fault) = journal.record(uid) + { + tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); + } + tracing::info!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, + ); + } + Err(err) => { + let action = classify_submit_error(&err); + Retrier::new(host).apply(watch, action, tick.epoch_s)?; + match action { + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::Backoff { seconds } => { + tracing::warn!("submit backoff {seconds}s: {err}"); + } + RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + // `RetryAction` is non-exhaustive; the ledger already + // ran the effect, so the log needs only the name. + other => { + let action_label: &'static str = other.into(); + tracing::warn!("submit retry action {action_label}: {err}"); + } + } + } + } + Ok(()) +} + +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; the caller drops the watch. +fn build_order_creation( + order_data: &OrderData, + signature: Bytes, + from: Address, +) -> Result { + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 86e00dea..491e7752 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -16,9 +16,12 @@ //! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), and the [`RetryAction`] classifier driving -//! submit-failure dispatch. +//! the structured poll seam ([`Verdict`]) with the deployed 1.x +//! revert decoding quarantined behind [`LegacyRevertAdapter`], the +//! classifiers mapping submit failures into +//! the keeper [`RetryAction`], and [`run`] - the poll -> +//! outcome -> gate/journal/submit composition over the keeper +//! stores. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -47,9 +50,10 @@ //! [`CowHost`]: cow::CowHost //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`PollOutcome`]: cow::PollOutcome -//! [`decode_revert`]: cow::decode_revert +//! [`Verdict`]: cow::Verdict +//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter //! [`RetryAction`]: cow::RetryAction +//! [`run`]: cow::run() #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index ea46e0e3..6d5c1453 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,7 +4,7 @@ //! //! Covered here: //! -//! - `decode_revert` selector dispatch (no-panic guard). +//! - `LegacyRevertAdapter::decode` selector dispatch (no-panic guard). //! - `gpv2_to_order_data` marker mapping (no-panic guard). //! //! The generic properties (`eth_call` round-trip, `scale_decimal`) @@ -15,12 +15,12 @@ use proptest::prelude::*; proptest! { - /// `decode_revert` on arbitrary revert bytes must never panic and - /// must return `None` for inputs shorter than the 4-byte EVM - /// selector. + /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must + /// never panic and must return `None` for inputs shorter than the + /// 4-byte EVM selector. #[test] - fn decode_revert_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::decode_revert(&bytes); + fn legacy_revert_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = crate::cow::LegacyRevertAdapter::decode(&bytes); if bytes.len() < 4 { prop_assert!(outcome.is_none()); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs new file mode 100644 index 00000000..cda235ba --- /dev/null +++ b/crates/shepherd-sdk/tests/run.rs @@ -0,0 +1,458 @@ +//! Keeper-run acceptance tests against the composed +//! `shepherd_sdk_test::MockHost`. These live as an integration test +//! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` +//! externally, and the external and unit-test copies of the traits +//! are distinct types. + +use std::cell::Cell; + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk_test::capture_tracing; +use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk_test::MockHost; + +const SEPOLIA: u64 = 11_155_111; + +/// Closure-backed source so each test scripts its own outcome and +/// observes its own poll calls. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + type Outcome = Verdict; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + keccak256(b"conditional order params") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: 0, + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +// ---- lifecycle outcomes ---- + +#[test] +fn try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + seed_watch(&host); + let before = host.store.snapshot(); + + run( + &host, + &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); + assert_eq!(host.cow_api.call_count(), 0); +} + +#[test] +fn try_on_block_sets_the_block_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| Verdict::WaitBlock { + wait_until: 2_000, + reason: [0; 4], + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_block_key()).unwrap(), + &2_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn try_at_epoch_sets_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| Verdict::WaitTimestamp { + wait_until: 1_800_000_000, + reason: [0; 4], + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_800_000_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn invalid_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 1).unwrap(); + + run( + &host, + &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), + &sample_tick(), + ) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +// ---- gating and skipping ---- + +#[test] +fn gated_watch_is_not_polled() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + Verdict::TryNextBlock { reason: [0; 4] } + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0, "a gated watch must not reach the source"); +} + +#[test] +fn malformed_watch_rows_are_skipped() { + let host = MockHost::new(); + host.store.set("watch:no-separator", b"junk").unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + Verdict::TryNextBlock { reason: [0; 4] } + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0); +} + +// ---- ready -> submission ---- + +#[test] +fn ready_submits_once_and_journals_the_client_uid() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok(client_uid(&order))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + assert_eq!(host.cow_api.call_count(), 1); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "submitted:{{client_uid}} receipt must be recorded", + ); + assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); +} + +#[test] +fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xfeedface".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!( + !snapshot.contains_key("submitted:0xfeedface"), + "marker must key on the client UID, not the divergent server UID", + ); + assert!(logs.any(|e| e.message.contains("UID divergence"))); +} + +#[test] +fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + Journal::submitted(&host) + .record(&client_uid(&order)) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "the source is still consulted"); + assert_eq!( + host.cow_api.call_count(), + 0, + "the journal guard must short-circuit before any network work", + ); +} + +#[test] +fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.kind = B256::repeat_byte(0x42); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert!(host.store.snapshot().contains_key(&key)); +} + +/// A Ready order whose `validTo` exceeds the constructor's client-side +/// one-year horizon can never be submitted: the rejection is +/// deterministic for the polled payload, so the watch must drop +/// through the ledger instead of re-polling and warn-looping on every +/// block forever. (Watch-tower net effect: submit, orderbook rejects +/// with `ExcessiveValidTo`, classifier drops.) +#[test] +fn ready_beyond_the_valid_to_horizon_drops_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.validTo = valid_to_in(2 * 365 * 24 * 3_600); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); + + assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); + let snapshot = host.store.snapshot(); + assert!( + !snapshot.contains_key(&key), + "an unsubmittable order must not survive to warn-loop forever", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); +} + +// ---- submission failure dispatch ---- + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +#[test] +fn transient_rejection_keeps_the_watch_ungated() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InsufficientFee"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +#[test] +fn permanent_rejection_drops_the_watch_through_the_ledger() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 1) + .unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InvalidSignature"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert!( + host.store.is_empty(), + "a permanent rejection must drop the watch and its gates", + ); +} + +/// The orderbook already holds the order: the receipt is recorded, the +/// watch survives, and the next tick short-circuits on the journal +/// instead of re-posting. +#[test] +fn duplicated_order_records_the_receipt_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "already-submitted must record the receipt", + ); + + // The next tick must not touch the orderbook again. + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// A rate-limit fault with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. +#[test] +fn rate_limited_submit_backs_off_through_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .respond(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + + let tick = sample_tick(); + run(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} diff --git a/docs/00-overview.md b/docs/00-overview.md index 11e3c0fb..7803f497 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -272,11 +272,13 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `Fault` + the `HostFault` trait - the shared failure vocabulary and per-interface typed errors (`ChainError`) with `?` support | | | `chain::{eth_call_params, parse_eth_call_result}` + `chain::chainlink` - JSON-RPC plumbing helpers | | | `config` / `address` - config-table lookups, decimal scaling, address parsing | +| | `keeper::{WatchSet, Gates, Journal, Retrier, ConditionalSource}` - the conditional-commitment strategy keeper: watch registry, poll gates, receipt journal, retry dispatch over the local-store seam | | | `http::{fetch, Fetch, FetchError, FetchOptions}` - allowlisted outbound HTTP over wasi:http on the standard `http` crate's `Request` / `Response` types | | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `Verdict`, `LegacyRevertAdapter`, `RetryAction`, `classify_api_error`) | +| | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | | `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 697d3448..8b62896f 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,982 +1,311 @@ -# SDK Design: Layered SDK (`nexum-sdk` + `shepherd-sdk`) - -> **Status: future direction, not in 0.2 scope.** This document is the **0.3+ north-star** vision for the layered SDK. The 0.2 SDK shipped a focused subset, and the macro-driven authoring model below was superseded by the host-trait seam in [ADR-0009](adr/0009-host-trait-surface.md) - that is the design that ships. Treat the macros, two-crate split, `TypedState`, `Signer`, `HostTransport` / `Provider`, and `cargo-nexum` CLI sections below as design intent, not API documentation. For the shipped surface, see [`sdk.md`](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. -> -> The split, for quick reference: -> -> | Feature | M3 status | Where | -> |---|---|---| -> | `shepherd-sdk` crate | ✅ shipped | `crates/shepherd-sdk/` | -> | `shepherd-sdk-test` crate (mock host) | ✅ shipped | `crates/shepherd-sdk-test/` | -> | Host traits (`ChainHost`, `LocalStoreHost`, `LoggingHost`) + supertrait `Host` | ✅ shipped | `crates/nexum-sdk/src/host.rs` (see ADR-0009); the CoW `CowApiHost` lives in `crates/shepherd-sdk/src/cow/` | -> | `strategy.rs` (pure logic) + `lib.rs` (wit-bindgen adapter) recipe | ✅ shipped | every M2/M3 module | -> | `Fault` + `HostFault` trait, `ChainError` (SDK-side mirror of wit) | ✅ shipped | `crates/nexum-sdk/src/host.rs` | -> | `chain` helpers (`eth_call_params`, `parse_eth_call_result`) | ✅ shipped | `crates/nexum-sdk/src/chain/`; the CoW `decode_revert_hex` lives in `crates/shepherd-sdk/src/cow/` | -> | `cow` helpers (`PollOutcome`, `RetryAction`, `classify_api_error`, `gpv2_to_order_data`, `decode_revert`, `IConditionalOrder`) | ✅ shipped | `crates/shepherd-sdk/src/cow/` | -> | `http::fetch` over wasi:http (+ `Fetch` seam, `FetchError`) | ✅ shipped | `crates/nexum-sdk/src/http.rs` | -> | `MockHost` with per-trait mocks (`MockChain`, `MockLocalStore`, `MockLogging`; CoW `MockCowApi`) | ✅ shipped | `crates/nexum-sdk-test/src/lib.rs` + `crates/shepherd-sdk-test/src/lib.rs` | -> | Separate `nexum-sdk` crate | ✅ shipped | `crates/nexum-sdk/` carries the generic surface (host seam, bind macro, chain/config/address, http, tracing); `shepherd-sdk` layers the CoW domain on top with no re-export | -> | `#[nexum::module]` / `#[shepherd::module]` proc macros | ❌ deferred (M5) | modules write `wit_bindgen::generate!` + `WitBindgenHost` adapter by hand | -> | Named event handlers (`on_block` / `on_chain_logs` / `on_tick` / `on_message` injection) | ❌ deferred (M5) | modules pattern-match on `types::Event` in `Guest::on_event` | -> | `async fn` handler support via `block_on` | ❌ deferred (M5) | strategy functions are synchronous | -> | Full alloy `Provider` via `HostTransport` | ❌ deferred (M5) | modules call `host.request(chain_id, method, params)` with JSON strings | -> | `TypedState` (postcard-backed typed local-store) | ❌ deferred (M5) | modules call `host.set(&key, &raw_bytes)` directly | -> | `Signer` (ECDSA + EIP-712 via `identity` host interface) | ❌ deferred (M5) | modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side | -> | `Cow` typed CoW Protocol API client (quote / get_order / raw_request) | ❌ deferred (M5) | `cow-api` exposes only `submit-order` today | -> | `MockIdentity`, `MockProvider`, `WasmTestHarness` | ❌ deferred (M5) | tests against `&impl Host` + per-trait mocks | -> | `cargo nexum` CLI (new / build / package / publish) | ❌ deferred (M5) | modules use `cargo build --target wasm32-wasip2` directly | -> | `block.timestamp` in ms | ✅ shipped | confirmed in `nexum:host/types` | -> -> **Reader's guide**: treat the sections below as design intent the next two milestones move toward, not API documentation for the code that exists today. For M3 API reference, see [sdk.md](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. The M3 architectural decision is captured in [ADR-0009](adr/0009-host-trait-surface.md). - -## Purpose - -The SDK is split into two layers: - -1. **`nexum-sdk`** -- the universal SDK for any `nexum:host/event-module`. It provides: - - WIT bindings (re-exported, version-pinned) - - A proc macro (`#[nexum::module]`) that eliminates boilerplate (supports `async fn` for natural `.await`) - - A full alloy `Provider` backed by the host's RPC stack (`HostTransport`) - - Typed local-store helpers (serde over raw bytes) - - A typed `Signer` for key management and signing - - Ethereum ABI helpers (alloy-sol-types integration) - - A test harness with a mock host (`MockHost`) - - A logging convenience layer - - The per-interface typed error model over the shared `Fault` vocabulary - -2. **`shepherd-sdk`** -- the CoW Protocol extension. It depends on `nexum-sdk` (modules import both directly; nothing is re-exported) and adds: - - CoW-specific WIT bindings (`shepherd:cow`) - - A typed CoW Protocol API client (`Cow`) - - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd` world - - CoW-specific mock testing utilities - -Module authors should never interact with `wit-bindgen` or the canonical ABI directly. - -## Crate Structure +# SDK Design: The Two-Persona SDK Plan + +This document describes the guest-side SDK crates and the plan that +shapes them: a **module-author persona** and a **venue-adapter +persona**, each with its own crate pair and attribute macro. The +module-author persona is shipped and is what this document mostly +describes; the venue-adapter persona is design intent tracked by a +separate epic and is called out explicitly as such wherever it +appears below. + +For the architectural decision behind the host-trait seam that the +module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). +For the rustdoc-level API reference (the source of truth once you are +writing module code), see [`sdk.md`](sdk.md) and the rustdoc under +`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and `crates/nexum-macros/`. + +## The two personas + +The runtime has two kinds of guest authors, and they need different +things from the SDK: + +1. **Module author.** Writes an automation module against + `nexum:host/event-module` (or the CoW-extended `shepherd:cow/shepherd` + world): react to blocks, chain logs, ticks, or messages; read and + write local state; submit orders. This persona is served today by + `nexum-sdk` (+ the `#[nexum::module]` macro, spelled + `#[nexum_sdk::module]` in code) and, for CoW-specific modules, + `shepherd-sdk` on top. + +2. **Venue adapter author.** Writes an adapter that exposes a trading + venue (CoW Protocol, a DEX, a lending market, ...) to modules + through a common intent surface, so a module author does not need + to know the venue's wire format. This persona is planned but not + yet shipped: the crate (`nexum-venue-sdk`), the per-venue crates + (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the + `#[nexum::venue]` macro, and the `nexum-venue-test` conformance kit + are all tracked by the SDK-surfaces epic and have no code in the + tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) + below for the shape of the plan. + +Both personas share one proc-macro crate, `nexum-macros`, and the +same host-trait philosophy: guest code is written against small Rust +traits that mirror the WIT interfaces one-for-one, so strategy logic +can be unit-tested against an in-memory mock without a `wasm32-wasip2` +toolchain or a running wasmtime instance. + +## Module-author persona (shipped): `nexum-sdk` + `shepherd-sdk` + +### Crate structure ``` nexum-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor (block_on is internal) -│ ├── bindings.rs # generated by wit-bindgen (checked in or build.rs) -│ ├── transport.rs # HostTransport -- alloy Transport impl over chain::request / chain::request-batch -│ ├── local_store.rs # typed local-store helpers -│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing) -│ ├── abi.rs # Ethereum ABI encoding/decoding -│ ├── log.rs # logging convenience -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro (async fn support) +└── src/ + ├── lib.rs # crate docs, `pub use nexum_macros::module` + ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) + ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError + ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters + ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier + ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader + ├── events.rs # native alloy Log assembly from the wire ChainLog record + ├── config.rs # (key, value) config-table lookups, decimal scaling + ├── address.rs # EVM address parsing with typed errors + ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) + ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam + └── proptests.rs # cfg(test) property tests (not part of the public surface) + +nexum-macros/ +├── Cargo.toml # proc-macro = true +└── src/ + └── lib.rs # #[module] attribute macro shepherd-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # CoW-specific prelude and API; modules import nexum-sdk directly -│ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow) -│ ├── cow.rs # Cow -- typed CoW Protocol API wrapper -│ └── testing.rs # CoW-specific mock utilities -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (CoW variant) -``` - -The workspace root `wit/nexum-host/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: - -```toml -# nexum-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/nexum-host" -``` - -```toml -# shepherd-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/shepherd-cow" -``` - -Both SDKs pin a specific `wit-bindgen` version so module authors are insulated from upstream churn. - -The crates above are the guest-side SDK. The host side ships separately as the `nexum-runtime` library plus the `nexum` binary (`crates/nexum-cli`). A Rust host embedding the runtime directly should start from `crates/nexum-runtime/examples/embed.rs` rather than the SDK. - -## The `#[nexum::module]` and `#[shepherd::module]` Macros - -### Universal: `#[nexum::module]` - -Without the macro, a module author writes (against the typed 0.2 config): - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -struct MyModule; - -impl Guest for MyModule { - fn init(config: Config) -> Result<(), Fault> { ... } - fn on_event(event: Event) -> Result<(), Fault> { - match event { - Event::Block(block) => { ... } - Event::ChainLogs(logs) => { ... } - Event::Tick(tick) => { ... } - Event::Message(msg) => { ... } - } - } -} - -export!(MyModule); -``` - -With the macro, module authors implement **named event handlers** instead. The macro generates the `on_event` match dispatch, the `Guest` trait impl, WIT bindings, and `export!`: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; - // ... - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. - -### CoW Protocol: `#[shepherd::module]` - -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: - -```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct CowTwapMonitor; - -impl CowTwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?; - // ... - Ok(()) - } -} -``` - -### What the macro generates - -For the universal `#[nexum::module]`: - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -impl Guest for TwapMonitor { - fn init(config: Config) -> Result<(), Fault> { - TwapMonitor::init(config.into()).map_err(Fault::from) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - TwapMonitor::on_block(block, &provider).await - } - Event::ChainLogs(logs) => { - let provider = nexum_sdk::provider(logs[0].chain_id); - TwapMonitor::on_chain_logs(logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }).map_err(Fault::from) - } -} - -export!(TwapMonitor); -``` - -For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow/cow-api` alongside the `nexum:host` base. - -### Named event handlers - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from the `chain-logs` batch chain id) | -| `on_tick(tick)` | `Tick` (`tick.fired_at`) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: - -- **If the second parameter is `&RootProvider`**: the macro creates `nexum_sdk::provider(chain_id)` (also for CoW modules) and passes it in. The chain_id is derived from the event payload (`block.chain_id`, `logs[0].chain_id`). -- **If no second parameter**: the macro passes only the payload. -- **Both sync and async handlers work.** Async handlers are wrapped in `block_on`; sync handlers are called directly. -- **Unimplemented handlers** become `Ok(())` -- the module only handles event types it cares about. - -### Escape hatch: `on_event` - -For modules that need custom dispatch logic, defining `on_event` directly takes precedence over named handlers: - -```rust -#[nexum::module] -struct CustomModule; - -impl CustomModule { - fn init(config: Config) -> Result<()> { Ok(()) } - - // Full control -- named handlers are ignored if on_event exists - async fn on_event(event: Event) -> Result<()> { - match event { - Event::Block(block) if block.chain_id == 42161 => { /* Arbitrum only */ } - Event::Block(_) => { /* other chains */ } - _ => {} - } - Ok(()) - } -} -``` - -Resolution order: -1. `on_event` defined -> use it directly (wrap in `block_on` if async) -2. Any of `on_block` / `on_chain_logs` / `on_tick` / `on_message` defined -> generate the match dispatch -3. Neither -> compile error - -> Full async design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md#eliminating-block_on-async-module-functions) - -## Prelude - -### Universal: `nexum_sdk::prelude` - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; - -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` - -One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `Signer` for signing. - -`block_on` is no longer a public re-export in 0.2 -- it's hidden behind the `#[nexum::module]` macro. See the [migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) for the full SDK rename table. - -### CoW Protocol: `shepherd_sdk::prelude` - -```rust -// shepherd_sdk::prelude -- CoW-specific items only; no nexum-sdk re-export -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` - -CoW module authors write `use nexum_sdk::prelude::*;` alongside `use shepherd_sdk::prelude::*;` -- the CoW prelude adds only the merged CoW `cow-api` interface and the typed `Cow` client. - -## Typed Local-Store Helpers - -Raw local-store is `string -> list`. The SDK adds a typed layer using serde: - -```rust -use nexum_sdk::prelude::*; -use serde::{Serialize, Deserialize}; - -#[derive(Serialize, Deserialize)] -struct TwapProgress { - last_block: u64, - posted_parts: Vec<[u8; 32]>, -} - -// Read typed value -let progress: Option = TypedState::get("progress")?; - -// Write typed value -TypedState::set("progress", &TwapProgress { - last_block: 19_000_001, - posted_parts: vec![part_hash], -})?; - -// Delete -TypedState::delete("progress")?; - -// List keys by prefix -let keys: Vec = TypedState::list_keys("orders/")?; -``` - -Implementation: - -```rust -pub struct TypedState; - -impl TypedState { - pub fn get(key: &str) -> Result> { - match local_store::get(key)? { - Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)), - None => Ok(None), - } - } - - pub fn set(key: &str, value: &T) -> Result<()> { - let bytes = postcard::to_allocvec(value)?; - local_store::set(key, &bytes)?; - Ok(()) - } - - pub fn delete(key: &str) -> Result<()> { - local_store::delete(key)?; - Ok(()) - } - - pub fn list_keys(prefix: &str) -> Result> { - Ok(local_store::list_keys(prefix)?) - } -} -``` - -Serialisation uses **postcard** (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead. - -## Signer - -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: - -```rust -use nexum_sdk::prelude::*; - -// Get available signing accounts -let accounts = Signer::accounts()?; -for account in &accounts { - info!("available signer: 0x{}", hex::encode(account)); -} - -// Sign raw bytes with a specific account -let signature = Signer::sign(&accounts[0], &data_to_sign)?; -// signature is 65 bytes: r (32) || s (32) || v (1) - -// Sign EIP-712 typed data -let typed_data_json = r#"{"types":...,"primaryType":"Order","domain":...,"message":...}"#; -let signature = Signer::sign_typed_data(&accounts[0], typed_data_json)?; -``` - -Implementation: - -```rust -/// Typed client for the identity WIT interface. -/// -/// Provides cryptographic signing operations backed by the host engine's -/// key management. The host manages private keys -- modules never see them. -pub struct Signer; - -impl Signer { - /// Get available signing accounts (20-byte Ethereum addresses). - pub fn accounts() -> Result>> { - identity::accounts().map_err(Fault::from) - } - - /// Get available signing accounts as alloy `Address` types. - pub fn addresses() -> Result> { - let accounts = Self::accounts()?; - accounts - .into_iter() - .map(|a| { - Address::try_from(a.as_slice()) - .map_err(|_| Fault::InvalidInput("invalid address length".into())) - }) - .collect() - } - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign(account: &[u8], data: &[u8]) -> Result> { - identity::sign(account, data).map_err(Fault::from) - } - - /// Sign EIP-712 typed data with the specified account. - /// `typed_data` is a JSON string conforming to the EIP-712 specification. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign_typed_data(account: &[u8], typed_data: &str) -> Result> { - identity::sign_typed_data(account, typed_data).map_err(Fault::from) - } -} -``` - -Note: modules can also use `identity` indirectly through `chain`. When a module calls `chain::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `chain` implementation delegates to the `identity` backend internally. `Signer` is for modules that need direct, raw signing operations -- e.g. EIP-712 over an off-chain order payload. - -Modules can match on `Fault::Denied` to distinguish "user rejected" from a transport failure -- see the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping table. - -## Ethereum ABI Helpers & alloy Provider - -Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `chain::request` / `chain::request-batch`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): - -```rust -use nexum_sdk::prelude::*; - -// Define the contract interface -sol! { - function getTradeableOrderWithSignature( - address owner, - bytes32 ctx, - bytes32 orderHash - ) external view returns ( - bytes memory order, - bytes memory signature - ); -} - -// Named handler -- provider is injected by the macro -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API -- natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner_addr).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract_addr) - .input(getTradeableOrderWithSignatureCall { - owner: owner_addr, - ctx: ctx_bytes, - orderHash: order_hash, - }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - let order_bytes = decoded.order; - Ok(()) -} -``` - -The SDK re-exports: -- `alloy_primitives::{Address, B256, U256, Bytes}` -- core Ethereum types. -- `alloy_sol_types::sol!` -- compile-time ABI codec generation. -- `alloy_provider::Provider` -- the full alloy Provider trait. -- `alloy_rpc_types::*` -- `TransactionRequest`, `Filter`, `Block`, etc. - -These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the `chain::request` (and batched `chain::request-batch`) host functions -- see doc 07 for the full design. - -## CoW Protocol API: `Cow` - -The `cow-api` WIT interface (in `shepherd:cow`) exposes a REST passthrough to the CoW Protocol API plus a typed `submit-order` function (the two were separate interfaces, `cow` and `order`, in 0.1). The `shepherd-sdk` wraps this with a typed `Cow` client: - -```rust -use shepherd_sdk::prelude::*; - -let cow = Cow::new(42161); - -// Submit an order via the merged cow-api interface -let uid = cow.submit_order(&OrderCreation { - sell_token: sell_addr, - buy_token: buy_addr, - sell_amount: U256::from(1_000_000), - buy_amount: U256::from(950_000), - kind: OrderKind::Sell, - valid_to: (block.timestamp / 1000) + 300, // block.timestamp is ms in 0.2 - ..Default::default() -})?; - -// Get a quote -let quote = cow.get_quote(&OrderQuoteRequest { ... })?; - -// Get an order by UID -let order = cow.get_order(&uid)?; - -// Raw request for endpoints not yet wrapped -let resp = cow.raw_request("GET", "/api/v1/auction", None)?; -``` - -The `Cow` client handles JSON serialisation and routes requests through the host's `cow-api::request` (REST passthrough) and `cow-api::submit-order` (order submission) functions. - -## Logging Convenience - -Wrappers over the `logging` WIT interface (provided in `nexum-sdk`; CoW modules import them from `nexum-sdk` directly): - -```rust -// nexum_sdk::log -pub fn trace(msg: &str) { logging::log(Level::Trace, msg); } -pub fn debug(msg: &str) { logging::log(Level::Debug, msg); } -pub fn info(msg: &str) { logging::log(Level::Info, msg); } -pub fn warn(msg: &str) { logging::log(Level::Warn, msg); } -pub fn error(msg: &str) { logging::log(Level::Error, msg); } - -/// Format + log in one call -#[macro_export] -macro_rules! info { - ($($arg:tt)*) => { - $crate::log::info(&format!($($arg)*)) - }; -} -``` - -Usage: - -```rust -nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id); -``` - -## Error Handling - -In 0.2 each interface declares its own typed error, and they share one payload-bearing `Fault` vocabulary for the cross-domain cases. The SDK exposes `Fault`, the `HostFault` trait (recovers an embedded fault plus a stable snake_case label), and the richer `ChainError`. A `From for Fault` fold lets a strategy aggregating store and chain calls `?`-propagate into one `Fault`. - -```rust -pub enum Fault { - Unsupported(String), - Unavailable(String), - Denied(String), - RateLimited(RateLimit), // { retry_after_ms: Option } - Timeout, - InvalidInput(String), - Internal(String), -} - -/// Recovers the shared fault from a richer, per-interface error, plus a -/// stable snake_case label for logs and metrics. -pub trait HostFault { - fn fault(&self) -> Option<&Fault>; - fn label(&self) -> &'static str; -} - -/// The chain interface embeds `Fault` and adds a structured JSON-RPC case. -pub enum ChainError { - Fault(Fault), - Rpc(RpcError), // { code: i32, message: String, data: Option> } -} - -pub type Result = core::result::Result; -``` - -Interfaces with nothing to add report `Fault` directly (identity, local-store, remote-store, messaging, and the module exports). The module exports return `Result<(), Fault>`; module-defined failures are plain `Fault` cases, and the supervisor supplies the module name and derives its log kind from the fault label. Module authors use `?` naturally, and match on the case (or on `ChainError::Rpc` for a revert) for retry/backoff: - -```rust -// Universal module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // ChainError -> Fault via From - let decoded = MyCall::abi_decode_returns(&data) - .map_err(|e| Fault::InvalidInput(e.to_string()))?; // module-defined - TypedState::set("last", &decoded)?; // store Fault - let sig = Signer::sign(&account, &data)?; // identity Fault - Ok(()) -} - -// Inspecting the case for retry decisions -match host.request(chain_id, "eth_blockNumber", "[]") { - Ok(n) => Ok(n), - Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), - Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), - Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // structured revert - Err(e) => Err(e.into()), -} - -// CoW module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // chain error - TypedState::set("last", &num)?; // store Fault - Cow::new(block.chain_id).submit_order(&order)?; // cow-api-error - Ok(()) -} -``` - -See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to `fault` cases. - -## Testing Framework - -### Universal: `nexum-sdk` Mock Host - -The `nexum-sdk` provides a mock host so modules can be tested without a live blockchain or runtime. +└── src/ + ├── lib.rs # crate docs; no re-export of nexum-sdk + ├── prelude.rs # cowprotocol order/signing/orderbook re-exports + ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost + ├── cow/ # CowApiHost trait, gpv2_to_order_data, Verdict, LegacyRevertAdapter, + │ # RetryAction classifiers, run() (poll -> gate/journal/submit) + └── proptests.rs # cfg(test) property tests (not part of the public surface) +``` + +`nexum-sdk` is host-neutral and domain-free: any module targeting the +runtime pulls helpers and canonical primitive types from it regardless +of which world it exports. `shepherd-sdk` depends on `nexum-sdk` and +layers the CoW Protocol domain on top; modules that touch the +orderbook import both crates directly (nothing is re-exported between +them). `shepherd-sdk` has not been retired - the clean break described +in the SDK epic (folding its CoW surface into a future `cow-venue` +crate) is deferred to a follow-on train, sequenced after the +venue-adapter persona lands. + +Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over +`ChainHost` / `LocalStoreHost` / `LoggingHost`) and `shepherd-sdk-test` +(composes those mocks with `MockCowApi`). See +[Testing](#testing-nexum-sdk-test-and-shepherd-sdk-test) below. + +### The host-trait seam + +Neither crate calls `wit_bindgen`-generated functions directly. +Instead `nexum-sdk::host` exposes small traits that mirror the WIT +interfaces: ```rust -use nexum_sdk::testing::{MockHost, MockChain}; - -#[test] -fn test_monitor_processes_block() { - let mut host = MockHost::new(); - - // Set up mock chain state - host.chain(42161) - .block_number(19_000_001) - .mock_call( - "0xfdaFc9d...", // contract address - &get_active_orders_calldata(), // expected calldata - &mock_orders_response(), // return value - ); - - // Pre-populate local-store (simulating previous run) - host.local_store() - .set("last_block", &19_000_000u64.to_le_bytes()); - - // Dispatch a block event - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, // ms since epoch - })); - - assert!(result.is_ok()); - - // Verify local-store was updated - let last = host.local_store().get::("last_block").unwrap(); - assert_eq!(last, Some(19_000_001)); +pub trait ChainHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -``` - -### Identity Mocking - -The `MockHost` supports identity mocking for modules that use signing: - -```rust -use nexum_sdk::testing::{MockHost, MockIdentity}; - -#[test] -fn test_module_signs_data() { - let mut host = MockHost::new(); - - // Configure mock identity with test accounts - host.identity() - .add_account(hex::decode("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap()) - .on_sign(|account, data| { - // Return a mock 65-byte signature - Ok(vec![0u8; 65]) - }) - .on_sign_typed_data(|account, typed_data| { - // Return a mock 65-byte signature for EIP-712 - Ok(vec![0u8; 65]) - }); - - let result = host.dispatch(Event::Block(Block { - chain_id: 1, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1700000000, - })); - - assert!(result.is_ok()); - - // Verify signing was called - assert_eq!(host.identity().sign_calls().len(), 1); +pub trait LocalStoreHost { + fn get(&self, key: &str) -> Result>, Fault>; + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; + fn delete(&self, key: &str) -> Result<(), Fault>; + fn list_keys(&self, prefix: &str) -> Result, Fault>; } -``` - -### CoW Protocol: `shepherd-sdk` Mock Extensions - -The `shepherd-sdk` extends `MockHost` with CoW-specific assertions: - -```rust -use shepherd_sdk::testing::{MockHost, MockCow}; - -#[test] -fn test_twap_monitor_submits_order() { - let mut host = MockHost::new(); - - host.chain(42161).block_number(19_000_001); - - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - - assert!(result.is_ok()); - - // Verify an order was submitted (CoW-specific) - assert_eq!(host.submitted_orders().len(), 1); +pub trait LoggingHost { + fn log(&self, level: Level, message: &str); } -``` - -### MockHost Internals +pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} +impl Host for T {} +``` + +`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`, +`cow_api_request`), and +its own supertrait `CowHost: Host + CowApiHost`. Strategy code takes +`&impl Host` (or a narrower `` bound +when it only needs part of the surface) so tests inject +`nexum_sdk_test::MockHost` while the compiled module injects the +wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) +for the full rationale (four traits over one fat trait, the +`strategy.rs` / `lib.rs` split, and the world-neutral `HostError` +predecessor that per-interface typed errors later replaced - see +[ADR-0011](adr/0011-per-interface-typed-errors.md)). + +### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` + +Every module still keeps its own `wit_bindgen::generate!` call (the +macro emits types into the calling crate; re-exporting wit-bindgen +output from a library crate would duplicate symbols and break the +component-export contract). What the SDK removes is the ~80 lines of +mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` +declarative macro emits a `WitBindgenHost` struct, the `ChainHost` / +`LocalStoreHost` / `LoggingHost` impls over the generated import +shims, the `Fault` / `ChainError` converters in both directions, a +`Level` <-> wit-bindgen `logging::Level` converter, a +`From for nexum_sdk::events::Log` impl, and an +`install_tracing()` helper that routes `tracing::info!(...)` through +the bound host logging call. The adapter is capability-selected: the +zero-argument form emits the full set for blanket-world modules, and +the `caps: [chain, logging]` form (what `#[nexum_sdk::module]` +generates from the manifest) emits only the pieces whose imports the +module's world carries. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` +layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. + +### The `#[nexum::module]` macro + +`nexum-macros` ships one attribute macro, re-exported as +`nexum_sdk::module`. Apply it to an inherent `impl` block whose +methods are named event handlers - `init`, `on_block`, +`on_chain_logs`, `on_tick`, `on_message` - and the macro reads the +crate's `module.toml`, synthesizes the per-module world from its +`[capabilities]`, and generates the `wit_bindgen::generate!` call for +that world, the capability-selected `bind_host_via_wit_bindgen!` +invocation, a `Guest` implementation whose `on_event` dispatches to +whichever handlers are present (absent handlers become a no-op for +that event), and `export!`: ```rust -// nexum-sdk: universal mock host -pub struct MockHost { - local_store: HashMap>, - chains: HashMap, - identity: MockIdentity, - logs: Vec<(Level, String)>, -} - -pub struct MockChain { - block_number: u64, - /// Maps (method, params_json) -> result_json for RPC mocking - rpc_mocks: HashMap<(String, String), String>, - logs: Vec, -} - -pub struct MockIdentity { - accounts: Vec>, - sign_handler: Option Result>>>, - sign_typed_data_handler: Option Result>>>, - sign_calls: Vec<(Vec, Vec)>, - sign_typed_data_calls: Vec<(Vec, String)>, -} - -// shepherd-sdk: extends with CoW-specific fields -pub struct CowMockHost { - inner: MockHost, // universal mock - submitted_orders: Vec<(u64, Vec)>, - cow_requests: Vec<(u64, String, String, Option)>, -} -``` +// modules/examples/http-probe/src/lib.rs (shipped) +mod strategy; -The mock host implements the same trait interface as the real host. Tests run as native Rust (not compiled to WASM) -- the mock substitutes for the WIT imports. +use nexum::host::types; -For alloy `Provider`-based tests, the `nexum-sdk` also provides `MockProvider` (backed by alloy's `Asserter`-based mock transport) -- see doc 07 for details. +struct HttpProbe; -### Integration Testing - -For tests that compile to WASM and run in a real wasmtime instance: - -```rust -use nexum_sdk::testing::WasmTestHarness; - -#[test] -fn test_module_as_component() { - let harness = WasmTestHarness::new("target/wasm32-wasip2/release/twap_monitor.wasm"); - - harness.mock_chain(42161).block_number(100); - harness.call_init(vec![("api_url".into(), "mock".into())]).unwrap(); - - let result = harness.call_on_event(Event::Block(Block { - chain_id: 42161, - number: 100, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - assert!(result.is_ok()); -} -``` - -This tests the full component boundary (canonical ABI marshalling, host function binding). - -## Project Scaffolding - -### `cargo-nexum` CLI - -> **Future direction, not in 0.2 scope.** The `cargo-nexum` cargo subcommand described in this section does not ship in 0.2. Module authors today build with `cargo build --target wasm32-wasip2 --release` (the M5 reference repo includes a `justfile` with the canonical recipes). A `cargo-nexum` (or successor) scaffolding/packaging CLI is on the 0.3 roadmap. -> -> **Two separate tools (design intent):** `cargo-nexum` would be a cargo subcommand for **module authors** (new, build, package, publish). The `nexum` binary is the **operator runtime** (run, module list/restart, local-store purge). Embedders bypass the binary entirely and drive the runtime through the `nexum-runtime` library. - -```bash -cargo nexum new my-module -``` - -Generates: - -``` -my-module/ -├── Cargo.toml -├── module.toml # manifest template -└── src/ - └── lib.rs # minimal module skeleton -``` - -#### Universal module (targeting `nexum:host/event-module`) - -`Cargo.toml`: -```toml -[package] -name = "my-module" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + // ... Ok(()) } - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) + fn on_block(block: types::Block) -> Result<(), Fault> { + strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + .map_err(sdk_fault_into_wit) } } ``` -#### CoW Protocol module (targeting `shepherd:cow/shepherd`) - -`Cargo.toml`: -```toml -[package] -name = "my-cow-module" -version = "0.1.0" -edition = "2024" +Two things worth being precise about, since they differ from earlier +drafts of this plan: + +- **One macro, not two.** There is no separate `#[shepherd::module]`. + The macro reads the crate's `module.toml` and generates against a + per-module world whose imports are exactly the + `[capabilities].required`/`optional` declarations (a chain + + local-store module simply has no `cow-api` or `identity` bindings to + call). This retires the import-elision dependency ADR-0009 flagged + for macro-built modules: their imports equal their declarations by + construction, and the runtime's capability check is a backstop + rather than a consumer of toolchain dead-import elision. Declaring + `cow-api` (or `pool`) pulls that import into the world, and the + module layers its own domain adapter (a `CowApiHost` impl over the + generated shims) on top of the emitted core one. +- **Handlers are synchronous.** `init` and the named handlers are + plain `fn`, called directly with no `block_on` wrapper. There is no + `async fn` handler support and no injected `&RootProvider` - modules + call `host.request(chain_id, method, params_json)` (or the + `chain::eth_call_params` / `parse_eth_call_result` helpers) directly + against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). + +The `Guest`/`export!` shape the macro emits still follows the +`strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` +(handlers plus the macro attribute) split from ADR-0009. The keeper +helpers in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, +`ConditionalSource`, `Retrier` - give conditional-commitment +modules (watchers that poll a set of pending commitments) a shared set +of `LocalStoreHost` conventions instead of hand-rolled key schemes. + +### Testing: `nexum-sdk-test` and `shepherd-sdk-test` -[lib] -crate-type = ["cdylib"] - -[dependencies] -shepherd-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: ```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct MyCowModule; - -impl MyCowModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); - Ok(()) - } - - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` - -`module.toml` (same for both): -```toml -[module] -name = "my-module" -version = "0.1.0" -description = "" -authors = [] -component = "sha256:TODO" - -[module.resources] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -max_state_bytes = 52_428_800 - -[module.restart] -max_consecutive_failures = 10 - -[chains] -required = [] -optional = [] - -[capabilities] -required = ["chain", "local-store", "logging"] -optional = [] - -[config] -``` - -### Build - -```bash -cargo component build --release -# -> target/wasm32-wasip2/release/my_module.wasm -``` - -### Package - -```bash -cargo nexum package -# Computes sha256, updates module.toml, creates bundle directory -``` - -### Publish to Swarm - -```bash -cargo nexum publish --swarm http://localhost:1633 --batch-id -# Uploads bundle to Swarm, prints content reference -``` - -## SDK / Runtime Version Compatibility - -The WIT definition is versioned (`nexum:host@0.2.0`). The SDK pins this version. When the WIT evolves: - -- **Patch** (0.2.x): backwards-compatible additions (new host functions, new manifest fields, new SDK helpers). Old modules continue to work. -- **Minor** (0.x.0): may add new required exports. Old modules need recompilation. -- **Major** (x.0.0): breaking changes. Runtime supports multiple world versions during transition. - -The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.2.1` satisfies a guest compiled against `@0.2.0`. - -0.2 is the coordinated breaking-change window relative to 0.1. The 0.2.0 contracts (WIT package name, interface names, the per-interface typed errors over the shared `fault` vocabulary, the `module.toml` schema, the `#[nexum::module]` macro surface) are stable starting at 0.2.0 -- see the [migration guide §10](migration/0.1-to-0.2.md#10-deprecation-policy-going-forward-both) for the full deprecation policy. - -## Summary - -| SDK Layer | Provides | -|-----------|----------| -| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`); `async fn` + provider injection (universal) | -| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd` world | -| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` (including 0.2's `chain::request-batch` for real wire-level batching) | -| `Signer` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | -| `Cow` | Typed CoW Protocol API client backed by host `cow-api` interface (shepherd-sdk only) | -| `nexum_sdk::prelude::*` | Universal types, interfaces, alloy re-exports in one import | -| `shepherd_sdk::prelude::*` | CoW-specific types and interfaces; the universal surface comes from `nexum_sdk::prelude::*` | -| `TypedState` | Serde-based typed local-store over raw bytes | -| `sol!` | Compile-time Ethereum ABI codec (alloy-sol-types) | -| `log::{info!, ...}` | Formatted logging macros | -| `Fault` / `HostFault` / `ChainError` / `Result` | Per-interface typed errors over the shared `fault` vocabulary, with `?` support and case-based matching | -| `nexum_sdk::testing::MockHost` | Native-Rust unit tests with universal mock host (includes identity mocking) | -| `shepherd_sdk::testing::MockHost` | Extends universal mock with CoW-specific assertions | -| `testing::MockProvider` | alloy `Provider` mock for RPC-level testing | -| `testing::WasmTestHarness` | Integration tests against real wasmtime | -| `cargo nexum` | new / build / package / publish / check / migrate CLI | +use nexum_sdk::host::*; +use nexum_sdk_test::MockHost; + +let host = MockHost::new(); +host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + +assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); +assert_eq!(host.chain.calls().len(), 1); +``` + +`MockHost` composes one mock per trait (`chain`, `store`, `logging` +in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on the +`shepherd:cow/cow-api` seam, backed by `MockCowApi` by default or the +per-call-scriptable `MockVenue` via `MockHost::with_venue()`), each +recording calls and letting tests program responses. Tests run as +plain native Rust against the traits - no `wasm32-wasip2` target, no +wasmtime instance, no network round-trip. This is the whole SDK-side +testing story today. (The runtime crate separately ships a +feature-gated component-level harness - `nexum-runtime`'s +`test_utils::TestRuntime`, behind the `test-utils` feature - that +loads a compiled `.wasm` plus manifest under real wasmtime and +dispatches events to it; that is runtime-internal tooling, not part +of the module-author SDK contract.) + +## Venue-adapter persona (planned) + +The venue-adapter persona is the other half of the two-persona plan +and is **not shipped**. It is tracked by a set of open issues under +the SDK-surfaces epic and depends on a venue-adapter WIT world that +does not exist yet either. Nothing below this heading describes code +in this repository; it is recorded here so the module-author persona +above is read in the context of where the SDK is going, not as a +competing vision. + +The planned shape: + +- **`nexum-venue-sdk`** - a new crate carrying the guest-side + `VenueAdapter` trait over the (also planned) adapter-world bindgen, + a `borsh`-backed `IntentBody` derive that enforces a per-venue + version enum (an adapter rejects an intent body tagged with an + unknown version rather than misinterpreting it), and typed wrappers + over the scoped transport imports (`http`, `messaging`, `chain`) an + adapter is granted. +- **Per-venue crates** - e.g. a `cow-venue` crate that would carry + CoW Protocol's intent-body codec and become the eventual home for + the CoW helpers `shepherd-sdk::cow` carries today, once the clean + break happens. +- **`#[nexum::venue]`** - a second attribute macro in `nexum-macros`, + parallel to `#[nexum::module]`: it would emit the per-cdylib export + glue for an adapter and a per-component world matching the + manifest's declared capabilities (retiring the import-elision + dependency for the venue side from day one, rather than as + follow-on work). +- **`nexum-venue-test`** - a conformance kit: published codec + round-trip vectors (so a non-Rust adapter author can prove + byte-exact `IntentBody` encoding without linking Rust), header- + derivation golden fixtures, and a `MockTransport` for adapter unit + tests. + +Once this lands, `shepherd-sdk`'s CoW surface is expected to move into +the `cow-venue` crate as a single clean-break migration - the same +no-deprecation-window reasoning [ADR-0011](adr/0011-per-interface-typed-errors.md) +gives for pre-1.0 wire breaks applies here - and this document should +be revisited to describe the venue-adapter persona as shipped rather +than planned. + +## Non-Rust module and adapter authors + +For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is +relevant - they generate bindings directly from the WIT package for +their target world with their language's `wit-bindgen`. The WIT is +the universal contract; both Rust SDKs are an ergonomics layer on top +of it, not a requirement. + +## Where to go next + +- [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry + point for module authors. +- [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam + decision this document builds on. +- [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed + error model (`Fault`, `ChainError`, `CowApiError`) the host traits + return. +- [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - + what changed in the SDK surface between 0.1 and 0.2. +- [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough + design and why module authors call `host.request` directly rather + than through an injected provider. diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index d0391e04..30c1f8b7 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -987,7 +987,7 @@ graph TD - **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`Verdict`, `RetryAction`, `gpv2_to_order_data`, `LegacyRevertAdapter`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. diff --git a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md index c8ba48b4..c78d14c6 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -36,3 +36,9 @@ This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream P - Bumping the rev is a single-line workspace edit; reviewers see one diff per primitive added to PR #5. - Drop the patch entirely once a published `cowprotocol` release contains both the alpha.3 follow-ups and the ADR-0007 protocol-primitive additions (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, `wasm32` feature-gate). Until then, expect the patch rev to advance with every push to PR #5. - Modules built against this workspace inherit the patch transitively; modules built standalone against crates.io will see `alpha.3` and may hit the very bugs the patch closes. Flag this in the SDK README when M3 lands. + +## Addendum (2026-07): patch channel moved to nullislabs/cow-rs + +The patch target has since moved from `bleu/cow-rs` to `https://github.com/nullislabs/cow-rs` (rev `17fc0c5`). The fork carries two changes the workspace needs ahead of a published `cowprotocol` 0.2.0: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and the WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) has neither. + +The decision and its consequences are otherwise unchanged: one workspace-level `[patch.crates-io]` line, advanced by bumping the rev, dropped entirely once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the workspace `Cargo.toml` states the current drop condition. diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index ef4aae32..fde9b463 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -96,6 +96,8 @@ This interacts with `wit_bindgen::generate!` in a way worth pinning here, becaus **Hardening planned for M5** (recorded here, NOT a 0.2 deliverable): generate a per-module world (`shepherd:cow/price-alert`, etc.) that only re-exports the capabilities the module declares. The M5 `#[nexum::module]` macro is the natural place to derive this world from the manifest. Eliminates the elision dependency. -Until then, **a module that adds an import of an undeclared capability will fail capability enforcement at boot**, not at compile time. This is the intended behaviour - the alternative would be to widen the supertype world or to make enforcement lenient, both of which would damage least-privilege. +**Update: the hardening shipped** (earlier than planned, in the M1 SDK-surfaces work). `#[nexum_sdk::module]` derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction, an undeclared capability is a compile-time error (its bindings do not exist), and `enforce_capabilities` is a backstop rather than an elision consumer. The paragraphs above stay accurate for hand-rolled modules still compiled against the supertype world (twap-monitor, ethflow-watcher, stop-loss). + +Until those migrate, **a hand-rolled module that adds an import of an undeclared capability will fail capability enforcement at boot**, not at compile time. This is the intended behaviour - the alternative would be to widen the supertype world or to make enforcement lenient, both of which would damage least-privilege. _Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ diff --git a/docs/design/apply/apply-complete.sh b/docs/design/apply/apply-complete.sh new file mode 100755 index 00000000..2227fe5d --- /dev/null +++ b/docs/design/apply/apply-complete.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ------------------------------------------------------------------ 0. config + guard +REPO="nullislabs/shepherd" +OWNER="nullislabs" +PROJECT="1" +PROJECT_ID="PVT_kwDODm2Wqs4BcJ3a" + +COMPONENT_FIELD="PVTSSF_lADODm2Wqs4BcJ3azhW0t-M" +STATUS_FIELD="PVTSSF_lADODm2Wqs4BcJ3azhW0t9I" +STATUS_TODO="f75ad846" + +# per-issue body files live in bodies/.md next to this script +BODIES="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bodies" + +DRY_RUN="${DRY_RUN:-1}" +run(){ if [ "$DRY_RUN" = 1 ]; then echo "+ $*"; else echo ">> $*"; eval "$*"; fi; } + +# per-key state captured at create/ensure time (declared early: the sub-issue helpers read it) +declare -A NUM ID ITEM + +# --- native sub-issue helpers (used from step 2 onward). KEY refs resolve via NUM/ID; #NNN via cache or REST. +resolve_num(){ local r="$1"; if [[ "$r" == \#* ]]; then echo "${r#\#}"; else echo "${NUM[$r]}"; fi; } +resolve_id(){ + local r="$1" + if [[ "$r" == \#* ]]; then + if [ -n "${ID[$r]:-}" ]; then echo "${ID[$r]}" + elif [ "$DRY_RUN" = 1 ]; then echo "" + else gh api "repos/$REPO/issues/${r#\#}" -q .id; fi + else echo "${ID[$r]}"; fi +} +# link_sub PARENT_REF CHILD_REF (native sub-issue; detach any existing DIFFERENT parent first, then attach) +link_sub(){ + local p c cn; p="$(resolve_num "$1")"; c="$(resolve_id "$2")"; cn="$(resolve_num "$2")" + if [ "$DRY_RUN" != 1 ] && [[ "$cn" =~ ^[0-9]+$ ]]; then + local pp + pp="$(gh api graphql -f query="query{repository(owner:\"$OWNER\",name:\"shepherd\"){issue(number:$cn){parent{number}}}}" -q '.data.repository.issue.parent.number' 2>/dev/null || echo "")" + if [ -n "$pp" ] && [ "$pp" != "null" ] && [ "$pp" != "$p" ]; then + echo ">> detach #$cn from current parent #$pp before re-parent to #$p" + gh api -X DELETE "repos/$REPO/issues/$pp/sub_issue" -F sub_issue_id="$c" >/dev/null 2>&1 || true + fi + fi + run "gh api repos/$REPO/issues/$p/sub_issues -F sub_issue_id=$c" +} +# unlink_sub OLD_PARENT_REF CHILD_REF (remove a stale parent link before re-parenting) +unlink_sub(){ + local p c; p="$(resolve_num "$1")"; c="$(resolve_id "$2")" + run "gh api -X DELETE repos/$REPO/issues/$p/sub_issue -F sub_issue_id=$c" +} + +# --- Project Component field: the 12 options (name -> single-select option id) +declare -A COMP_OPT=( + [Engine]=6f70e61b [Runtime/Lifecycle]=b73f2d05 [Host Backends]=443041f1 [Chain]=c11b797e + [Storage]=a2eb71e6 [WIT/ABI]=e077d895 [CoW]=63aaa607 [SDK/DX]=74e5d4be [Modules]=6ef6a455 + [Observability]=7e739f27 [Tooling/Packaging]=461d450e [Docs]=526e258a +) +# --- matching component/* label per Component (Docs carries no component label; the docs kind suffices) +declare -A COMP_LABEL=( + [Engine]=component/engine-runtime [Runtime/Lifecycle]=component/lifecycle [Host Backends]=component/engine-host + [Chain]=component/chain [Storage]=component/local-store [WIT/ABI]=component/wit-abi [CoW]=component/cow-integration + [SDK/DX]=component/sdk [Modules]=component/modules [Observability]=component/observability + [Tooling/Packaging]=component/tools [Docs]="" +) + +# --- clean milestone titles (nine-milestone scheme; every issue/epic uses these) +M0="M0: Runtime architecture and lifecycle" +M1="M1: Videre contract reshape and host-intent decoupling" +M2="M2: Generic venue-agnostic host" +M3="M3: Videre SDK, macros and DX" +M4="M4: CoW on the generic seam (the shepherd bundle)" +M5="M5: The gated three-repo split" +M6="M6: Second-venue acceptance and vocabulary freeze" +M7="M7: Egress guard" +M8="M8: Post-v1 hardening and debt" +# create_issue KEY TITLE SUMMARY MILESTONE KIND_LABELS_CSV COMPONENT [BLOCKED_BY_TEXT] +# creates the issue, captures NUM+REST id, adds to Project #1, sets Component + Status=Todo. +# The component/* label is appended automatically from COMPONENT (Docs adds none). +create_issue(){ + local key="$1" title="$2" summary="$3" ms="$4" kinds="$5" comp="$6" blk="${7:-}" + local clabel="${COMP_LABEL[$comp]:-}" + local bodyfile="$BODIES/$key.md" + if [ ! -f "$bodyfile" ]; then echo "FATAL: missing body file $bodyfile (issue key '$key')" >&2; exit 1; fi + local largs=() + local IFS=','; local l + for l in $kinds; do [ -n "$l" ] && largs+=(--label "$l"); done + unset IFS + [ -n "$clabel" ] && largs+=(--label "$clabel") + + if [ "$DRY_RUN" = 1 ]; then + echo "+ gh issue create --repo $REPO --title '$title' --milestone '$ms' ${largs[*]} --body-file bodies/$key.md [key=$key]" + echo "+ project item-add + Component='$comp'(${COMP_OPT[$comp]}) + Status=Todo [key=$key]" + NUM[$key]="<$key>"; ID[$key]=""; ITEM[$key]="" + return + fi + local url num id item + url="$(gh issue create --repo "$REPO" --title "$title" --milestone "$ms" "${largs[@]}" --body-file "$bodyfile")" + num="${url##*/}" + id="$(gh api "repos/$REPO/issues/$num" -q .id)" + NUM[$key]="$num"; ID[$key]="$id" + echo ">> created #$num [$key] $url" + item="$(gh project item-add "$PROJECT" --owner "$OWNER" --url "$url" --format json -q .id)" + ITEM[$key]="$item" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$COMPONENT_FIELD" --single-select-option-id "${COMP_OPT[$comp]}" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$STATUS_FIELD" --single-select-option-id "$STATUS_TODO" +} + +# ensure_existing #NUM MILESTONE(""=leave) COMPONENT [ADD_LABEL] +# moved-in existing issue: set milestone, add component label, ensure Project #1 membership + Component field. +ensure_existing(){ + local ref="$1" ms="$2" comp="$3" addlbl="${4:-}" + local num="${ref#\#}" clabel="${COMP_LABEL[$comp]:-}" + local eargs="gh issue edit $num --repo $REPO" + [ -n "$ms" ] && eargs="$eargs --milestone '$ms'" + [ -n "$clabel" ] && eargs="$eargs --add-label $clabel" + [ -n "$addlbl" ] && eargs="$eargs --add-label $addlbl" + run "$eargs" + if [ "$DRY_RUN" = 1 ]; then + echo "+ project item-add #$num + Component='$comp'(${COMP_OPT[$comp]})" + ID["#$num"]="" + return + fi + local id item + id="$(gh api "repos/$REPO/issues/$num" -q .id)"; ID["#$num"]="$id" + item="$(gh project item-add "$PROJECT" --owner "$OWNER" --url "https://github.com/$REPO/issues/$num" --format json -q .id)" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$COMPONENT_FIELD" --single-select-option-id "${COMP_OPT[$comp]}" +} + +# ensure_epic #NUM MILESTONE COMPONENT [TITLE] +# reused existing epic: add `epic` label + milestone + component + project; optional retitle. +ensure_epic(){ + local ref="$1" ms="$2" comp="$3" title="${4:-}" + [ -n "$title" ] && run "gh issue edit ${ref#\#} --repo $REPO --title '$title'" + ensure_existing "$ref" "$ms" "$comp" epic +} + +# ---- resume preload: the M1..M5 issues (#359..#409) were already created on the first run. +# Only epic-m4-operator-delivery (#409) is referenced as a parent by the remaining ops, so preload it. +NUM[epic-m4-operator-delivery]=409 +ID[epic-m4-operator-delivery]="$(gh api repos/$REPO/issues/409 -q .id)" +echo ">> resume: preloaded epic-m4-operator-delivery = #409 (id ${ID[epic-m4-operator-delivery]})" +echo; echo "## completing from M5 #124 through M8 (first run aborted here on the #124->#127 parent clash)" +ensure_existing "#124" "$M5" "Docs"; link_sub epic-m4-operator-delivery "#124" + +# ---- M6 : reused #140 (second-venue acceptance + freeze) +echo; echo "### M6 -- existing #140 (second-venue acceptance and vocabulary freeze)" +ensure_epic "#140" "$M6" "SDK/DX" "sdk: prove venue-neutrality with a second venue and freeze the vocabulary" +create_issue risk-value-flow-freeze-hold \ + "wit: hold the value-flow freeze until the second venue proves the abstraction" \ + "Keep videre additively extensible through the cut and hold the value-flow freeze until the post-cut second venue proves the abstraction; owns the cross-repo re-pin ripple runbook." \ + "$M6" "debt,needs-design" "WIT/ABI" +# child order: risk-hold, #141 (curated registry, demoted), #330 (freeze, re-parented off #137, lands last) +link_sub "#140" risk-value-flow-freeze-hold +ensure_existing "#141" "$M6" "SDK/DX"; link_sub "#140" "#141" +unlink_sub "#137" "#330"; ensure_existing "#330" "$M6" "WIT/ABI"; link_sub "#140" "#330" + +# ---- M7 : reused #139 + new capability-teeth epic +echo; echo "### M7 -- existing #139 (the real egress guard)" +ensure_epic "#139" "$M7" "Runtime/Lifecycle" +create_issue guard-policy-async \ + "runtime: make the guard policy check async for live-state I/O" \ + "Convert the guard policy check to async so the real guard can simulate over live state and call remote analyzers without blocking the loop." \ + "$M7" "breaking" "Runtime/Lifecycle" +create_issue guard-derive-before-guard \ + "runtime: close the derive-before-guard escape and single-decode the body" \ + "Prevent derivation side-effects before the guard check and decode the body once so submit consumes the guard-vetted header." \ + "$M7" "security" "Runtime/Lifecycle" +create_issue guard-signing-boundary \ + "runtime: move the guard checkpoint to the signed transaction boundary" \ + "Add the guard checkpoint at the signed unsigned-tx boundary against the real identity backend, sharing one checkpoint with the guard engine." \ + "$M7" "security" "Runtime/Lifecycle" +# child order: policy-async, derive-before-guard, #52 (identity backend), signing-boundary +link_sub "#139" guard-policy-async +link_sub "#139" guard-derive-before-guard +ensure_existing "#52" "$M7" "Host Backends"; link_sub "#139" "#52" +link_sub "#139" guard-signing-boundary + +echo; echo "### M7 -- epic: capability and egress enforcement teeth" +create_issue epic-m6-egress-capability-teeth \ + "guard: capability and egress enforcement teeth" \ + "Bring http and messaging egress under real enforcement and align mock-grant fidelity so no capability escapes the compile-time world guarantee." \ + "$M7" "security,epic" "Runtime/Lifecycle" +create_issue guard-egress-cap-world-guarantee \ + "runtime: bring http egress under the compile-time world guarantee" \ + "Bring http egress under the synthesised-world guarantee and canonicalize a single adapter import-narrowing contract." \ + "$M7" "security" "Runtime/Lifecycle" +create_issue messaging-query-scope \ + "host: enforce the messaging query scope with the waku backend" \ + "Enforce the declared messaging scope on the query path, landed with the waku backend." \ + "$M7" "bug" "Host Backends" +create_issue mock-grant-fidelity \ + "host: align mock capability-grant fidelity to the real host grant" \ + "Reconcile the mock capability grant with the real host grant so a capability the host would deny is denied under mock." \ + "$M7" "debt" "Host Backends" +link_sub epic-m6-egress-capability-teeth guard-egress-cap-world-guarantee +link_sub epic-m6-egress-capability-teeth messaging-query-scope +link_sub epic-m6-egress-capability-teeth mock-grant-fidelity + +# ---- M8 : four new debt epics + the slim grant tracker #127 +echo; echo "### M8 -- epic: deferred videre abstraction concepts" +create_issue epic-m7-videre-deferred-concepts \ + "sdk: deferred videre abstraction concepts" \ + "The intentionally-parked videre abstractions (maker-side offer, taker-side RFQ firm-quote, and the venue-neutral materialiser) held until a real driving venue exists to shape them." \ + "$M8" "feature,epic" "SDK/DX" +create_issue rfq-firm-quote-additive \ + "wit: add an additive firm-quote field for RFQ venues" \ + "Add an additive firm-quote field to the quote record plus the accept/settle path, gated on a real RFQ venue." \ + "$M8" "feature,needs-design" "WIT/ABI" +create_issue materialiser-source-venue \ + "sdk: generalize the keeper into a venue-neutral materialiser" \ + "Generalize the keeper sweep assembler into a source-and-venue-neutral materialiser proven against two dissimilar pairs." \ + "$M8" "dx,needs-design" "SDK/DX" +ensure_existing "#355" "$M8" "WIT/ABI"; link_sub epic-m7-videre-deferred-concepts "#355" +link_sub epic-m7-videre-deferred-concepts rfq-firm-quote-additive +link_sub epic-m7-videre-deferred-concepts materialiser-source-venue + +echo; echo "### M8 -- epic: chain robustness and typed-fault debt" +create_issue epic-m7-chain-typed-fault-debt \ + "chain: robustness and typed-fault debt" \ + "Finish the typed-fault story across chain and the stub backends and clear the chain request-batch and backfill debt." \ + "$M8" "debt,epic" "Chain" +ensure_existing "#269" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#269" +ensure_existing "#288" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#288" +ensure_existing "#286" "$M8" "SDK/DX"; link_sub epic-m7-chain-typed-fault-debt "#286" +ensure_existing "#285" "$M8" "Observability"; link_sub epic-m7-chain-typed-fault-debt "#285" +ensure_existing "#289" "$M8" "Docs"; link_sub epic-m7-chain-typed-fault-debt "#289" +ensure_existing "#302" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#302" + +echo; echo "### M8 -- epic: runtime test-harness and performance debt" +create_issue epic-m7-runtime-test-perf-debt \ + "runtime: test-harness and performance debt" \ + "Host-internal debt: a multi-module test harness, a supervisor clock seam, lock performance, and state-seam batching." \ + "$M8" "debt,epic" "Runtime/Lifecycle" +ensure_existing "#283" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#283" +ensure_existing "#284" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#284" +ensure_existing "#280" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#280" +ensure_existing "#105" "$M8" "Storage"; link_sub epic-m7-runtime-test-perf-debt "#105" + +echo; echo "### M8 -- epic: messaging backend, docs and soak" +create_issue epic-m7-messaging-docs-soak \ + "host: messaging backend, docs and soak evidence" \ + "The deferred waku messaging backend and payload codec, the doc-consistency passes, and the unattended seven-day soak evidence." \ + "$M8" "feature,epic" "Host Backends" +ensure_existing "#152" "$M8" "Host Backends"; link_sub epic-m7-messaging-docs-soak "#152" +ensure_existing "#212" "$M8" "Host Backends"; link_sub epic-m7-messaging-docs-soak "#212" +ensure_existing "#341" "$M8" "Docs"; link_sub epic-m7-messaging-docs-soak "#341" +ensure_existing "#65" "$M8" "Tooling/Packaging"; link_sub epic-m7-messaging-docs-soak "#65" + +echo; echo "### M8 -- existing #127 (slim grant tracker; keeps epic label, no children)" +ensure_epic "#127" "$M8" "Docs" "docs: grant delivery plan and evidence tracker" +# children #121/#125 re-parented out (to M4 and M5); #127 references them in its body only. + +# ------------------------------------------------------------------ 6. re-parents summary (executed inline above) +echo; echo "## 6. native re-parents executed inline: #321->generic-host, #322->sdk-authoring, #330->#140, #121->cow-bugfixes, #125->operator-delivery" + +echo; echo "== done (DRY_RUN=$DRY_RUN). Review, then run: DRY_RUN=0 ./apply-plan.sh ==" diff --git a/docs/design/apply/apply-plan.sh b/docs/design/apply/apply-plan.sh new file mode 100755 index 00000000..adc46940 --- /dev/null +++ b/docs/design/apply/apply-plan.sh @@ -0,0 +1,638 @@ +#!/usr/bin/env bash +# +# apply-plan.sh : nullislabs/shepherd issue/milestone reorganization (nine-milestone videre spine) +# +# REVIEW THIS SCRIPT, THEN RUN: DRY_RUN=0 ./apply-plan.sh +# +# By default (DRY_RUN=1) every mutating GitHub call is ECHOED, not executed, so you can read the +# full plan of record before anything touches the remote. Nothing here reads or writes local git. +# +# Source of truth : docs/design/issue-milestone-plan.json (reconcile + epics arrays) +# Human context : docs/design/issue-milestone-plan.md +# House style : terse What / Why / Done-when; no phase jargon; no em dashes; Oxford -ize. +# +# Prerequisites: `gh auth status` green with repo + project + write:org scopes for nullislabs. +# The Project #1 Component/Status field + option IDs below are pinned from the apply facts. +# +# What it does, in order: +# 1. Rename 7 milestones in place + CREATE the new M2 milestone (milestone #7 is KEPT AS-IS as M0). +# 2. Close 7 delivered/obsolete issues; resolve 2 merges (#325/#326 -> #324, #329 -> #293). +# 3. Apply the 6 modifies (retitle / rescope / re-milestone) + demote #136/#141 out of epic-hood. +# 4. Define helpers (create_issue / ensure_existing / ensure_epic / link_sub / unlink_sub). +# 5. Walk every milestone M0..M8: reuse-or-create each epic, create/attach each child in order. +# 6. Execute the 5 native re-parents (#321,#322,#330 off #137; #121,#125 off #127). +# +# NOTE ON BODIES: new-issue bodies are terse house-style stubs (one-sentence What + Why linking the +# milestone/plan + Done-when pointing at the plan JSON key). Enrich before running if you want the +# full acceptance prose inline; the authoritative acceptance lives in the plan JSON per key. + +set -euo pipefail + +# ------------------------------------------------------------------ 0. config + guard +REPO="nullislabs/shepherd" +OWNER="nullislabs" +PROJECT="1" +PROJECT_ID="PVT_kwDODm2Wqs4BcJ3a" + +COMPONENT_FIELD="PVTSSF_lADODm2Wqs4BcJ3azhW0t-M" +STATUS_FIELD="PVTSSF_lADODm2Wqs4BcJ3azhW0t9I" +STATUS_TODO="f75ad846" + +# per-issue body files live in bodies/.md next to this script +BODIES="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bodies" + +DRY_RUN="${DRY_RUN:-1}" +run(){ if [ "$DRY_RUN" = 1 ]; then echo "+ $*"; else echo ">> $*"; eval "$*"; fi; } + +# per-key state captured at create/ensure time (declared early: the sub-issue helpers read it) +declare -A NUM ID ITEM + +# --- native sub-issue helpers (used from step 2 onward). KEY refs resolve via NUM/ID; #NNN via cache or REST. +resolve_num(){ local r="$1"; if [[ "$r" == \#* ]]; then echo "${r#\#}"; else echo "${NUM[$r]}"; fi; } +resolve_id(){ + local r="$1" + if [[ "$r" == \#* ]]; then + if [ -n "${ID[$r]:-}" ]; then echo "${ID[$r]}" + elif [ "$DRY_RUN" = 1 ]; then echo "" + else gh api "repos/$REPO/issues/${r#\#}" -q .id; fi + else echo "${ID[$r]}"; fi +} +# link_sub PARENT_REF CHILD_REF (native GitHub sub-issue; POST attaches/reassigns) +link_sub(){ + local p c; p="$(resolve_num "$1")"; c="$(resolve_id "$2")" + run "gh api repos/$REPO/issues/$p/sub_issues -F sub_issue_id=$c" +} +# unlink_sub OLD_PARENT_REF CHILD_REF (remove a stale parent link before re-parenting) +unlink_sub(){ + local p c; p="$(resolve_num "$1")"; c="$(resolve_id "$2")" + run "gh api -X DELETE repos/$REPO/issues/$p/sub_issue -F sub_issue_id=$c" +} + +# --- Project Component field: the 12 options (name -> single-select option id) +declare -A COMP_OPT=( + [Engine]=6f70e61b [Runtime/Lifecycle]=b73f2d05 [Host Backends]=443041f1 [Chain]=c11b797e + [Storage]=a2eb71e6 [WIT/ABI]=e077d895 [CoW]=63aaa607 [SDK/DX]=74e5d4be [Modules]=6ef6a455 + [Observability]=7e739f27 [Tooling/Packaging]=461d450e [Docs]=526e258a +) +# --- matching component/* label per Component (Docs carries no component label; the docs kind suffices) +declare -A COMP_LABEL=( + [Engine]=component/engine-runtime [Runtime/Lifecycle]=component/lifecycle [Host Backends]=component/engine-host + [Chain]=component/chain [Storage]=component/local-store [WIT/ABI]=component/wit-abi [CoW]=component/cow-integration + [SDK/DX]=component/sdk [Modules]=component/modules [Observability]=component/observability + [Tooling/Packaging]=component/tools [Docs]="" +) + +# --- clean milestone titles (nine-milestone scheme; every issue/epic uses these) +M0="M0: Runtime architecture and lifecycle" +M1="M1: Videre contract reshape and host-intent decoupling" +M2="M2: Generic venue-agnostic host" +M3="M3: Videre SDK, macros and DX" +M4="M4: CoW on the generic seam (the shepherd bundle)" +M5="M5: The gated three-repo split" +M6="M6: Second-venue acceptance and vocabulary freeze" +M7="M7: Egress guard" +M8="M8: Post-v1 hardening and debt" + +echo "== DRY_RUN=$DRY_RUN (1 echoes, 0 executes). Repo=$REPO Project #$PROJECT ==" + +# ------------------------------------------------------------------ 1. milestones +echo; echo "## 1. milestones (rename 7 in place; #7 kept as M0; create M2)" +# #7 "M0: Runtime architecture and lifecycle" -> KEEP AS-IS (no rename, no content move) +run "gh api repos/$REPO/milestones/8 -X PATCH -f title='$M1'" +run "gh api repos/$REPO/milestones/5 -X PATCH -f title='$M3'" +run "gh api repos/$REPO/milestones/3 -X PATCH -f title='$M4'" +run "gh api repos/$REPO/milestones/4 -X PATCH -f title='$M5'" +run "gh api repos/$REPO/milestones/10 -X PATCH -f title='$M6'" +run "gh api repos/$REPO/milestones/9 -X PATCH -f title='$M7'" +run "gh api repos/$REPO/milestones/6 -X PATCH -f title='$M8'" +# #1 "(dissolved) Host backends real" -> leave dissolved/closed (no action) +# create the one brand-new milestone (M2) +run "gh api repos/$REPO/milestones -f title='$M2' -f description='Make nexum-runtime a generic, venue-agnostic component host: grow the Extension seam, extract VenueRegistry, delete HostState.pool_router, zero-leak CI gate.'" + +# ------------------------------------------------------------------ 2. closes + merges +echo; echo "## 2. close delivered/obsolete + resolve merges" +close_issue(){ # $1 num $2 comment $3 reason(completed|"not planned") + run "gh issue comment $1 --repo $REPO --body \"$2\"" + run "gh issue close $1 --repo $REPO --reason \"${3:-completed}\"" +} +close_issue 339 "Obsolete: this fixes a bullet inside docs/migration/0.1-to-0.2.md, which is deleted as reshape migration cruft. Closing as no longer applicable." "not planned" +close_issue 287 "Superseded: the legacy cow-api extension it targets is removed by #293; the timeout/429 typed-fault requirement is carried by the cow adapter errorType-to-venue-error projection and the host-chain equivalent #269." "not planned" +close_issue 222 "Delivered by the M1 train: ConditionalSource/Retrier/RetryAction landed in nexum-sdk. Forward keeper-sweep rework is tracked by the videre-sdk work." "completed" +close_issue 137 "Delivered by the M1 train. Successor is the videre contract reshape epic (rename, quote, normalize, host-intent decouple), not a reopen." "completed" +close_issue 135 "Delivered by the M1 train: keeper primitives and the single-venue loop landed in nexum-sdk. Deferred generalization is the videre-sdk sweep assembler plus the M8 materialiser." "completed" +close_issue 131 "Docs-only, delivered by the design-docs PR. Go-forward doc work is the source-of-truth rewrite." "completed" +close_issue 7 "Stale pre-restructure roadmap epic. Its goal is largely delivered and its workstreams are decomposed into the M0..M8 milestones and individual issues. Nothing tracks against it." "not planned" +# merges: comment the target, close the folded issues as merged +run "gh issue comment 324 --repo $REPO --body \"Absorbing #325 (golden vectors + conformance wiring) and #326 (bundle the adapter into the distribution): all three are the single cow-adapter cdylib deliverable of the shepherd bundle.\"" +close_issue 325 "Merged into #324 (the single cow adapter cdylib deliverable)." "not planned" +close_issue 326 "Merged into #324 (the single cow adapter cdylib deliverable)." "not planned" +run "gh issue comment 293 --repo $REPO --body \"Absorbing #329: per the shepherd-bundle decision, shepherd-sdk is folded INTO the bundle at the carve rather than retired as a standalone crate. Tracked here with the legacy cow-api cone retirement.\"" +unlink_sub "#136" "#329" # remove #329 from #136 before close (#136 demotes to a leaf) +close_issue 329 "Merged into #293 (shepherd-sdk absorbed into the shepherd bundle at the carve)." "not planned" + +# ------------------------------------------------------------------ 3. modifies (retitle / rescope / demote) +echo; echo "## 3. modifies + epic-label demotions (milestone + component are set in the epic walk below)" +# #274 rescope 2-repo -> 3-repo cut (retitled here; milestone/component set as reused epic in step 5) +run "gh issue edit 274 --repo $REPO --title 'packaging: carve nexum-runtime, videre and shepherd into three repos'" +run "gh issue comment 274 --repo $REPO --body \"Rescoped from a two-repo split to the three-repo cut (nexum-runtime, videre, shepherd); post-split preset items fold into the seam generalization. Re-milestoned to the gated three-repo split.\"" +# #136 rescope + DEMOTE from epic to normal issue (its child #329 merged into #293, so it is childless) +run "gh issue edit 136 --repo $REPO --title 'sdk: consolidate the nexum-sdk macros and rename to videre-sdk' --remove-label epic" +run "gh issue comment 136 --repo $REPO --body \"Rescoped: shepherd-sdk is absorbed into the shepherd bundle (not kept standalone). Residual is the nexum-sdk/macro consolidation plus the nexum-venue-sdk to videre-sdk rename. Demoted from epic to a normal issue.\"" +# #141 DEMOTE from epic to leaf under the reused second-venue epic (#140); owns no native sub-issues +run "gh issue edit 141 --repo $REPO --remove-label epic" +run "gh issue comment 141 --repo $REPO --body \"Demoted from epic to a leaf issue under the second-venue acceptance epic (curated adapter registry and consent surface). No native sub-issues to re-parent.\"" +# #330 retitle under the videre rename; freeze held to the second-venue milestone +run "gh issue edit 330 --repo $REPO --title 'wit: freeze-gate decisions for value-flow'" +run "gh issue comment 330 --repo $REPO --body \"Retitled under the videre:value-flow rename. Keeps the two freeze-gate ontology decisions (minimal-length canonical amount encoding; native-token representable-but-invalid). The 1.0 freeze is HELD until the post-cut second venue proves the abstraction.\"" +# #273 rescope: preset launch-surface subsumed by the seam generalization +run "gh issue edit 273 --repo $REPO --title 'runtime: fold the preset launch-surface into the seam generalization'" +run "gh issue comment 273 --repo $REPO --body \"Rescoped: the preset/Runtime-trait launch-surface ask is subsumed by growing the Extension seam plus the bare launcher. Residual is the MockRuntime preset path. Re-milestoned to the generic venue-agnostic host.\"" +# #289 drop the deleted-migration-file bullet; keep the still-valid doc debt; stays M8 +run "gh issue comment 289 --repo $REPO --body \"Drop the docs/migration/0.1-to-0.2.md bullet (that file is deleted as reshape cruft). Keep the ADR-0011 error-record restoration, the docs/07 rpc payload callout, the production.md house-style pass and the stale diagram regen. Distinct from the source-of-truth rewrite.\"" +# #139 rescope: fold the router/capability/lifecycle hardening as children (done structurally in step 5) +run "gh issue comment 139 --repo $REPO --body \"Rescoped to fold the router/capability/lifecycle hardening the guard engine did not enumerate (single-decode/derive-before-guard, signed-tx boundary, async policy, http-under-world guarantee, messaging query scope, adapter sweeps, mock fidelity) as children. Advisory-only for the reshape milestone; depends on the identity backend #52.\"" + +# ------------------------------------------------------------------ 4. helpers +echo; echo "## 4. (helpers defined)" + +# create_issue KEY TITLE SUMMARY MILESTONE KIND_LABELS_CSV COMPONENT [BLOCKED_BY_TEXT] +# creates the issue, captures NUM+REST id, adds to Project #1, sets Component + Status=Todo. +# The component/* label is appended automatically from COMPONENT (Docs adds none). +create_issue(){ + local key="$1" title="$2" summary="$3" ms="$4" kinds="$5" comp="$6" blk="${7:-}" + local clabel="${COMP_LABEL[$comp]:-}" + local bodyfile="$BODIES/$key.md" + if [ ! -f "$bodyfile" ]; then echo "FATAL: missing body file $bodyfile (issue key '$key')" >&2; exit 1; fi + local largs=() + local IFS=','; local l + for l in $kinds; do [ -n "$l" ] && largs+=(--label "$l"); done + unset IFS + [ -n "$clabel" ] && largs+=(--label "$clabel") + + if [ "$DRY_RUN" = 1 ]; then + echo "+ gh issue create --repo $REPO --title '$title' --milestone '$ms' ${largs[*]} --body-file bodies/$key.md [key=$key]" + echo "+ project item-add + Component='$comp'(${COMP_OPT[$comp]}) + Status=Todo [key=$key]" + NUM[$key]="<$key>"; ID[$key]=""; ITEM[$key]="" + return + fi + local url num id item + url="$(gh issue create --repo "$REPO" --title "$title" --milestone "$ms" "${largs[@]}" --body-file "$bodyfile")" + num="${url##*/}" + id="$(gh api "repos/$REPO/issues/$num" -q .id)" + NUM[$key]="$num"; ID[$key]="$id" + echo ">> created #$num [$key] $url" + item="$(gh project item-add "$PROJECT" --owner "$OWNER" --url "$url" --format json -q .id)" + ITEM[$key]="$item" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$COMPONENT_FIELD" --single-select-option-id "${COMP_OPT[$comp]}" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$STATUS_FIELD" --single-select-option-id "$STATUS_TODO" +} + +# ensure_existing #NUM MILESTONE(""=leave) COMPONENT [ADD_LABEL] +# moved-in existing issue: set milestone, add component label, ensure Project #1 membership + Component field. +ensure_existing(){ + local ref="$1" ms="$2" comp="$3" addlbl="${4:-}" + local num="${ref#\#}" clabel="${COMP_LABEL[$comp]:-}" + local eargs="gh issue edit $num --repo $REPO" + [ -n "$ms" ] && eargs="$eargs --milestone '$ms'" + [ -n "$clabel" ] && eargs="$eargs --add-label $clabel" + [ -n "$addlbl" ] && eargs="$eargs --add-label $addlbl" + run "$eargs" + if [ "$DRY_RUN" = 1 ]; then + echo "+ project item-add #$num + Component='$comp'(${COMP_OPT[$comp]})" + ID["#$num"]="" + return + fi + local id item + id="$(gh api "repos/$REPO/issues/$num" -q .id)"; ID["#$num"]="$id" + item="$(gh project item-add "$PROJECT" --owner "$OWNER" --url "https://github.com/$REPO/issues/$num" --format json -q .id)" + gh project item-edit --project-id "$PROJECT_ID" --id "$item" --field-id "$COMPONENT_FIELD" --single-select-option-id "${COMP_OPT[$comp]}" +} + +# ensure_epic #NUM MILESTONE COMPONENT [TITLE] +# reused existing epic: add `epic` label + milestone + component + project; optional retitle. +ensure_epic(){ + local ref="$1" ms="$2" comp="$3" title="${4:-}" + [ -n "$title" ] && run "gh issue edit ${ref#\#} --repo $REPO --title '$title'" + ensure_existing "$ref" "$ms" "$comp" epic +} + +# ------------------------------------------------------------------ 5. epics + children, milestone order M0..M8 +echo; echo "## 5. epics + children (single parent per issue; children in execution order)" + +# ---- M0 : reused lifecycle epic #294 only (milestone #7 KEPT; do NOT re-milestone #294 or its children) +echo; echo "### M0 -- existing #294 (no milestone change)" +ensure_epic "#294" "" "Runtime/Lifecycle" +ensure_existing "#51" "" "Runtime/Lifecycle" # already parented under #294 +ensure_existing "#53" "" "Runtime/Lifecycle" +ensure_existing "#107" "" "Runtime/Lifecycle" +ensure_existing "#244" "" "Runtime/Lifecycle" +ensure_existing "#265" "" "Observability" +ensure_existing "#266" "" "Runtime/Lifecycle" +# (children already native sub-issues of #294 -> no link needed) + +# ---- M1 : two new WIT epics +echo; echo "### M1 -- epic: master-gate fold" +create_issue epic-m0-p0-master-gate-fold \ + "wit: land the host-intent decouple master gate for an acyclic split" \ + "Land the host-intent WIT decouple (host event carries opaque status bytes) as the master gate, plus the single oracle-validated fold to a green tip, so the acyclic split is CI-verifiable while nothing is pinned." \ + "$M1" "feature,epic" "WIT/ABI" +create_issue gap-opaque-status-contract-spec \ + "wit: spec the opaque-status destructuring contract with a version discriminator" \ + "Pin the wire form (version discriminator plus destructuring rule) and schema ownership for the opaque status bytes the host event will carry." \ + "$M1" "docs,needs-design" "WIT/ABI" +create_issue host-r6-decouple \ + "wit: carry opaque status bytes so the host stops importing intent" \ + "Drop the host-to-intent WIT use so the host world becomes a leaf carrying opaque status bytes; the master gate for the whole split." \ + "$M1" "breaking,needs-design" "WIT/ABI" +create_issue p0-acyclicity-scaffold \ + "engine: land the acyclicity and zero-leak CI check (advisory first)" \ + "Add the CI and local command that assert the host reaches no intent/venue/cow crate; advisory now, flipped to blocking later." \ + "$M1" "debt" "Engine" +create_issue guard-advisory-m1 \ + "runtime: ship an advisory-only guard posture and document the non-enforcing checkpoint" \ + "Keep the allow-all guard as default, feature-gate the intent import, and document the checkpoint as advisory-only for this milestone." \ + "$M1" "security" "Runtime/Lifecycle" +create_issue guard-deny-quota \ + "runtime: charge quota on guard-deny to close the busy-loop denial-of-service" \ + "Charge the caller quota on a guard-deny verdict so a denied submission cannot be retried in a free tight loop." \ + "$M1" "bug" "Runtime/Lifecycle" +create_issue gap-p0-fold-tail-hygiene \ + "packaging: land the fold-tail codec discriminator, migration-cruft deletion and retry doc" \ + "Fold-tail hygiene riding the reshape: codec version discriminator plus reject-unknown, delete migration cruft, and the must-not-retry doc caveat." \ + "$M1" "debt" "Tooling/Packaging" +create_issue gap-p0-wit-fold-execution \ + "packaging: execute the contract reshape as one oracle-validated fold across the train" \ + "Run the whole reshape as a single range-limited fold across the train with goldens regenerated and the byte-identical tip oracle re-asserted." \ + "$M1" "debt" "Tooling/Packaging" +create_issue gap-m1-green-tip-gate \ + "packaging: finish the train to a single green linear tip before any carve" \ + "Track the remaining train cars to a single green linear tip; the signed-off precondition for beginning the carve." \ + "$M1" "debt" "Tooling/Packaging" +for c in gap-opaque-status-contract-spec host-r6-decouple p0-acyclicity-scaffold guard-advisory-m1 guard-deny-quota gap-p0-fold-tail-hygiene gap-p0-wit-fold-execution gap-m1-green-tip-gate; do + link_sub epic-m0-p0-master-gate-fold "$c" +done + +echo; echo "### M1 -- epic: videre L2 contract" +create_issue epic-m0-videre-l2-contract \ + "wit: reshape the intent contract into the videre venue abstraction" \ + "Reshape the pre-release intent WIT into videre (rename, pin the surface, normalize to 0.1.0, add quote): the venue-neutral settlement and quoting contract every later venue and keeper compiles against." \ + "$M1" "feature,epic" "WIT/ABI" +create_issue videre-wit-rename \ + "wit: rename the intent packages to videre" \ + "One mechanical rename of the intent/value-flow/adapter packages to videre, folded into the oracle-validated pass." \ + "$M1" "breaking" "WIT/ABI" +create_issue videre-wit-surface \ + "wit: pin the videre contract surface (types, venue, value-flow)" \ + "Pin the shapes of videre types, venue (mirrored worker/provider faces) and value-flow (named records); EVM-only." \ + "$M1" "breaking" "WIT/ABI" +create_issue videre-wit-normalize \ + "wit: normalize every package to a single 0.1.0" \ + "Reset every WIT package and use reference to 0.1.0 as one fold, deleting the migration cruft." \ + "$M1" "debt" "WIT/ABI" +create_issue videre-quote \ + "wit: add quote to the videre venue faces and the client typestate" \ + "Add quote to both venue faces and a thin value-flow-typed quote record with a client quote-then-submit typestate." \ + "$M1" "breaking" "WIT/ABI" +create_issue gap-handshake-manifest-key-decision \ + "wit: decide the install-time body-versions manifest key and match semantics" \ + "Pin the manifest key name and the supported-set match semantics for the install-time body-versions handshake." \ + "$M1" "docs,needs-design" "WIT/ABI" +for c in videre-wit-rename videre-wit-surface videre-wit-normalize videre-quote gap-handshake-manifest-key-decision; do + link_sub epic-m0-videre-l2-contract "$c" +done + +# ---- M2 : two new host-generalization epics (the brand-new milestone) +echo; echo "### M2 -- epic: generic venue-agnostic host" +create_issue epic-m1-generic-venue-agnostic-host \ + "runtime: make the host generic and venue-agnostic" \ + "Grow the Extension seam to worker and provider roles, extract the venue registry and the generic supervised-component primitive, de-hardcode the known table, land the bare launcher and the videre-host platform registration, so nothing venue/intent/cow shaped lives in the host layer." \ + "$M2" "feature,epic" "Runtime/Lifecycle" +create_issue host-extension-seam-roles \ + "runtime: grow the extension seam to carry worker and provider roles" \ + "Grow the extension seam to contribute namespace/capabilities/link/service/provider, adding a type-erased host service and a provider kind; the long pole." \ + "$M2" "feature,needs-design" "Runtime/Lifecycle" +create_issue host-venue-registry-extract \ + "runtime: extract the venue registry service and delete the privileged router field" \ + "Move the router into the generic services map as an extension-owned venue registry and delete the privileged pool_router field." \ + "$M2" "debt" "Runtime/Lifecycle" +create_issue host-generic-component-kind \ + "runtime: extract a generic supervised-component primitive from the adapter actor" \ + "Extract the generic supervised-component primitive (fuel, trap projection, serialization, sweeps) and collapse the hardcoded kind match into a generic role loop." \ + "$M2" "feature" "Runtime/Lifecycle" +create_issue adapter-supervision-sweeps \ + "runtime: fold venue adapters into the restart and poison sweeps" \ + "Fold provider components into the restart and poison-recovery sweeps and expose a liveness signal distinguishing unknown-venue from temporarily-dead." \ + "$M2" "debt" "Runtime/Lifecycle" +create_issue host-nexum-world-registry \ + "engine: de-hardcode the known table and extract world synthesis into nexum-world" \ + "Delete the baked capability rows, source rows from registered extensions, and extract world synthesis plus the table into a plain nexum-world lib." \ + "$M2" "debt" "Engine" +create_issue host-generic-launcher-bin \ + "runtime: extract a generic launcher and a bare engine binary" \ + "Extract a generic launcher lib and a bare no-extension engine binary, retiring the backwards cli-to-cow dependency." \ + "$M2" "debt" "Runtime/Lifecycle" +create_issue gap-videre-host-platform-crate \ + "runtime: build the videre-host crate and platform registration" \ + "Build the videre-host L2 crate and a platform() entrypoint registering the provider kind, venue registry, guard seam, venue client and install predicate through the generic seam." \ + "$M2" "feature" "Runtime/Lifecycle" +create_issue videre-body-versions-handshake \ + "wit: add an install-time body-versions schema handshake" \ + "Add a body-versions query plus a manifest field, with the supervisor refusing to boot a keeper/adapter pair whose versions do not intersect." \ + "$M2" "feature" "WIT/ABI" +# child order: seam-roles, registry-extract, #321, component-kind, sweeps, world-registry, launcher, #273, videre-host, handshake +link_sub epic-m1-generic-venue-agnostic-host host-extension-seam-roles +link_sub epic-m1-generic-venue-agnostic-host host-venue-registry-extract +unlink_sub "#137" "#321"; ensure_existing "#321" "$M2" "Runtime/Lifecycle"; link_sub epic-m1-generic-venue-agnostic-host "#321" +link_sub epic-m1-generic-venue-agnostic-host host-generic-component-kind +link_sub epic-m1-generic-venue-agnostic-host adapter-supervision-sweeps +link_sub epic-m1-generic-venue-agnostic-host host-nexum-world-registry +link_sub epic-m1-generic-venue-agnostic-host host-generic-launcher-bin +ensure_existing "#273" "$M2" "Runtime/Lifecycle"; link_sub epic-m1-generic-venue-agnostic-host "#273" +link_sub epic-m1-generic-venue-agnostic-host gap-videre-host-platform-crate +link_sub epic-m1-generic-venue-agnostic-host videre-body-versions-handshake + +echo; echo "### M2 -- epic: prove venue-agnostic (zero-leak gate)" +create_issue epic-m1-s1-venue-agnostic-gate \ + "runtime: prove the host is venue-agnostic (zero-leak gate)" \ + "Land the permanent zero-leak and acyclicity CI check and flip it to blocking, proving the host is venue-agnostic once the router field is deleted and the echo venue boots." \ + "$M2" "feature,epic" "Engine" +create_issue host-zero-leak-ci-gate \ + "engine: add the zero-leak CI check for the host layer" \ + "Add a required check that fails if the host regains intent/venue/cow symbols or crate edges, green at the generalized tip." \ + "$M2" "debt" "Engine" +create_issue s1-gate-runtime-venue-agnostic \ + "runtime: prove the host is venue-agnostic (zero-leak CI blocking)" \ + "Delete the router field and flip the zero-leak check to blocking, with an echo-venue boot integration test as the oracle." \ + "$M2" "feature" "Engine" +link_sub epic-m1-s1-venue-agnostic-gate host-zero-leak-ci-gate +link_sub epic-m1-s1-venue-agnostic-gate s1-gate-runtime-venue-agnostic + +# ---- M3 : two new SDK/DX epics +echo; echo "### M3 -- epic: videre-sdk and the blessed authoring path" +create_issue epic-m2-videre-sdk-authoring \ + "sdk: videre-sdk and the blessed venue and keeper authoring path" \ + "Land the venue and keeper author front door: videre-sdk with the keeper sweep assembler, the single blessed venue and keeper macros with a typed venue client, and the videre conformance kit; additively extensible for the post-cut second venue." \ + "$M3" "feature,epic" "SDK/DX" +create_issue videre-sdk-crate \ + "sdk: rename to videre-sdk and add the keeper sweep assembler and venue client" \ + "Rename the venue SDK to videre-sdk and add the generic keeper sweep assembler and the typed intent client." \ + "$M3" "feature" "SDK/DX" +create_issue videre-venue-macro \ + "sdk: make the venue macro the single blessed authoring path" \ + "Fix the venue macro to emit the typed venue adapter, demote the raw export path to internal codegen, and narrow imports by construction." \ + "$M3" "dx" "SDK/DX" +create_issue videre-conformance-kit \ + "sdk: ship the videre conformance kit with a wire-drift test gate" \ + "Rename and harden the conformance kit so a venue test fails on any wire-shape drift, with hardened goldens." \ + "$M3" "dx" "SDK/DX" +create_issue videre-keeper-macro \ + "sdk: add the keeper macro and a typed venue client" \ + "Add the keeper macro that drives a venue through a typed venue client, wiring the event subs with zero boxing on the hot path." \ + "$M3" "dx" "SDK/DX" +# child order: #136, videre-sdk-crate, #322, #264, videre-venue-macro, videre-conformance-kit, videre-keeper-macro +ensure_existing "#136" "$M3" "SDK/DX"; link_sub epic-m2-videre-sdk-authoring "#136" +link_sub epic-m2-videre-sdk-authoring videre-sdk-crate +unlink_sub "#137" "#322"; ensure_existing "#322" "$M3" "SDK/DX"; link_sub epic-m2-videre-sdk-authoring "#322" +ensure_existing "#264" "$M3" "SDK/DX"; link_sub epic-m2-videre-sdk-authoring "#264" +link_sub epic-m2-videre-sdk-authoring videre-venue-macro +link_sub epic-m2-videre-sdk-authoring videre-conformance-kit +link_sub epic-m2-videre-sdk-authoring videre-keeper-macro + +echo; echo "### M3 -- epic: guest seams, alloy provider and DX polish" +create_issue epic-m2-reth-alloy-dx-seams \ + "sdk: guest seams, alloy provider and the dx polish cluster" \ + "Complete the guest-facing developer surface: identity/messaging/remote-store guest traits with mocks, richer local-store queries, an alloy provider seam over the chain host, and the alloy-grade DX polish cluster." \ + "$M3" "dx,epic" "SDK/DX" +create_issue host-backend-guest-seams \ + "sdk: add guest seams and mocks for identity, messaging and remote-store" \ + "Add guest traits and mocks for the three backend interfaces missing a seam and wire them to the stub backends." \ + "$M3" "dx" "SDK/DX" +create_issue gap-alloy-provider-seam \ + "sdk: add an alloy provider seam over the chain host" \ + "Add an alloy transport over the chain host and a guest provider, carrying the typed chain-method surface to the guest." \ + "$M3" "dx" "SDK/DX" +create_issue gap-dx-polish-cluster \ + "sdk: land the alloy-grade DX polish cluster" \ + "Mirror the venue fault type, add an order builder, uniform non-exhaustive, sealed traits, single-source consts, and remove the golden-bridge boilerplate." \ + "$M3" "dx" "SDK/DX" +link_sub epic-m2-reth-alloy-dx-seams host-backend-guest-seams +ensure_existing "#291" "$M3" "Storage"; link_sub epic-m2-reth-alloy-dx-seams "#291" +link_sub epic-m2-reth-alloy-dx-seams gap-alloy-provider-seam +link_sub epic-m2-reth-alloy-dx-seams gap-dx-polish-cluster + +# ---- M4 : reused #138 + two new CoW epics +echo; echo "### M4 -- existing #138 (cow adapter + flagship ports)" +ensure_epic "#138" "$M4" "CoW" +create_issue cleave-cow-venue \ + "cow: cleave the cow venue from the composable-cow keeper" \ + "Split the mixed crate so the venue holds only the orderbook body while composable machinery moves to a separate keeper, gated by a CI symbol check." \ + "$M4" "feature" "CoW" +create_issue cow-idempotency-seam \ + "cow: settle the idempotency seam before order assembly moves into the adapter" \ + "Pick and wire a deterministic pre-submit identifier so the journal idempotency check survives order assembly moving into the adapter." \ + "$M4" "feature" "CoW" +create_issue shepherd-cow-event-abi-wits \ + "wit: own the shepherd-cow event-ABI packages at the bundle layer" \ + "Consolidate the cow on-chain event-ABI surfaces under the bundle-owned WIT package, consumed only by bundle crates." \ + "$M4" "feature" "WIT/ABI" +create_issue composable-poll-wire-swap \ + "cow: swap the composable-cow poll wire and delete the legacy adapter" \ + "Fork-gated: swap the poll onto the structured non-reverting path, fully populate the post verdict, and delete the legacy revert adapter." \ + "$M4" "debt,blocked,needs-design" "CoW" "the fork deployment" +# child order: cleave, idempotency, #324, event-abi, #323, #327, #328, #293, poll-wire-swap +link_sub "#138" cleave-cow-venue +link_sub "#138" cow-idempotency-seam +ensure_existing "#324" "$M4" "CoW" # already parented under #138 +link_sub "#138" shepherd-cow-event-abi-wits +ensure_existing "#323" "$M4" "CoW" # already parented under #138 +ensure_existing "#327" "$M4" "CoW" # already parented under #138 +ensure_existing "#328" "$M4" "CoW" # already parented under #138 +ensure_existing "#293" "$M4" "CoW" # already parented under #138 +link_sub "#138" composable-poll-wire-swap + +echo; echo "### M4 -- epic: seam gate + source-of-truth docs" +create_issue epic-m3-s1b-seam-gate \ + "cow: run the cow keeper on the generic seam and rewrite the docs" \ + "Close the seam gate: the keeper submits through the videre venue client with the cow-api host retired, and the source-of-truth docs are rewritten as the shipped-venue reference." \ + "$M4" "feature,epic" "CoW" +create_issue s1b-gate-cow-on-generic-seam \ + "cow: run the cow keeper on the generic venue client" \ + "Flip the keeper submit onto the venue client and retire the cow-api host extension, keeping the seam port decoupled from the fork-gated poll swap." \ + "$M4" "feature" "CoW" +create_issue gap-docs-source-of-truth-rewrite \ + "docs: rewrite the platform docs as the shipped-venue source of truth" \ + "Rewrite the platform docs so the venue persona is documented as shipped and venue adapters are the extension mechanism, marking cow-api the legacy read path." \ + "$M4" "docs" "Docs" +link_sub epic-m3-s1b-seam-gate s1b-gate-cow-on-generic-seam +link_sub epic-m3-s1b-seam-gate gap-docs-source-of-truth-rewrite + +echo; echo "### M4 -- epic: carry the live keeper fixes into the port" +create_issue epic-m3-cow-keeper-bugfixes \ + "cow: carry the live twap and composable keeper fixes into the port" \ + "Land the still-live twap and composable keeper correctness fixes on the ported keeper, plus the grant deliverable-divergence reconcile." \ + "$M4" "bug,epic" "CoW" +# children all existing (#121 re-parented off #127; rest freshly parented) +unlink_sub "#127" "#121"; ensure_existing "#121" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#121" +ensure_existing "#48" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#48" +ensure_existing "#75" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#75" +ensure_existing "#320" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#320" +ensure_existing "#54" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#54" +ensure_existing "#64" "$M4" "CoW"; link_sub epic-m3-cow-keeper-bugfixes "#64" + +# ---- M5 : reused #274 + new operator-delivery epic +echo; echo "### M5 -- existing #274 (the three-repo cut)" +ensure_epic "#274" "$M5" "Tooling/Packaging" # title already set in step 3 +create_issue s2-transitional-workspace \ + "packaging: build the transitional path-dep workspace in three groupings" \ + "Reorganize the crates into the three prospective groupings as path-dep workspace members and add a dep-sync CI check." \ + "$M5" "debt" "Tooling/Packaging" +create_issue host-wit-deps-flip-carve \ + "packaging: flip the host WIT to crate-local wit-deps and carve the L1 repo" \ + "Flip host WIT resolution to crate-local wit-deps and carve the host as a standalone repo under the tip oracle." \ + "$M5" "debt,blocked" "Tooling/Packaging" "the zero-leak gate landing" +create_issue s2-wit-cross-repo-consumption \ + "packaging: source cross-repo WIT from wit-deps and git tags" \ + "Make every WIT resolution crate-local and source cross-repo packages from pinned git tags with lockfiles." \ + "$M5" "debt" "Tooling/Packaging" +create_issue s2-cut-gate-checklist \ + "packaging: assert the go/no-go cut gate before any carve" \ + "A single checklist that must close before the carves start: host venue-agnostic and cow on the generic seam, with the second venue de-gated to post-cut." \ + "$M5" "debt" "Tooling/Packaging" +create_issue s2-three-carves \ + "packaging: carve nexum-runtime, videre and shepherd into three repos" \ + "Three history-preserving carves executed as one coordinated operation under the byte-identical tip oracle." \ + "$M5" "breaking" "Tooling/Packaging" +create_issue videre-consumable-release-graduation \ + "packaging: cut the first consumable videre release and graduate off the umbrella" \ + "Cut the first consumable videre-sdk and WIT release and add a fresh-clone external-consumer smoke test against published deps only." \ + "$M5" "dx" "Tooling/Packaging" +# child order: transitional, wit-deps-flip, wit-cross-repo, checklist, three-carves, release-graduation +link_sub "#274" s2-transitional-workspace +link_sub "#274" host-wit-deps-flip-carve +link_sub "#274" s2-wit-cross-repo-consumption +link_sub "#274" s2-cut-gate-checklist +link_sub "#274" s2-three-carves +link_sub "#274" videre-consumable-release-graduation + +echo; echo "### M5 -- epic: operator delivery" +create_issue epic-m4-operator-delivery \ + "packaging: operator delivery, multi-chain and the swarm remote-store" \ + "Operator-facing delivery landed alongside the cut: green CI and CD, the multi-chain provider map and deployment docs, ghcr image packaging, and the real Swarm remote-store backend (an implementation item riding this epic for delivery convenience)." \ + "$M5" "feature,epic" "Tooling/Packaging" +ensure_existing "#337" "$M5" "Tooling/Packaging"; link_sub epic-m4-operator-delivery "#337" +ensure_existing "#151" "$M5" "Storage"; link_sub epic-m4-operator-delivery "#151" +unlink_sub "#127" "#125"; ensure_existing "#125" "$M5" "Tooling/Packaging"; link_sub epic-m4-operator-delivery "#125" +ensure_existing "#124" "$M5" "Docs"; link_sub epic-m4-operator-delivery "#124" + +# ---- M6 : reused #140 (second-venue acceptance + freeze) +echo; echo "### M6 -- existing #140 (second-venue acceptance and vocabulary freeze)" +ensure_epic "#140" "$M6" "SDK/DX" "sdk: prove venue-neutrality with a second venue and freeze the vocabulary" +create_issue risk-value-flow-freeze-hold \ + "wit: hold the value-flow freeze until the second venue proves the abstraction" \ + "Keep videre additively extensible through the cut and hold the value-flow freeze until the post-cut second venue proves the abstraction; owns the cross-repo re-pin ripple runbook." \ + "$M6" "debt,needs-design" "WIT/ABI" +# child order: risk-hold, #141 (curated registry, demoted), #330 (freeze, re-parented off #137, lands last) +link_sub "#140" risk-value-flow-freeze-hold +ensure_existing "#141" "$M6" "SDK/DX"; link_sub "#140" "#141" +unlink_sub "#137" "#330"; ensure_existing "#330" "$M6" "WIT/ABI"; link_sub "#140" "#330" + +# ---- M7 : reused #139 + new capability-teeth epic +echo; echo "### M7 -- existing #139 (the real egress guard)" +ensure_epic "#139" "$M7" "Runtime/Lifecycle" +create_issue guard-policy-async \ + "runtime: make the guard policy check async for live-state I/O" \ + "Convert the guard policy check to async so the real guard can simulate over live state and call remote analyzers without blocking the loop." \ + "$M7" "breaking" "Runtime/Lifecycle" +create_issue guard-derive-before-guard \ + "runtime: close the derive-before-guard escape and single-decode the body" \ + "Prevent derivation side-effects before the guard check and decode the body once so submit consumes the guard-vetted header." \ + "$M7" "security" "Runtime/Lifecycle" +create_issue guard-signing-boundary \ + "runtime: move the guard checkpoint to the signed transaction boundary" \ + "Add the guard checkpoint at the signed unsigned-tx boundary against the real identity backend, sharing one checkpoint with the guard engine." \ + "$M7" "security" "Runtime/Lifecycle" +# child order: policy-async, derive-before-guard, #52 (identity backend), signing-boundary +link_sub "#139" guard-policy-async +link_sub "#139" guard-derive-before-guard +ensure_existing "#52" "$M7" "Host Backends"; link_sub "#139" "#52" +link_sub "#139" guard-signing-boundary + +echo; echo "### M7 -- epic: capability and egress enforcement teeth" +create_issue epic-m6-egress-capability-teeth \ + "guard: capability and egress enforcement teeth" \ + "Bring http and messaging egress under real enforcement and align mock-grant fidelity so no capability escapes the compile-time world guarantee." \ + "$M7" "security,epic" "Runtime/Lifecycle" +create_issue guard-egress-cap-world-guarantee \ + "runtime: bring http egress under the compile-time world guarantee" \ + "Bring http egress under the synthesised-world guarantee and canonicalize a single adapter import-narrowing contract." \ + "$M7" "security" "Runtime/Lifecycle" +create_issue messaging-query-scope \ + "host: enforce the messaging query scope with the waku backend" \ + "Enforce the declared messaging scope on the query path, landed with the waku backend." \ + "$M7" "bug" "Host Backends" +create_issue mock-grant-fidelity \ + "host: align mock capability-grant fidelity to the real host grant" \ + "Reconcile the mock capability grant with the real host grant so a capability the host would deny is denied under mock." \ + "$M7" "debt" "Host Backends" +link_sub epic-m6-egress-capability-teeth guard-egress-cap-world-guarantee +link_sub epic-m6-egress-capability-teeth messaging-query-scope +link_sub epic-m6-egress-capability-teeth mock-grant-fidelity + +# ---- M8 : four new debt epics + the slim grant tracker #127 +echo; echo "### M8 -- epic: deferred videre abstraction concepts" +create_issue epic-m7-videre-deferred-concepts \ + "sdk: deferred videre abstraction concepts" \ + "The intentionally-parked videre abstractions (maker-side offer, taker-side RFQ firm-quote, and the venue-neutral materialiser) held until a real driving venue exists to shape them." \ + "$M8" "feature,epic" "SDK/DX" +create_issue rfq-firm-quote-additive \ + "wit: add an additive firm-quote field for RFQ venues" \ + "Add an additive firm-quote field to the quote record plus the accept/settle path, gated on a real RFQ venue." \ + "$M8" "feature,needs-design" "WIT/ABI" +create_issue materialiser-source-venue \ + "sdk: generalize the keeper into a venue-neutral materialiser" \ + "Generalize the keeper sweep assembler into a source-and-venue-neutral materialiser proven against two dissimilar pairs." \ + "$M8" "dx,needs-design" "SDK/DX" +ensure_existing "#355" "$M8" "WIT/ABI"; link_sub epic-m7-videre-deferred-concepts "#355" +link_sub epic-m7-videre-deferred-concepts rfq-firm-quote-additive +link_sub epic-m7-videre-deferred-concepts materialiser-source-venue + +echo; echo "### M8 -- epic: chain robustness and typed-fault debt" +create_issue epic-m7-chain-typed-fault-debt \ + "chain: robustness and typed-fault debt" \ + "Finish the typed-fault story across chain and the stub backends and clear the chain request-batch and backfill debt." \ + "$M8" "debt,epic" "Chain" +ensure_existing "#269" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#269" +ensure_existing "#288" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#288" +ensure_existing "#286" "$M8" "SDK/DX"; link_sub epic-m7-chain-typed-fault-debt "#286" +ensure_existing "#285" "$M8" "Observability"; link_sub epic-m7-chain-typed-fault-debt "#285" +ensure_existing "#289" "$M8" "Docs"; link_sub epic-m7-chain-typed-fault-debt "#289" +ensure_existing "#302" "$M8" "Chain"; link_sub epic-m7-chain-typed-fault-debt "#302" + +echo; echo "### M8 -- epic: runtime test-harness and performance debt" +create_issue epic-m7-runtime-test-perf-debt \ + "runtime: test-harness and performance debt" \ + "Host-internal debt: a multi-module test harness, a supervisor clock seam, lock performance, and state-seam batching." \ + "$M8" "debt,epic" "Runtime/Lifecycle" +ensure_existing "#283" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#283" +ensure_existing "#284" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#284" +ensure_existing "#280" "$M8" "Runtime/Lifecycle"; link_sub epic-m7-runtime-test-perf-debt "#280" +ensure_existing "#105" "$M8" "Storage"; link_sub epic-m7-runtime-test-perf-debt "#105" + +echo; echo "### M8 -- epic: messaging backend, docs and soak" +create_issue epic-m7-messaging-docs-soak \ + "host: messaging backend, docs and soak evidence" \ + "The deferred waku messaging backend and payload codec, the doc-consistency passes, and the unattended seven-day soak evidence." \ + "$M8" "feature,epic" "Host Backends" +ensure_existing "#152" "$M8" "Host Backends"; link_sub epic-m7-messaging-docs-soak "#152" +ensure_existing "#212" "$M8" "Host Backends"; link_sub epic-m7-messaging-docs-soak "#212" +ensure_existing "#341" "$M8" "Docs"; link_sub epic-m7-messaging-docs-soak "#341" +ensure_existing "#65" "$M8" "Tooling/Packaging"; link_sub epic-m7-messaging-docs-soak "#65" + +echo; echo "### M8 -- existing #127 (slim grant tracker; keeps epic label, no children)" +ensure_epic "#127" "$M8" "Docs" "docs: grant delivery plan and evidence tracker" +# children #121/#125 re-parented out (to M4 and M5); #127 references them in its body only. + +# ------------------------------------------------------------------ 6. re-parents summary (executed inline above) +echo; echo "## 6. native re-parents executed inline: #321->generic-host, #322->sdk-authoring, #330->#140, #121->cow-bugfixes, #125->operator-delivery" + +echo; echo "== done (DRY_RUN=$DRY_RUN). Review, then run: DRY_RUN=0 ./apply-plan.sh ==" diff --git a/docs/design/apply/bodies/adapter-supervision-sweeps.md b/docs/design/apply/bodies/adapter-supervision-sweeps.md new file mode 100644 index 00000000..d5888f0f --- /dev/null +++ b/docs/design/apply/bodies/adapter-supervision-sweeps.md @@ -0,0 +1,14 @@ +Fold venue adapters into the restart and poison-recovery sweeps and expose an `adapters_alive` liveness signal. Adapters currently boot once and install but are not in the sweeps (`supervisor.rs:61-66`), so a trapped adapter stays dead until the process restarts and the router only projects the trap to an internal-error. + +## Why +Because adapters are outside the recovery sweeps, a strategy cannot tell an unknown venue (never installed) from a venue that is temporarily dead (trapped but recoverable), and a trapped adapter never comes back on its own. Folding adapters into the sweeps recovers them automatically, and a liveness signal lets strategies distinguish the two cases. This falls out naturally from extracting the generic supervised-component primitive from `AdapterActor`. Part of milestone M2: Generic venue-agnostic host. Blocked by: Extract a generic supervised-component primitive from AdapterActor. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Fold venue adapters, as the generalized provider/component kind, into the restart and poison-recovery sweeps. +- Expose `adapters_alive` or an equivalent liveness signal. +- Distinguish unknown-venue from venue-temporarily-dead. + +## Done when +- Trapped adapters are swept and restarted. +- A liveness signal distinguishes unknown-venue from venue-temporarily-dead. +- A trap-to-recovery test is present. diff --git a/docs/design/apply/bodies/cleave-cow-venue.md b/docs/design/apply/bodies/cleave-cow-venue.md new file mode 100644 index 00000000..eebda77b --- /dev/null +++ b/docs/design/apply/bodies/cleave-cow-venue.md @@ -0,0 +1,18 @@ +Split crates/cow-venue into two: an orderbook-only venue and a separate composable-cow keeper. Today lib.rs re-exports both OrderBody (order.rs) and ComposableBody (composable.rs) from the same crate. + +## Why +The load-bearing rule is that the CoW venue is only the CoW orderbook: submit, quote, status, and cancel of an OrderBody on api.cow.fi, mapping orderbook errors to venue errors, with no knowledge of ComposableCoW, getTradeableOrderWithSignature, revert selectors, TWAP, or EthFlow. Mixing the composable keeper into the venue crate breaks that boundary and means a new CoW keeper cannot be written without dragging in composable machinery. This is a pure-Rust re-split with zero contract dependency, so it can land now. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: cow-onvidere-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Keep in crates/cow-venue: the orderbook only, OrderBody, the borsh codec, classification.toml/rs, and the orderbook client. +- Move to a separate composable-cow keeper crate/module: ComposableBody, the COMPOSABLE_COW address and topic-0, getTradeableOrderWithSignature, the revert-selector handling, LegacyRevertAdapter, and Verdict. +- Drop the Composable variant from the venue body. +- Add a CI gate that asserts the venue crate carries none of the composable symbols. +- Regenerate goldens. + +## Done when +- crates/cow-venue contains only orderbook concerns: OrderBody, the borsh codec, classification.toml/rs, and the orderbook client. +- ComposableBody, composable.rs, getTradeableOrderWithSignature, COMPOSABLE_COW, ConditionalOrderCreated, the revert-selector handling, LegacyRevertAdapter, and Verdict live in a separate composable-cow keeper crate/module. +- A CI gate asserts the venue crate has zero Composable*, getTradeableOrder, or revert-selector symbols. +- A new CoW keeper producing OrderBodys can be written without importing composable machinery. +- The workspace is green and goldens are regenerated. diff --git a/docs/design/apply/bodies/composable-poll-wire-swap.md b/docs/design/apply/bodies/composable-poll-wire-swap.md new file mode 100644 index 00000000..5a267cb1 --- /dev/null +++ b/docs/design/apply/bodies/composable-poll-wire-swap.md @@ -0,0 +1,19 @@ +Delete composable.rs and LegacyRevertAdapter and switch the composable-cow poll onto the fork's structured non-reverting getTradeableOrderWithSignature. This is gated on the ComposableCoW fork being deployed and must not be coupled to the keeper port. + +## Why +The structured non-reverting poll cannot be instantiated until the ComposableCoW fork is deployed: Verdict::Post is the one variant LegacyRevertAdapter never produces, and Verdict::NeedsInput is dead surface until IOrderModule and the fork land. This wire-swap is hard-blocked on the fork's deployments/networks.json being non-empty on a shepherd target chain. It must stay decoupled from the keeper port, otherwise the whole shepherd split freezes behind a third-party deployment clock. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: cow-onvidere-epic; composable-cow-keeper-port. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Delete composable.rs and LegacyRevertAdapter. +- Switch the poll onto the fork's structured non-reverting getTradeableOrderWithSignature. +- Fully populate Verdict::Post; wire and test the Verdict::NeedsInput arm. +- Model next_poll_timestamp as an Option or NextPoll rather than the 0 sentinel that collides with the fork wire. +- Delete the legacy host-extension surface in wit/shepherd-cow. + +## Done when +- The work proceeds only once the fork's deployments/networks.json is non-empty on a shepherd target chain. +- composable.rs and LegacyRevertAdapter are deleted. +- Verdict::Post is fully populated by the structured non-reverting poll. +- The Verdict::NeedsInput arm is wired and dispatch-tested. +- Verdict::Post.next_poll_timestamp is modelled as Option or NextPoll, not the 0 sentinel that collides with the fork wire. +- The legacy host-extension surface in wit/shepherd-cow is deleted. diff --git a/docs/design/apply/bodies/cow-idempotency-seam.md b/docs/design/apply/bodies/cow-idempotency-seam.md new file mode 100644 index 00000000..b6691056 --- /dev/null +++ b/docs/design/apply/bodies/cow-idempotency-seam.md @@ -0,0 +1,15 @@ +Settle how the CoW keeper obtains a deterministic identifier for its idempotency check before order assembly moves into the adapter. Today shepherd-sdk/src/cow/run.rs derives the client-side order UID and checks the submitted Journal before the network call. + +## Why +Once OrderCreation and UID assembly move into the adapter's submit, the keeper can no longer derive the UID pre-submit, which opens a double-post risk: on restart the keeper cannot tell whether it already posted an order. This must be settled before assembly moves, otherwise the idempotency check silently stops working across the keeper-to-adapter boundary. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: cow-onvidere-epic; Cleave cow-venue: orderbook-only venue vs composable-cow keeper. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Pick one mechanism: adapter.derive-header returns a deterministic intent-id the keeper journals, or SubmitOutcome carries a receipt equal to the UID. +- Re-route the Journal idempotency check onto that identifier. +- Add a regression test covering resubmit after restart. + +## Done when +- The keeper can derive or obtain a deterministic intent-id pre-submit without assembling OrderCreation itself. +- The chosen mechanism is implemented: either adapter.derive-header returns a deterministic intent-id the keeper journals, or SubmitOutcome carries a receipt equal to the UID. +- The submitted Journal check remains effective across the keeper-to-adapter boundary, with no double-post window. +- A regression test exercises resubmit-after-restart and asserts a single orderbook POST. diff --git a/docs/design/apply/bodies/epic-m0-p0-master-gate-fold.md b/docs/design/apply/bodies/epic-m0-p0-master-gate-fold.md new file mode 100644 index 00000000..ed036618 --- /dev/null +++ b/docs/design/apply/bodies/epic-m0-p0-master-gate-fold.md @@ -0,0 +1,9 @@ +This epic lands the host-intent decouple as the master gate and folds the whole reshape to a green, CI-verifiable tip while nothing is pinned yet. + +## Goal +Land the free in-monorepo reshape: the host-intent decouple, where the host event carries opaque status bytes, as the master gate, plus the single oracle-validated git-filter-repo fold to a green tip, so the acyclic split becomes CI-verifiable while nothing is pinned. + +## Scope +The host world currently imports the intent package, which makes an acyclic three-repo split physically impossible; this epic breaks that import first, then reshapes and re-versions the contract as one train-wide fold rather than as per-car edits. Alongside the decouple it stands up the CI invariant that keeps nexum-runtime venue-agnostic, keeps the egress guard advisory-only for now, and closes the busy-loop DoS on guard-deny. The work culminates in a single green linear tip that is the precondition for any later repository carve. + +Milestone: M1: Videre contract reshape and host-intent decoupling. diff --git a/docs/design/apply/bodies/epic-m0-videre-l2-contract.md b/docs/design/apply/bodies/epic-m0-videre-l2-contract.md new file mode 100644 index 00000000..7969c54a --- /dev/null +++ b/docs/design/apply/bodies/epic-m0-videre-l2-contract.md @@ -0,0 +1,9 @@ +This epic reshapes the pre-release intent contract into the videre venue abstraction that every later venue and keeper compiles against. + +## Goal +Reshape the pre-release intent WIT into videre: rename it, pin its surface, normalize it to 0.1.0, and add quoting, producing the venue-neutral settlement and quoting contract that every later venue and keeper compiles against, all riding the master-gate fold. + +## Scope +The intent packages are renamed into the videre namespace and their Rust symbols cleaned up, the type surface is pinned to a stable EVM-only shape, every package version string is reset to a single @0.1.0, and quoting is added to both faces of the contract. Together these produce a venue-neutral settlement and quoting contract that later venues and the keeper compile against. All of it rides the same oracle-validated fold as the master gate so the tip stays byte-identical across rebuild paths. + +Milestone: M1: Videre contract reshape and host-intent decoupling. diff --git a/docs/design/apply/bodies/epic-m1-generic-venue-agnostic-host.md b/docs/design/apply/bodies/epic-m1-generic-venue-agnostic-host.md new file mode 100644 index 00000000..7d5c8ff9 --- /dev/null +++ b/docs/design/apply/bodies/epic-m1-generic-venue-agnostic-host.md @@ -0,0 +1,9 @@ +Make the runtime host fully generic so that nothing venue, intent or cow shaped lives in the host layer. + +## Goal +Grow the extension seam so it can carry both worker and provider roles, pull the venue registry and the generic supervised-component primitive out of the adapter actor, and remove every hardcoded venue assumption from the host. Once this lands, the host holds venues, intents and cow logic only through generic registration points, never as privileged fields or baked-in table rows. + +## Scope +This epic extends `Extension` to contribute host services and provider kinds, turns the privileged `HostState.pool_router` field into an extension-owned `VenueRegistry` service, and extracts the fuel/trap/serialization/sweep machinery from `AdapterActor` into a reusable supervised-component primitive. It de-hardcodes the KNOWN capability table so per-namespace rows come from registered extensions, and factors world synthesis into a plain `nexum-world` library. It then lands a generic launcher library plus a bare `Ext=()` engine binary, and assembles the `videre-host` crate whose `videre::platform()` registers the venue provider-kind, the registry service, the egress guard seam and the client interface through the generalized seam. Together these pieces let the host boot with zero cow or venue dependencies while venues plug in as ordinary extensions. + +Milestone: M2: Generic venue-agnostic host. diff --git a/docs/design/apply/bodies/epic-m1-s1-venue-agnostic-gate.md b/docs/design/apply/bodies/epic-m1-s1-venue-agnostic-gate.md new file mode 100644 index 00000000..291d7590 --- /dev/null +++ b/docs/design/apply/bodies/epic-m1-s1-venue-agnostic-gate.md @@ -0,0 +1,9 @@ +Prove the host is venue-agnostic by landing a permanent zero-leak CI check and making it a blocking, required gate. + +## Goal +Enforce, forever, that the host layer never regains intent, venue or cow knowledge. The check greps the runtime crate for forbidden symbols and inspects its dependency graph for forbidden crate edges, and an echo-venue boot test acts as the oracle that the generic seam actually works. + +## Scope +This epic adds a permanent zero-leak and acyclicity CI check over `nexum-runtime`, then flips it from advisory to blocking once the `HostState.pool_router` field is deleted and the router is carried only through the generic services map. The forcing function is that an echo-venue must still install and route a submission with no intent or cow crate anywhere in the graph, which demonstrates the host is genuinely venue-agnostic rather than merely refactored. + +Milestone: M2: Generic venue-agnostic host. diff --git a/docs/design/apply/bodies/epic-m2-reth-alloy-dx-seams.md b/docs/design/apply/bodies/epic-m2-reth-alloy-dx-seams.md new file mode 100644 index 00000000..6025347d --- /dev/null +++ b/docs/design/apply/bodies/epic-m2-reth-alloy-dx-seams.md @@ -0,0 +1,9 @@ +Complete the guest-facing developer surface with the remaining host seams, an alloy provider over the chain host, and a cluster of alloy-grade DX polish. + +## Goal +Bring the guest SDK up to an alloy-grade standard. Fill in the identity, messaging and remote-store guest traits with mocks so modules can unit-test host-free, add richer local-store queries, put an alloy Provider seam over the raw chain request path, and land the polish cluster (typed fault mirror, typestate builders, sealed traits, single-source vocabularies) that removes the last of the hand-copied boilerplate. + +## Scope +Three of the six host interfaces still have no guest seam; this epic adds their traits and mocks and widens the host supertrait to cover them. It replaces the stringly chain request surface with an alloy Provider shim so authors call typed methods instead of hand-building JSON-RPC, and carries the typed chain method surface through to the guest. The remaining work is a polish cluster that mirrors the venue error into a typed fault, introduces a typestate order builder with typed token newtypes, seals the extension traits, and derives the mirrored vocabularies from single-source constants so the fault list and known table are no longer hand-maintained in several places. + +Milestone: M3: Videre SDK, macros and DX. diff --git a/docs/design/apply/bodies/epic-m2-videre-sdk-authoring.md b/docs/design/apply/bodies/epic-m2-videre-sdk-authoring.md new file mode 100644 index 00000000..2732a9ec --- /dev/null +++ b/docs/design/apply/bodies/epic-m2-videre-sdk-authoring.md @@ -0,0 +1,9 @@ +Deliver the blessed front door for venue and keeper authors: one SDK crate, one venue macro, one keeper macro, a typed venue client, and a conformance kit. + +## Goal +Give venue and keeper authors a single, clear authoring path. Ship videre-sdk with the generic keeper sweep assembler, one blessed venue macro and one blessed keeper macro backed by a typed venue client, and a conformance kit that holds every venue to portable wire vectors and header goldens. Leave room to add the second venue after the split without reworking the surface. + +## Scope +This epic renames and reshapes the existing venue-author SDK into videre-sdk and adds the generic Keeper::sweep assembler that wires the watch set, gates, source poll, retrier and journal together. On top of the crate it lands the two macros that authors actually write against: one that emits a typed venue adapter and one that drives a venue through a typed client, so authors never hand-write byte marshalling. The conformance kit rounds it out by turning wire-shape drift into a failing cargo test, with hardened goldens under the videre namespace. The pieces compose so a keeper compiles against videre-sdk alone, with no cow or host dependency. + +Milestone: M3: Videre SDK, macros and DX. diff --git a/docs/design/apply/bodies/epic-m3-cow-keeper-bugfixes.md b/docs/design/apply/bodies/epic-m3-cow-keeper-bugfixes.md new file mode 100644 index 00000000..a836852c --- /dev/null +++ b/docs/design/apply/bodies/epic-m3-cow-keeper-bugfixes.md @@ -0,0 +1,9 @@ +Carry the still-live TWAP and composable keeper correctness fixes onto the ported keeper. + +## Goal +Land the outstanding TWAP and composable keeper correctness fixes on the ported keeper: deduplication and retry classification, gate-marker leaks, revert-selector loops, the signature-race retry, and conditional-order removal, plus reconcile the grant deliverable divergence so the shipped keeper matches what was promised. + +## Scope +These are known-live correctness bugs in the current TWAP and composable keepers that must not be lost when the keeper is ported onto the generic venue seam. The work covers the retry and deduplication paths, leaked gate markers, tight loops on revert selectors, the race between signing and submission, and removal of stale conditional orders. Alongside the code fixes, it reconciles where the delivered behaviour diverged from the grant deliverable so the ported keeper is both correct and accountable to what was committed. + +Milestone: M4: CoW on the generic seam (the shepherd bundle). diff --git a/docs/design/apply/bodies/epic-m3-s1b-seam-gate.md b/docs/design/apply/bodies/epic-m3-s1b-seam-gate.md new file mode 100644 index 00000000..b8b79ec0 --- /dev/null +++ b/docs/design/apply/bodies/epic-m3-s1b-seam-gate.md @@ -0,0 +1,9 @@ +Close the seam gate: prove the flagship CoW keeper runs on the generic venue seam, then rewrite the reference docs to match. + +## Goal +Get the CoW keeper submitting through the videre venue client with the legacy cow-api host retired, and rewrite the source-of-truth docs so they describe the shipped venue as the reference example rather than the deprecated host-extension model. + +## Scope +This epic proves the generic L2 seam carries a real venue end to end, not just in principle. The keeper stops calling the cow-api host directly and instead goes through videre:venue/client, and the cow-api host extension is removed. Once the seam port is real, the architecture docs are rewritten so the venue persona is documented as shipped and venue adapters are named as the domain-extension mechanism, with cow-api reframed as a legacy read path. The two pieces move together: the code change makes the docs true, and the docs make the shipped design discoverable. + +Milestone: M4: CoW on the generic seam (the shepherd bundle). diff --git a/docs/design/apply/bodies/epic-m4-operator-delivery.md b/docs/design/apply/bodies/epic-m4-operator-delivery.md new file mode 100644 index 00000000..6ea5b063 --- /dev/null +++ b/docs/design/apply/bodies/epic-m4-operator-delivery.md @@ -0,0 +1,9 @@ +Land the operator-facing delivery that ships alongside the repo cut: green CI and CD, multi-chain provider configuration and deployment docs, container image packaging, and the real Swarm remote-store backend. + +## Goal +Give an operator everything needed to run the split system in production. That means continuous integration and deployment that stays green (including the sccache fork-PR fail-open fix), a multi-chain provider map with deployment documentation, ghcr container image packaging, and the real Swarm remote-store backend, which rides this epic purely for delivery convenience. + +## Scope +The delivery work happens in step with the physical carve so the freshly split repositories ship a runnable, documented product rather than just source. CI and CD are hardened first, closing the sccache fork-PR failure so pull requests from forks no longer break the pipeline. On top of a green pipeline the operator surface is filled in: a provider map that lets a single deployment target multiple chains, deployment documentation, and packaged ghcr images. The Swarm remote-store backend replaces any placeholder store with the real implementation so operators have a working persistence path from day one. + +Milestone: M5: The gated three-repo split. diff --git a/docs/design/apply/bodies/epic-m6-egress-capability-teeth.md b/docs/design/apply/bodies/epic-m6-egress-capability-teeth.md new file mode 100644 index 00000000..572adc0f --- /dev/null +++ b/docs/design/apply/bodies/epic-m6-egress-capability-teeth.md @@ -0,0 +1,9 @@ +Puts real enforcement teeth behind http and messaging egress and closes the fidelity gap between mock and host capability grants. + +## Goal +Bring http and messaging egress under genuine enforcement, and align the mock capability grant with the real host grant, so that no capability can escape the compile-time world guarantee or slip past a test that the host would reject. + +## Scope +This epic hardens the egress capability model on three fronts that must agree with one another. It brings venue http egress under the synthesised-world guarantee (or documents the allowlist-only story at the seam) and settles on a single adapter import-narrowing contract, so undeclared egress fails at build time rather than at runtime. It enforces the declared messaging scope on the query path, matching publish-scope enforcement, before the Waku backend makes the gap live. Finally it reconciles the mock capability grant with the host `CapabilityRegistry` so mock and host give identical grant and deny decisions for every KNOWN capability, ideally derived from one source of truth. + +Milestone: M7: Egress guard. diff --git a/docs/design/apply/bodies/epic-m7-chain-typed-fault-debt.md b/docs/design/apply/bodies/epic-m7-chain-typed-fault-debt.md new file mode 100644 index 00000000..dac2f189 --- /dev/null +++ b/docs/design/apply/bodies/epic-m7-chain-typed-fault-debt.md @@ -0,0 +1,9 @@ +Finishes the typed-fault story across chain and the stub backends and clears the outstanding chain request-batch and backfill debt. + +## Goal +Complete the typed-fault handling across the chain layer and its stub backends, and pay down the accumulated chain request-batching and backfill debt. + +## Scope +The chain layer and its stub backends carry an incomplete typed-fault story that needs finishing so faults surface as structured types rather than opaque errors. Alongside that, the request-batch and backfill paths have accrued debt that should be cleared to keep chain access efficient and correct. The work fits together as a single robustness pass over the chain seam. + +Milestone: M8: Post-v1 hardening and debt. diff --git a/docs/design/apply/bodies/epic-m7-messaging-docs-soak.md b/docs/design/apply/bodies/epic-m7-messaging-docs-soak.md new file mode 100644 index 00000000..93d5bf78 --- /dev/null +++ b/docs/design/apply/bodies/epic-m7-messaging-docs-soak.md @@ -0,0 +1,9 @@ +Delivers the deferred Waku messaging backend and payload codec, the doc-consistency passes, and the unattended seven-day soak evidence. + +## Goal +Land the parked Waku messaging backend and its payload codec, complete the documentation consistency passes, and produce the unattended seven-day soak evidence. + +## Scope +The Waku messaging backend and its payload codec were deferred and now need a real implementation behind the host messaging seam. In parallel, the documentation needs consistency passes so the shipped surface matches its description, and an unattended seven-day soak run provides the durability evidence that the runtime holds up over time. These strands close out the messaging and evidence debt for the post-v1 window. + +Milestone: M8: Post-v1 hardening and debt. diff --git a/docs/design/apply/bodies/epic-m7-runtime-test-perf-debt.md b/docs/design/apply/bodies/epic-m7-runtime-test-perf-debt.md new file mode 100644 index 00000000..3d19daff --- /dev/null +++ b/docs/design/apply/bodies/epic-m7-runtime-test-perf-debt.md @@ -0,0 +1,9 @@ +Pays down host-internal debt across the test harness, supervisor clock seam, lock performance, and state-seam batching. + +## Goal +Clear the host-internal runtime debt: a multi-module test harness, a supervisor clock seam, lock performance, and state-seam batching. + +## Scope +The runtime carries several pieces of internal debt that do not surface to venues but slow development and hurt performance. A multi-module test harness lets scenarios exercise more than one module at once, a supervisor clock seam makes lifecycle timing testable, and lock and state-seam batching work reduce contention and round-trips. Together these harden the host internals ahead of further venue work. + +Milestone: M8: Post-v1 hardening and debt. diff --git a/docs/design/apply/bodies/epic-m7-videre-deferred-concepts.md b/docs/design/apply/bodies/epic-m7-videre-deferred-concepts.md new file mode 100644 index 00000000..58bb88fd --- /dev/null +++ b/docs/design/apply/bodies/epic-m7-videre-deferred-concepts.md @@ -0,0 +1,9 @@ +Holds the videre abstractions deliberately parked until a real driving venue exists to shape them. + +## Goal +Carry the intentionally-deferred videre abstraction concepts to the point where a real venue can drive their final shape: the maker-side offer, the taker-side RFQ firm-quote, and the venue-neutral materialiser. + +## Scope +These abstractions were parked on purpose so their shapes are not guessed ahead of a concrete venue. The maker-side offer and the taker-side RFQ firm-quote sit on opposite sides of the same quoting story, and the firm-quote slots additively onto the existing quote record. The venue-neutral materialiser generalizes the keeper sweep assembler over both source and target venue. Each piece stays deferred until a second real venue and a settled keeper-to-pool port exist to prove it out. + +Milestone: M8: Post-v1 hardening and debt. diff --git a/docs/design/apply/bodies/gap-alloy-provider-seam.md b/docs/design/apply/bodies/gap-alloy-provider-seam.md new file mode 100644 index 00000000..5ba000f4 --- /dev/null +++ b/docs/design/apply/bodies/gap-alloy-provider-seam.md @@ -0,0 +1,15 @@ +Put an alloy Provider seam over the raw `ChainHost::request` path so guest strategies call typed provider methods instead of hand-building JSON-RPC, and carry the typed chain method surface through to the guest. + +## Why +Chain access today is a stringly `request(u64, &str, &str) -> String`, so authors hand-build JSON-RPC params and parse strings; this is the largest single DX gap from the alloy target. The promised `HostTransport: alloy Transport` shim is not in the SDK, and the closed chain method RPC enum exists host-side but is never carried to the guest. No other issue covers this. Part of milestone M3: Videre SDK, macros and DX. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add a `HostTransport` implementing the alloy `Transport` over `ChainHost::request`. +- Expose a guest `host.provider(Chain)` returning an alloy `Provider`. +- Carry the typed `ChainMethod` surface through to the guest. +- Add zero-cost `Chain` and `ChainId` newtypes. + +## Done when +- A guest strategy can call `host.provider(chain).get_block_number()` and `.call(&tx)` through an alloy `Provider` backed by `ChainHost`. +- The typed `ChainMethod` surface reaches the guest. +- No hand-rolled JSON-RPC remains at the call sites. diff --git a/docs/design/apply/bodies/gap-docs-source-of-truth-rewrite.md b/docs/design/apply/bodies/gap-docs-source-of-truth-rewrite.md new file mode 100644 index 00000000..88500dbe --- /dev/null +++ b/docs/design/apply/bodies/gap-docs-source-of-truth-rewrite.md @@ -0,0 +1,13 @@ +Rewrite docs/05 and docs/08 as source-of-truth: document the venue persona as shipped, name venue adapters as the domain-extension mechanism, and reframe cow-api as a legacy read path. + +## Why +The current docs are actively misleading, which makes them a cheap, high-return fix: docs/05 says the venue persona is not shipped when it is, and docs/08 documents only the deprecated Layer-3 host-extension model. The design decision further requires deleting the shepherd:cow and cow-api-as-adapter-extension ambiguity from the docs, keeping cow-api only as the legacy event-module read path. No other issue owns this affirmative rewrite; the migration-cruft deletion is handled separately. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: cow-api-retire. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- docs/05: document the venue persona as shipped, with the crate layout and a step-by-step walkthrough of authoring a venue on videre. +- docs/08: name venue adapters (#[videre::venue]) as THE domain-extension mechanism. +- docs/08: mark shepherd:cow and cow-api as the legacy read path and delete the adapter-extension ambiguity. + +## Done when +- docs/05 documents the shipped venue persona with an author-a-venue walkthrough. +- docs/08 names venue adapters as the extension mechanism and marks cow-api as the legacy read path with no adapter-extension ambiguity. diff --git a/docs/design/apply/bodies/gap-dx-polish-cluster.md b/docs/design/apply/bodies/gap-dx-polish-cluster.md new file mode 100644 index 00000000..ecfaa465 --- /dev/null +++ b/docs/design/apply/bodies/gap-dx-polish-cluster.md @@ -0,0 +1,20 @@ +Land the alloy-grade DX polish cluster as one umbrella: a typed venue fault mirror, a typestate order builder, uniform `#[non_exhaustive]`, sealed extension traits, single-source vocabularies, and removal of the hand-copied golden bridges. + +## Why +Several DX-polish items have no owner: operator logs still `{0:?}`-format the venue error and the `rate-limited{retry-after-ms}` detail does not survive the fold; the bare 12-field order body literal is error-prone; the fault vocabulary is hand-mirrored in three places and the known table is duplicated; and each venue adapter hand-copies roughly 80 lines of golden bridge boilerplate. Consolidating them removes the last of the copy-paste and makes the surface alloy-grade. Part of milestone M3: Videre SDK, macros and DX. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Mirror `VenueError` to a `VenueFault` with `Display`, an `IntoStaticStr` label and `From`, preserving `retry-after-ms`. +- Add an `Order` typestate builder to replace the bare 12-field `OrderBody` literal, plus CoW `SellToken` and `BuyToken` newtypes. +- Apply `#[non_exhaustive]` uniformly across the public error and label enums. +- Seal the extension traits (`Host`, `HostFault`, `RuntimeTypes`, `Runtime`, `IntentPool`) with a private `Sealed` supertrait. +- Derive the mirrored vocabularies from single-source consts, so the fault vocabulary and known table are emitted from one place. +- Remove the roughly 80-line `*_to_golden` bridge boilerplate. + +## Done when +- `VenueError` is mirrored to a `Display` and `IntoStaticStr` `VenueFault` that preserves `retry-after-ms`. +- The `Order` typestate builder and the sell and buy token newtypes exist. +- `#[non_exhaustive]` is uniform across the public error and label enums. +- The extension traits are sealed. +- The fault vocabulary and known table are emitted from single-source consts. +- The `*_to_golden` bridges are removed. diff --git a/docs/design/apply/bodies/gap-handshake-manifest-key-decision.md b/docs/design/apply/bodies/gap-handshake-manifest-key-decision.md new file mode 100644 index 00000000..81e70194 --- /dev/null +++ b/docs/design/apply/bodies/gap-handshake-manifest-key-decision.md @@ -0,0 +1,12 @@ +Decide the install-time handshake manifest key name (body_version versus a version-set field) and the supported-set match semantics. + +## Why +This is one of only two remaining open decisions: the precise manifest key name and the supported-set match semantics for the install-time handshake. The handshake implementation presumes this decision; the schema is videre's while Supervisor::install, which asserts agreement, lives in nexum-runtime and must stay venue-agnostic, so the videre-host install predicate supplies it. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Pin the manifest key name and the module-version-in-adapter-supported-set match semantics (exact-set versus range). +- Note where it slots: module and adapter manifests, asserted at install, fail-fast and logged. + +## Done when +- A one-page decision fixes the manifest key name and the supported-set match semantics. +- The install-time handshake implementation references it. diff --git a/docs/design/apply/bodies/gap-m1-green-tip-gate.md b/docs/design/apply/bodies/gap-m1-green-tip-gate.md new file mode 100644 index 00000000..c506169b --- /dev/null +++ b/docs/design/apply/bodies/gap-m1-green-tip-gate.md @@ -0,0 +1,12 @@ +Umbrella gate: drive the milestone to a single green linear dev/m1 tip before any repository carve begins. + +## Why +The plan has an explicit, un-owned gate: land the milestone's Rust amends (#249, #250, #251, #296), the #334 verdict-seam fixes, the install-time handshake, and the approved cars, to a single green linear dev/m1 tip. The carve must not begin until this tip exists, because carving mid-train triples the fold surgery across three repos. Several required cars are otherwise un-referenced: #249 supervisor missing-manifest error, #251 RateLimited fold test, #296 wit-bindgen 0.59 bump, and the #334 fixes (model Verdict::Post.next_poll_timestamp as an Option or NextPoll rather than a 0-sentinel, and add a NeedsInput dispatch test). Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Execute the reshape as one oracle-validated fold; Charge quota on guard-deny to close the busy-loop DoS. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Track landing of #249, #251, #296 plus the two #334 fixes plus the approved cars to one green linear tip (amend in place, no fold). + +## Done when +- dev/m1 is a green linear tip with #249, #251, #296 plus both #334 fixes plus approved cars merged. +- CI is green. +- The tip is explicitly signed off as the precondition for beginning the carve. diff --git a/docs/design/apply/bodies/gap-opaque-status-contract-spec.md b/docs/design/apply/bodies/gap-opaque-status-contract-spec.md new file mode 100644 index 00000000..e6ee6ad2 --- /dev/null +++ b/docs/design/apply/bodies/gap-opaque-status-contract-spec.md @@ -0,0 +1,14 @@ +Write a short design note or ADR that pins how the host event's opaque status bytes destructure, so the host-intent decouple can land correctly. + +## Why +The host-intent decouple drops `use nexum:intent/types.{receipt, intent-status}` from wit/nexum-host/types.wit and has the host event stream carry opaque status bytes, but how those bytes destructure is still an open decision: the exact wording and versioning scheme of the documented opaque-status destructuring contract is unresolved and ranks as a top risk. No other issue owns this design decision; the decouple is the implementation and cannot land correctly until the contract shape exists. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Define the wire form: a version discriminator plus a destructuring rule. +- Decide schema ownership: the bytes are host-emitted but their meaning is videre-owned. +- Land it as a short design note or ADR under docs/design/. + +## Done when +- A committed design note or ADR pins the opaque-status byte format: version discriminator, destructuring rule, and schema ownership. +- The host-intent decouple issue lists it as a dependency and cites it. +- The corresponding open item is closed. diff --git a/docs/design/apply/bodies/gap-p0-fold-tail-hygiene.md b/docs/design/apply/bodies/gap-p0-fold-tail-hygiene.md new file mode 100644 index 00000000..9d02d67b --- /dev/null +++ b/docs/design/apply/bodies/gap-p0-fold-tail-hygiene.md @@ -0,0 +1,16 @@ +Bundle the contract-hygiene items that must ride the reshape fold: the codec version discriminator, migration-cruft deletion, and the denied() doc caveat. + +## Why +The reshape enumerates several contract-hygiene items that must ride the fold but are not covered by any other issue: the surface pin sets the shape and the conformance kit lands too late for the fold. These are cheap now (echo-only pinning) and must precede the true 0.1.0 cut. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Pin the videre WIT surface. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add a codec version discriminator plus reject-unknown and a non-empty-vector assertion on the cross-language codec goldens (#297). +- Delete the migration cruft: docs/migration/0.1-to-0.2.md and the "Migration from 0.1" prose in docs/08-platform-generalisation.md. +- Add a MUST-NOT-retry doc caveat on venue-error.denied(). +- Verify the valid-until to valid-until-ms rename and the named-ERC-record lift actually land in the fold. + +## Done when +- Codec goldens carry a version discriminator, reject unknown versions, and assert a non-empty vector. +- docs/migration/0.1-to-0.2.md and the docs/08 migration prose are deleted. +- denied() has a MUST-NOT-retry doc. +- valid-until-ms and named ERC records are confirmed in-tree. diff --git a/docs/design/apply/bodies/gap-p0-wit-fold-execution.md b/docs/design/apply/bodies/gap-p0-wit-fold-execution.md new file mode 100644 index 00000000..ff91b39f --- /dev/null +++ b/docs/design/apply/bodies/gap-p0-wit-fold-execution.md @@ -0,0 +1,17 @@ +Land the entire contract reshape as one oracle-validated git-filter-repo/jj fold across the milestone train, rather than as per-car edits. + +## Why +Every reshape content change (the host-intent decouple, the videre rename, the version normalize, quote, the surface pin, plus the fold-tail hygiene) must land as a single train-wide fold. A WIT type touched in an early car is imported by all downstream cars, so editing car by car desyncs the stack. No other issue owns the mechanical fold execution: the range-limited git-filter-repo pass replayed across the stack, the jj-driven per-car rebases, mergiraf conflict resolution, videre-test golden regeneration, byte-identical tip-oracle re-assertion, and the single force-push. This is the proven keeper-rename template and it is load-bearing. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Decouple nexum:host from nexum:intent so the host event carries opaque status bytes; Spec the opaque-status destructuring contract; Rename the nexum:intent WIT packages and symbols to videre; Normalize all WIT packages to a single @0.1.0; Add quote to videre:venue and the IntentClient typestate; Pin the videre WIT surface; Fold-tail contract hygiene. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Assemble all reshape content changes on refactor/intent-contract-reshape. +- Run the fold across the milestone stack (#239 to #260 plus #334/#335). +- Regenerate goldens. +- Re-assert the tip oracle. +- Single force-push. + +## Done when +- The full reshape lands as one force-pushed fold. +- The tip oracle is byte-identical across the two rebuild paths. +- videre-test goldens are regenerated and green. +- All stack branches are MERGEABLE. diff --git a/docs/design/apply/bodies/gap-videre-host-platform-crate.md b/docs/design/apply/bodies/gap-videre-host-platform-crate.md new file mode 100644 index 00000000..3bd6b394 --- /dev/null +++ b/docs/design/apply/bodies/gap-videre-host-platform-crate.md @@ -0,0 +1,17 @@ +Build the `videre-host` crate and its `videre::platform()` entrypoint that registers the venue platform through the generalized runtime seam. The host-side issues grow the seam, extract the service and the generic component-kind, and fold adapters into the sweeps, but no issue yet owns the extension-layer crate assembly or the `videre::platform()` registration. + +## Why +Growing the seam is only useful if something registers a real venue platform through it. Nothing currently owns the guard row (`GuardPolicy`/`AllowAll` becoming a videre-owned `EgressGuard`), the venue-adapter and pool-host bindgens, `build_adapter_linker`, or the adapter path of `synthesize_venue`. This crate is that home: it makes videre a single extension registered via `builder.with_extension(videre::platform())`. Part of milestone M2: Generic venue-agnostic host. Blocked by: Grow the Extension seam to carry worker/provider roles; Extract PoolRouter to VenueRegistry as an extension-owned service; Extract a generic supervised-component primitive from AdapterActor. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Create `videre-host` as an extension-layer crate depending on `nexum-runtime` only. +- Register the venue-adapter `ProviderKind` and its install predicate (the body-versions handshake). +- Register the `VenueRegistry` service (the un-privileged former `PoolRouter`). +- Add the `EgressGuard` seam, advisory-only for this milestone. +- Register the `videre:venue/client` interface and land the venue-adapter and pool-host bindgens. +- Expose `videre::platform()` as the single registration entrypoint. + +## Done when +- `videre::platform()` registers the provider-kind, the `VenueRegistry` service, the `EgressGuard` seam and `videre:venue/client` via the seam. +- `videre-host` depends on `nexum-runtime` only. +- The echo-venue installs and a worker submits through it with the `HostState.pool_router` field deleted. diff --git a/docs/design/apply/bodies/guard-advisory-m1.md b/docs/design/apply/bodies/guard-advisory-m1.md new file mode 100644 index 00000000..afe1eb0d --- /dev/null +++ b/docs/design/apply/bodies/guard-advisory-m1.md @@ -0,0 +1,14 @@ +Keep the egress guard advisory-only for this milestone: keep AllowAllGuard as the default, feature-gate the pool import, and document the checkpoint as not yet enforcing. + +## Why +The egress guard is AllowAllGuard, a no-op (pool_router.rs lines 104-110), and the real guard is deferred wholly to the egress-guard epic. This milestone must not advertise a boundary it does not enforce. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Keep AllowAllGuard as the default guard. +- Feature-gate the nexum:intent/pool import so the advertised derive to guard to submit checkpoint is not shipped as enforcing in the default build. +- Document at the router seam and in venue docs that the checkpoint is advisory-only and not yet enforcing, with a forward pointer to the egress-guard epic. + +## Done when +- AllowAll is kept as the default. +- The pool import is feature-gated. +- The router seam and venue docs mark the checkpoint advisory-only for this milestone with a link to the guard epic. diff --git a/docs/design/apply/bodies/guard-deny-quota.md b/docs/design/apply/bodies/guard-deny-quota.md new file mode 100644 index 00000000..7869bb2f --- /dev/null +++ b/docs/design/apply/bodies/guard-deny-quota.md @@ -0,0 +1,13 @@ +Charge the caller's quota when the guard denies a submission, closing the free retry loop. + +## Why +When the guard denies a submission, the router does not charge the caller's quota, so a module can retry a denied submission in a tight loop for free: a DoS against the guard and router. It is latent today because only AllowAllGuard ships, but it is cheap to fix now and required the moment a real guard denies. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- On a guard-deny verdict, charge the caller's rate and quota exactly as an accepted submit would, before returning the denial. +- Add a test that a repeated denied submit exhausts quota rather than looping for free. + +## Done when +- Guard-deny charges quota. +- A repeated-deny loop is rate-limited. +- A regression test is present. diff --git a/docs/design/apply/bodies/guard-derive-before-guard.md b/docs/design/apply/bodies/guard-derive-before-guard.md new file mode 100644 index 00000000..14e00252 --- /dev/null +++ b/docs/design/apply/bodies/guard-derive-before-guard.md @@ -0,0 +1,14 @@ +Close two coupled defects in `pool_router.rs`: derivation running before the guard checkpoint (letting side effects escape policy) and a double-decode between the guard and submit. Decode the body once and feed the guard-vetted header straight into submit. + +## Why +Today the router runs the adapter's derive-header before `guard.check`, so any side effect performed during derivation escapes policy entirely; the honest fix is a guarded sub-world or moving derivation behind the checkpoint. Separately, the guard inspects the adapter's own derive-header output while submit re-decodes the body independently, a time-of-check-to-time-of-use gap: a buggy or hostile adapter can present a benign `gives` to the guard and settle something else. Passing the derived header into submit collapses this to a single decode. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Reorder or sandbox derivation so it cannot run side effects before `guard.check`. +- Decode the body once and thread the guard-vetted header through to submit, removing the independent re-decode. +- Add a divergent-re-decode test that proves an adapter cannot show one value to the guard and settle another. + +## Done when +- Derivation cannot side-effect before `guard.check`. +- The body is decoded once and submit consumes the guard-vetted header. +- A divergent-re-decode test proves no bypass. diff --git a/docs/design/apply/bodies/guard-egress-cap-world-guarantee.md b/docs/design/apply/bodies/guard-egress-cap-world-guarantee.md new file mode 100644 index 00000000..07de7848 --- /dev/null +++ b/docs/design/apply/bodies/guard-egress-cap-world-guarantee.md @@ -0,0 +1,13 @@ +Bring venue http egress under the compile-time synthesised-world guarantee and canonicalise a single adapter import-narrowing contract, retiring the blanket shim path. + +## Why +Two coupled gaps let egress escape the capability model. First, http escapes the compile-time guarantee: in the KNOWN table http has `import: None` (`world.rs:88-92`), so `wasi:http` is linked out-of-band and gated only by the `engine.toml` allowlist; the undeclared-capability-is-a-compile-error guarantee therefore covers chain, messaging, and logging but not http. Second, there are two adapter contracts: `export_venue_adapter!` imports chain and messaging unconditionally and leans on `wasm-tools` dead-import elision, while `synthesize_venue` narrows by construction. Either bring http under the synthesised-world guarantee or document loudly at the seam that http egress is allowlist-gated, and settle on one import-narrowing contract. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Bring http under the synthesised-world guarantee, or document at the seam that http egress is allowlist-gated only. +- Canonicalise one adapter contract that narrows imports by construction. +- Retire the blanket shim path that imports chain and messaging unconditionally and relies on dead-import elision. + +## Done when +- Undeclared http egress is a build error, or the allowlist-only story is documented at the seam. +- Exactly one adapter contract exists, with import narrowing by construction. diff --git a/docs/design/apply/bodies/guard-policy-async.md b/docs/design/apply/bodies/guard-policy-async.md new file mode 100644 index 00000000..8d7343fc --- /dev/null +++ b/docs/design/apply/bodies/guard-policy-async.md @@ -0,0 +1,14 @@ +Convert `GuardPolicy::check` (and the guard seam it fronts) from synchronous to async so the real guard can perform I/O without blocking the supervisor. + +## Why +`GuardPolicy::check` is synchronous, but the real guard performs I/O: simulate over provider-pool state, fact assembly, and possibly a remote analyzer or policy backend. None of that can be expressed by a sync trait without blocking the supervisor loop. The conversion must follow the project async-dispatch strategy: native async-fn-in-trait for static-dispatch guest traits, `async_trait` only for cold dyn boot paths, and keeping the dyn-required guard and service traits object-safe. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Make `GuardPolicy::check` and the guard seam it fronts async. +- Apply the async-dispatch split: native async-fn-in-trait for static-dispatch guest traits, `async_trait` only on cold dyn boot paths, guard and service traits kept object-safe. +- Update `AllowAllGuard` and all call sites to await the new signature. + +## Done when +- `GuardPolicy::check` is async and awaited without blocking the loop. +- The async-dispatch split matches the documented strategy. +- Green on MSRV 1.94. diff --git a/docs/design/apply/bodies/guard-signing-boundary.md b/docs/design/apply/bodies/guard-signing-boundary.md new file mode 100644 index 00000000..1d5ab4b3 --- /dev/null +++ b/docs/design/apply/bodies/guard-signing-boundary.md @@ -0,0 +1,13 @@ +Extend the guard checkpoint to cover the requires-signing class by placing it at the signed unsigned-tx / identity boundary, wired to the real keystore identity backend. + +## Why +The guard does not cover the requires-signing class at all. For that class the real value movement is the unsigned-tx calldata returned by submit and signed on the identity path, which is a stub today (`accounts()` returns `Ok(vec![])`). Identity signing lands together with the guard, so the checkpoint must sit at the signed unsigned-tx / identity boundary and gate against a real identity backend. The work must coordinate with the identity-boundary checkpoint under the guard epic so there is exactly one checkpoint, not two. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic, #52, #139. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add the guard checkpoint at the decoded unsigned-tx / identity boundary so requires-signing submits are gated. +- Wire the checkpoint to the real identity backend (#52) rather than the empty stub. +- Coordinate with the identity-boundary checkpoint child under #139 so a single shared checkpoint exists. + +## Done when +- Requires-signing submits are gated at the decoded unsigned-tx boundary against a real identity backend. +- A single checkpoint is shared with #139, not duplicated. diff --git a/docs/design/apply/bodies/host-backend-guest-seams.md b/docs/design/apply/bodies/host-backend-guest-seams.md new file mode 100644 index 00000000..dc0242ce --- /dev/null +++ b/docs/design/apply/bodies/host-backend-guest-seams.md @@ -0,0 +1,16 @@ +Add guest SDK seams and mocks for the identity, messaging and remote-store host interfaces, and wire them to the stub backends. These three of the six host interfaces currently have no guest seam. + +## Why +The identity, messaging and remote-store interfaces carry `adapter:None` in the macro known table, have no `*Host` trait and no `Mock*`, so the promise that each interface becomes a trait plus a mock is unfulfilled for all three. Without them, modules cannot unit-test host-free against these interfaces. Part of milestone M3: Videre SDK, macros and DX. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add `IdentityHost`, `MessagingHost` and `RemoteStoreHost` guest traits plus a `Mock*` for each. +- Make the bind macros recognise the three new traits and wire the corresponding bind-macro slices. +- Wire the three seams to the existing stub backends, with no change to backend liveness scope. +- Widen the `Host` supertrait to cover all six interfaces, or document opt-in subset supertraits. + +## Done when +- `IdentityHost`, `MessagingHost` and `RemoteStoreHost` guest traits plus their `Mock*` exist and are recognised by the bind macros. +- The three seams are wired to the stub backends. +- The `Host` supertrait covers all six interfaces, or the subset supertraits are documented. +- Modules can host-free unit-test against all three interfaces. diff --git a/docs/design/apply/bodies/host-extension-seam-roles.md b/docs/design/apply/bodies/host-extension-seam-roles.md new file mode 100644 index 00000000..0bfae525 --- /dev/null +++ b/docs/design/apply/bodies/host-extension-seam-roles.md @@ -0,0 +1,18 @@ +Grow the extension seam so it can register generic worker and provider roles instead of only adding host interfaces. Today `Extension { link, capabilities }` in `host/extension.rs` can hand a worker extra host interfaces, but it cannot register a component kind (`ModuleKind` is a hardcoded enum) or a host service (`PoolRouter` is a privileged field). + +## Why +The runtime should know only two generic roles: a worker, which the host pushes events at, and a provider, which the host holds behind a serialized actor. Baking component kinds into an enum and privileging a specific service as a named field is what forces venue and intent shapes into the host layer. `Extension` must instead contribute a namespace, capabilities, a link, a service and a provider, so that anything venue-specific plugs in through registration rather than through core edits. Part of milestone M2: Generic venue-agnostic host. Blocked by: host-r6-decouple. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Extend the `Extension` seam to contribute namespace, capabilities, link, service and provider. +- Add a type-erased `HostService` held on a typed per-namespace `HostState.services` map. +- Add a `ProviderKind` carrying a link plus an async install. +- Use native async-fn-in-trait for the hot static-dispatch guest traits; use `async_trait` only for the one cold dyn path, `ProviderKind::install`; keep `HostService` synchronous so it stays dyn-compatible. +- Preserve the existing worker boot path unchanged. + +## Done when +- The `Extension` seam carries namespace, capabilities, link, service and provider. +- `HostService` and `ProviderKind` traits exist. +- `HostState.services` is a typed per-namespace map. +- It compiles on MSRV 1.94 with native async-fn-in-trait for the hot traits and `async_trait` only for `ProviderKind::install`. +- The existing worker boot path is unchanged. diff --git a/docs/design/apply/bodies/host-generic-component-kind.md b/docs/design/apply/bodies/host-generic-component-kind.md new file mode 100644 index 00000000..4892e6f1 --- /dev/null +++ b/docs/design/apply/bodies/host-generic-component-kind.md @@ -0,0 +1,16 @@ +Extract the generic supervised-component and host-actor machinery out of `AdapterActor` into `nexum-runtime`, and collapse the hardcoded kind dispatch into a generic role loop. The supervisor currently hardcodes component kinds via `enum ModuleKind { EventModule | VenueAdapter }` with a `match kind` dispatch, and `AdapterActor` is a special-cased provider actor. + +## Why +Hardcoding component kinds and special-casing the adapter actor bakes venue shapes into the core and leaves provider components outside the recovery machinery. Extracting the reusable primitive (fuel refuel, trap-to-error projection, async-mutex serialization, and restart/poison-sweep membership) lets any provider component join the sweeps and lets the supervisor dispatch generically instead of by named kind. Part of milestone M2: Generic venue-agnostic host. Blocked by: Grow the Extension seam to carry worker/provider roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Extract the generic supervised-component and host-actor primitive from `AdapterActor` into `nexum-runtime`: fuel refuel, trap-to-error projection, async-mutex serialization, and sweep membership. +- Collapse the `match kind` dispatch into a generic role loop. +- Fold provider components into the restart and poison sweeps so they expose `adapters_alive`/`providers_alive`. +- Remove any venue-named kind arm from the core. + +## Done when +- The generic supervised-component and host-actor primitive is extracted from `AdapterActor` into `nexum-runtime` (fuel, trap projection, serialization, sweeps). +- The supervisor `match kind` is collapsed to a generic role loop. +- Provider components join the restart and poison sweeps and expose a liveness query. +- No venue-named kind arm remains in the core. diff --git a/docs/design/apply/bodies/host-generic-launcher-bin.md b/docs/design/apply/bodies/host-generic-launcher-bin.md new file mode 100644 index 00000000..ad08d5ff --- /dev/null +++ b/docs/design/apply/bodies/host-generic-launcher-bin.md @@ -0,0 +1,16 @@ +Extract a generic launcher library plus a bare `Ext=()` engine binary, and remove the backwards dependency from the CLI onto the cow host. The host charter calls for a generic launcher library (`nexum-launch`) and a bare engine binary (`nexum`), but the CLI composition root wires cow in directly (`launch.rs:16,47` via `shepherd_cow_host::extension` / `with_extensions`), making `nexum-cli` depend on `shepherd-cow-host`. + +## Why +`nexum-cli` depending on `shepherd-cow-host` is a backwards crate edge: a generic host binary should not reach into a specific downstream extension. Extracting `nexum-launch` and shipping a bare `Ext=()` binary gives a clean generic composition path, and moving the cow wiring into its own `shepherd` binary keeps the downstream composition root where it belongs. Part of milestone M2: Generic venue-agnostic host. Blocked by: Grow the Extension seam to carry worker/provider roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Extract a generic `nexum-launch` library that composes a runtime from a supplied extension list. +- Add a bare `nexum` binary with `Ext=()`. +- Remove the cow wiring from the generic path. +- Move the cow composition root into a separate `shepherd` binary. + +## Done when +- The generic `nexum-launch` library composes a runtime from a supplied extension list. +- A bare `Ext=()` `nexum` binary boots with zero cow or venue dependencies. +- No host-layer crate depends on `shepherd-cow-host`. +- The cow composition root is moved out to a `shepherd` binary. diff --git a/docs/design/apply/bodies/host-nexum-world-registry.md b/docs/design/apply/bodies/host-nexum-world-registry.md new file mode 100644 index 00000000..5fad9cf3 --- /dev/null +++ b/docs/design/apply/bodies/host-nexum-world-registry.md @@ -0,0 +1,18 @@ +Delete the baked venue rows from the KNOWN capability table so per-namespace rows come from registered extensions, and factor world synthesis into a new plain `nexum-world` library. The capability model and per-component world synthesis already shipped, but the KNOWN table still bakes a pool row (`world.rs:74`) and a `cow-api` to `shepherd:cow` row (`world.rs:80`), which leaks a downstream name into the host. + +## Why +As long as the KNOWN table hardcodes a pool row and a cow-api mapping, the host layer keeps knowledge of specific venues and cow. Sourcing per-namespace rows from registered extensions removes that knowledge, and extracting the synthesis and table into their own library gives the host a clean, venue-free capability core. Part of milestone M2: Generic venue-agnostic host. Blocked by: Grow the Extension seam to carry worker/provider roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Delete the baked pool and `cow-api` to `shepherd:cow` rows from the KNOWN table. +- Source per-namespace capability rows from registered extensions. +- Extract the `world.rs` synthesis and the KNOWN table into a new plain library, `nexum-world`. +- Rewrite `find_wit_root` from a workspace-ancestor walk to crate-local `wit/` plus `wit/deps` resolution. +- Keep `#[module]` in `nexum-module-macros`; leave venue and intent macros to `videre-macros`. + +## Done when +- The baked pool and `cow-api`/`shepherd:cow` rows are removed from the KNOWN table. +- Capability rows are registry-driven from registered extensions. +- World synthesis and the table are extracted into a plain `nexum-world` library. +- `find_wit_root` resolves crate-local `wit/`. +- No venue or cow string remains in `nexum-world`. diff --git a/docs/design/apply/bodies/host-r6-decouple.md b/docs/design/apply/bodies/host-r6-decouple.md new file mode 100644 index 00000000..da5021fe --- /dev/null +++ b/docs/design/apply/bodies/host-r6-decouple.md @@ -0,0 +1,16 @@ +Drop the intent import from the host world so the host event stream carries opaque status bytes instead of an intent-status-update variant. Today wit/nexum-host/types.wit line 8 does `use nexum:intent/types@0.1.0.{receipt, intent-status}` and the host event variant carries an intent-status-update, so the L1 host world imports the L2 intent package. + +## Why +Until that use is gone, nexum-runtime cannot compile without the L2 intent WIT and an acyclic three-repo split is physically impossible. This is the master gate: it moves first, before any crate moves. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Drop the `use nexum:intent/types` from wit/nexum-host/types.wit so nexum:host becomes a leaf package. +- Redefine the host event stream to carry opaque status bytes instead of the intent-status-update variant. +- Specify the versioned destructuring contract those bytes commit to. +- Regenerate goldens and re-assert the byte-identical tip oracle. + +## Done when +- nexum:host WIT no longer uses nexum:intent and is a leaf package. +- The host event carries opaque status bytes with a documented versioned destructuring contract. +- cargo tree -p nexum-runtime reaches no intent crate. +- Goldens and the tip oracle are re-validated. diff --git a/docs/design/apply/bodies/host-venue-registry-extract.md b/docs/design/apply/bodies/host-venue-registry-extract.md new file mode 100644 index 00000000..64b5fd42 --- /dev/null +++ b/docs/design/apply/bodies/host-venue-registry-extract.md @@ -0,0 +1,16 @@ +Turn the privileged `HostState.pool_router` field into an extension-owned `HostService` carried through the generic services map, and delete the named supervisor field. The router is currently a privileged field `HostState.pool_router` in `host/state.rs:54`, built and cloned through `supervisor.rs`. + +## Why +Once the seam can carry a service, the router no longer needs to be a privileged field: videre can register it as the renamed, un-privileged `VenueRegistry` behind `HostState.services[ns]`. Deleting `HostState.pool_router` and still booting the echo-venue is the forcing function that proves the host layer is intent-free. This issue covers the host half, holding the router only through the generic services map and removing the named field; the `VenueRegistry` implementation itself belongs to the extension. Part of milestone M2: Generic venue-agnostic host. Blocked by: Grow the Extension seam to carry worker/provider roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Delete the `HostState.pool_router` field. +- Carry the router only via `HostState.services` as a type-erased `HostService`, renamed `VenueRegistry`. +- Remove every `PoolRouter` symbol from `nexum-runtime/src`. +- Keep the echo-venue booting and routing a submit through the services map. + +## Done when +- The `HostState.pool_router` field is deleted. +- The router is carried only via `HostState.services` as a type-erased `HostService` renamed `VenueRegistry`. +- No `PoolRouter` symbol remains in `nexum-runtime/src`. +- The echo-venue still boots and routes a submit. diff --git a/docs/design/apply/bodies/host-wit-deps-flip-carve.md b/docs/design/apply/bodies/host-wit-deps-flip-carve.md new file mode 100644 index 00000000..af755985 --- /dev/null +++ b/docs/design/apply/bodies/host-wit-deps-flip-carve.md @@ -0,0 +1,16 @@ +Flip the nexum:host WIT resolution to crate-local wit-deps and physically extract nexum-runtime as its own L1 repository once the host is proven venue-agnostic. + +## Why +Today bindgen! and the venue macro resolve WIT through a workspace-ancestor walk into a shared ../../wit/* tree, which cannot survive a physical repo split. Once the runtime is proven venue-agnostic (zero-leak gate green), the runtime slice can be pulled out of the monorepo on crate-local WIT with its own semver. This requires the runtime generalization to land first and the free-reshape window to be closed before the cut. Part of milestone M5: The gated three-repo split. Blocked by: host-zero-leak-ci-gate; host-generic-launcher-bin. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Introduce wit-deps (deps.toml) for the runtime crate and check in lockfiles. +- Flip every bindgen! path list and the macro WIT-root off ../../wit/* to crate-local wit/ + wit/deps/. +- Perform a history-preserving git-filter-repo --path extraction of nexum-runtime as the L1 repository, reusing the keeper-rename template (byte-identical tip oracle plus jj/mergiraf). +- Adopt independent per-package semver for nexum:host (@0.1.x). + +## Done when +- nexum-runtime builds against crate-local wit/ + wit/deps with lockfiles checked in. +- The history-preserving git-filter-repo carve yields a standalone L1 repo passing the byte-identical tip oracle. +- The zero-leak gate is green in the carved repo. +- nexum:host is on independent semver. diff --git a/docs/design/apply/bodies/host-zero-leak-ci-gate.md b/docs/design/apply/bodies/host-zero-leak-ci-gate.md new file mode 100644 index 00000000..790096e9 --- /dev/null +++ b/docs/design/apply/bodies/host-zero-leak-ci-gate.md @@ -0,0 +1,15 @@ +Add a permanent CI check that fails if the host regains any intent, venue or cow knowledge, and wire it as a required check. Once the host is generic, the venue-agnostic invariant must be enforced permanently rather than left to review. + +## Why +A one-time refactor can silently regress: a later change could reintroduce an intent or venue symbol or a forbidden crate edge, quietly re-coupling the host. A grep-plus-dependency-graph check turns the venue-agnostic invariant into an enforced, permanent property. Part of milestone M2: Generic venue-agnostic host. Blocked by: Extract PoolRouter to VenueRegistry as an extension-owned service; De-hardcode the KNOWN capability table and extract world synthesis to nexum-world; Extract a generic supervised-component primitive from AdapterActor. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add a CI check that greps `crates/nexum-runtime/src` for `nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter` and fails on any hit. +- Run `cargo tree -p nexum-runtime` and fail if it reaches any videre, intent or cow crate. +- Wire it as a required check. +- Document the invariant in the crate charter. + +## Done when +- CI has a required check that greps `nexum-runtime/src` for intent, venue and cow symbols and runs `cargo tree -p nexum-runtime` for forbidden crate edges, failing on any hit. +- The check is green at the generalized tip. +- The invariant is documented in the crate charter. diff --git a/docs/design/apply/bodies/materialiser-source-venue.md b/docs/design/apply/bodies/materialiser-source-venue.md new file mode 100644 index 00000000..ff1096a2 --- /dev/null +++ b/docs/design/apply/bodies/materialiser-source-venue.md @@ -0,0 +1,13 @@ +Generalize the keeper sweep assembler into a fully source-agnostic and venue-agnostic Materialiser that materialises a source's outcomes onto any venue. Today the generic Keeper::sweep assembler resolves the outcome and gives strategy authors an assembler over the parts, but it is not yet venue-neutral. + +## Why +The venue-neutral Materialiser is the explicit destination past the first stable runtime: a single assembler that drives any source's outcomes onto any target venue with no venue-specific branches. It needs a second real venue and a settled keeper-to-pool port before it can be generalized safely, so it stays deferred until those exist. Part of milestone M8: Post-v1 hardening and debt. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Generalize the Keeper::sweep assembler to a Materialiser parameterized over both source and target venue. +- Share the common Sweep outcome across the parameterizations. +- Prove venue-neutrality against at least the CoW keeper and one second venue. + +## Done when +- Materialiser drives two distinct (Source, Venue) pairs through one assembler. +- The materialiser contains no venue-specific branches. diff --git a/docs/design/apply/bodies/messaging-query-scope.md b/docs/design/apply/bodies/messaging-query-scope.md new file mode 100644 index 00000000..60b39eb9 --- /dev/null +++ b/docs/design/apply/bodies/messaging-query-scope.md @@ -0,0 +1,13 @@ +Enforce the declared messaging scope on the `messaging.query` path, rejecting out-of-scope queries the same way publish scope is enforced. Land it with the Waku backend. + +## Why +The `messaging.query` path is not scope-checked, so a module or adapter can query messaging outside its declared scope. This is latent only because the messaging backend is a stub today; the hole goes live the moment the Waku backend lands. Enforcement must be consistent with how publish scope is already enforced and must ship with, or ahead of, the Waku backend. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Enforce the declared messaging scope on `messaging.query`, denying out-of-scope queries. +- Match the enforcement model already used for publish scope. +- Wire the enforcement into the Waku backend as it lands. + +## Done when +- Out-of-scope `messaging.query` is denied and in-scope queries succeed. +- Enforcement is wired into the Waku backend with tests for both cases. diff --git a/docs/design/apply/bodies/mock-grant-fidelity.md b/docs/design/apply/bodies/mock-grant-fidelity.md new file mode 100644 index 00000000..8288bbef --- /dev/null +++ b/docs/design/apply/bodies/mock-grant-fidelity.md @@ -0,0 +1,14 @@ +Reconcile the mock capability grant with the host `CapabilityRegistry` grant so a capability the host would deny is also denied under mock, ideally deriving both from the KNOWN capability table. + +## Why +The mock capability grant diverges from the host's real grant, so tests can pass while real enforcement differs; this fidelity gap hides capability regressions. As the real guard replaces the shims, the mock and host grant must be canonicalised to one behaviour, ideally sourced from a single source of truth: the KNOWN capability table. Part of milestone M7: Egress guard. Blocked by: egress-guard-hardening-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Reconcile the mock capability-grant behaviour with the host `CapabilityRegistry` grant. +- Derive both grant decisions from one source of truth, the KNOWN capability table, where practical. +- Add a skew-guard test that fails if mock and host diverge on any KNOWN capability. + +## Done when +- Mock and host agree on grant or deny for every KNOWN capability. +- A skew-guard test exists. +- No mock-only pass survives that the host would reject. diff --git a/docs/design/apply/bodies/p0-acyclicity-scaffold.md b/docs/design/apply/bodies/p0-acyclicity-scaffold.md new file mode 100644 index 00000000..8e2985b8 --- /dev/null +++ b/docs/design/apply/bodies/p0-acyclicity-scaffold.md @@ -0,0 +1,15 @@ +Add a CI job and a local command that assert nexum-runtime (L1) is venue-agnostic and knows nothing about intents, venues, or CoW. It ships advisory (non-blocking) now, with a tracked promotion to a blocking gate later. + +## Why +The entire split rests on one invariant: nexum-runtime knows nothing about intents, venues, or CoW. Deleting the HostState.pool_router field is the forcing-function acceptance test for that invariant, and the invariant needs to be continuously checkable long before the physical cut. Part of milestone M1: Videre contract reshape and host-intent decoupling. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add a CI job plus a just/cargo xtask entrypoint that asserts L1 is venue-agnostic. +- cargo tree -p nexum-runtime must not reach videre-*, intent, or cow crates. +- An rg symbol scan must return empty. +- Assert the WIT DAG resolves with nexum:host as a leaf. +- Land it advisory (non-blocking) now, with a tracked flip to a blocking gate. + +## Done when +- CI and a local command assert the cargo tree and rg symbol checks on nexum-runtime and the leaf-ness of nexum:host. +- The checks are wired advisory now with a tracked flip-to-blocking recorded. diff --git a/docs/design/apply/bodies/rfq-firm-quote-additive.md b/docs/design/apply/bodies/rfq-firm-quote-additive.md new file mode 100644 index 00000000..ff27443b --- /dev/null +++ b/docs/design/apply/bodies/rfq-firm-quote-additive.md @@ -0,0 +1,15 @@ +Add an optional firm quote to the videre quote record so a market-maker or RFQ venue can return a signed, time-limited firm price the taker accepts. Today videre 0.1 quoting returns only a plain indicative quote record. + +## Why +An RFQ venue does not return an indicative price: it returns a signed, time-limited firm price that the taker accepts and settles against. This is the smaller taker-side counterpart to the maker-side offer work, and it slots into the existing quote record additively as a firm: option field rather than a new interface. It stays deferred until a real RFQ venue exists so the firm-quote shape is not guessed. Part of milestone M8: Post-v1 hardening and debt. Blocked by: #355. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add firm: option to the videre:types quote record, present only for RFQ venues. +- Add the accept and settle path on the client and adapter faces. +- Keep the change additive and EVM-only. +- Gate the work on a real RFQ venue appearing. + +## Done when +- quote.firm is added additively and is present only for RFQ venues. +- A real RFQ venue exercises the firm quote against goldens. +- The indicative quote path is unchanged, with no breaking change. diff --git a/docs/design/apply/bodies/risk-value-flow-freeze-hold.md b/docs/design/apply/bodies/risk-value-flow-freeze-hold.md new file mode 100644 index 00000000..f1175e10 --- /dev/null +++ b/docs/design/apply/bodies/risk-value-flow-freeze-hold.md @@ -0,0 +1,21 @@ +Keep every `videre:*` WIT package additively extensible through the repo cut, and hold the `videre:value-flow` 1.0 freeze until a genuine second-protocol venue has compiled and passed videre-test against the split videre-sdk. This is a tracking issue that makes the freeze-hold mitigation explicit and checkable. + +## Why +The three repos are cut before a real second venue exists, so a wrong `videre` abstraction is discovered only after the cut and becomes a cross-repo change rather than an in-monorepo fold. The accepted trade-off is to keep the correction cheap: every `videre:*` package (types, venue, value-flow) stays additively extensible with no field frozen or removed, so a later correction is a non-breaking additive cross-repo change; and the `videre:value-flow` 1.0 freeze is not applied until the post-cut second venue has proven the abstraction and fed back any shape corrections. The cross-repo WIT versioning has no teeth during the transition (a mispinned tag silently drifts), so the dependency-sync and semver CI check must stay enforcing through the correction window, and the operational ripple of any additive fix must have a documented runbook and a reserved rework budget. Part of milestone M6: Second-venue acceptance and vocabulary freeze. Blocked by: s2-three-carves. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Keep every `videre:*` package (types, venue, value-flow) additively extensible through the cut: no field is frozen or removed, so a post-cut correction is a non-breaking additive cross-repo change. +- Hold the `videre:value-flow` 1.0 freeze (#330) until the second-protocol venue (#140) has compiled and passed videre-test against the split videre-sdk and fed back any shape corrections; do not apply the freeze earlier. +- Own the operational ripple of any post-cut additive `videre:*` correction with a documented runbook: re-tag `videre`, re-pin the git-tag/registry version in nexum-runtime, shepherd, and the second-venue repo, regenerate goldens cross-repo, then re-run the byte-identical tip oracle per repo. +- Reserve an abstraction-rework budget for landing such a correction. +- Keep the dependency-sync and semver CI check enforcing through the correction window so an additive re-pin cannot silently drift. +- Confirm at the cut that `videre:*` carries no freeze markers, and sign off the freeze-gate on the second venue's acceptance. + +## Done when +- A tracked checklist asserts `videre:*` is additively extensible with no freeze markers at the cut. +- The `videre:value-flow` 1.0 freeze (#330) is blocked until the post-cut second venue (#140) proves the abstraction. +- Any post-cut `videre:*` fix is landed as an additive, non-breaking cross-repo change. +- A documented re-tag then re-pin (nexum-runtime, shepherd, second-venue repo) then regenerate-goldens-cross-repo then re-run-tip-oracle-per-repo runbook exists. +- An abstraction-rework budget is reserved for the correction window. +- The dependency-sync and semver CI check is confirmed to stay enforcing through the correction window so an additive re-pin cannot silently drift. +- Signed off before the repo carve and before the vocabulary freeze. diff --git a/docs/design/apply/bodies/s1-gate-runtime-venue-agnostic.md b/docs/design/apply/bodies/s1-gate-runtime-venue-agnostic.md new file mode 100644 index 00000000..f4c07d38 --- /dev/null +++ b/docs/design/apply/bodies/s1-gate-runtime-venue-agnostic.md @@ -0,0 +1,14 @@ +Prove `nexum-runtime` is venue-agnostic by deleting the privileged router field and flipping the zero-leak CI check from advisory to blocking. This is a go/no-go gate before repos are cut. + +## Why +Cutting repos before the host is proven venue-agnostic would freeze an intent-shaped host into a repo boundary, which is expensive to undo later. The forcing function is deleting the `HostState.pool_router` field (`state.rs:54`) and carrying the router in a composite `Ext` lattice instead; if the host still boots an echo-venue with no intent or cow crate in the graph, it is genuinely generic. Part of milestone M2: Generic venue-agnostic host. Blocked by: p0-acyclicity-scaffold; CI gate: nexum-runtime has zero venue/intent/cow symbols. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Delete the `HostState.pool_router` field and carry the router in a composite `Ext` lattice. +- Promote the acyclicity and zero-leak CI check from advisory to blocking. +- Add an echo-venue boot integration test as the oracle. + +## Done when +- The `pool_router` field is deleted. +- The zero-leak CI check is blocking and green on `nexum-runtime`. +- The echo-venue boots through the generalized seam with no intent or cow crate in the graph. diff --git a/docs/design/apply/bodies/s1b-gate-cow-on-generic-seam.md b/docs/design/apply/bodies/s1b-gate-cow-on-generic-seam.md new file mode 100644 index 00000000..fe114f02 --- /dev/null +++ b/docs/design/apply/bodies/s1b-gate-cow-on-generic-seam.md @@ -0,0 +1,16 @@ +Flip the flagship CoW keeper onto the generic venue seam: change run.rs from CowApiHost::submit_order(...) to pool.submit(CowVenue::ID, cow_body_bytes), retiring the CowApiHost trait and the cow-api host extension. + +## Why +The split must not codify the generic L2 contract into a repo boundary while the flagship CoW venue still bypasses it. This is the go/no-go gate that proves the generic seam carries a real venue: the keeper submits through videre:venue/client rather than the CoW-specific host, and the cow-api host extension is removed. Note the fork-gate boundary: the Rust seam re-split lands now, and only the poll wire-swap is fork-blocked, so this gate must not be coupled to the fork-gated poll swap. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: s1-gate-runtime-venue-agnostic; cow-venue-cdylib; composable-cow-keeper-port; cow-api-retire. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Flip run.rs from CowApiHost::submit_order(...) to pool.submit(CowVenue::ID, cow_body_bytes). +- Retire the CowApiHost trait and the cow-api host extension. +- Keep this gate to the seam port only; do not couple it to the fork-gated poll swap. + +## Done when +- The CoW adapter cdylib is on videre:adapter. +- The keeper submits through videre:venue/client, not CowApiHost. +- The venue-crate symbol gate is green. +- The idempotency seam is in place. +- The seam port is not coupled to the fork-gated poll swap. diff --git a/docs/design/apply/bodies/s2-cut-gate-checklist.md b/docs/design/apply/bodies/s2-cut-gate-checklist.md new file mode 100644 index 00000000..5d9dc69d --- /dev/null +++ b/docs/design/apply/bodies/s2-cut-gate-checklist.md @@ -0,0 +1,18 @@ +A single tracking and checklist issue, plus a short pre-carve runbook, that must be closed before the three carves may start, asserting that the two cut gates are green. + +## Why +The physical repo cut is gated (refactor now, cut later) on two conditions only: the runtime is venue-agnostic (zero-leak gate blocking and green, pool_router deleted); and a real shepherd cow-venue cdylib exists with the keeper ported off CowApiHost onto videre:venue/client. Per the 2026-07-15 decision the cut is not gated on a second venue: the genuine non-cow second-protocol venue is de-gated from the cut and becomes a post-cut acceptance milestone built against the already-split videre-sdk, so it can no longer freeze the carve behind an unbuilt venue. The risk this carries is that a wrong videre abstraction discovered by the post-cut second venue becomes a cross-repo change rather than an in-monorepo fold, so videre:* must stay additively extensible through the cut and the videre:value-flow 1.0 freeze must hold until the second venue proves the abstraction. Part of milestone M5: The gated three-repo split. Blocked by: s1-gate-runtime-venue-agnostic; s1b-gate-cow-on-generic-seam. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Track the runtime-venue-agnostic gate: zero-leak gate blocking and green, pool_router deleted. +- Track the cow-on-generic-seam gate: a real shepherd cow-venue cdylib with the keeper ported off CowApiHost onto videre:venue/client. +- Confirm all planned WIT reshapes are complete. +- Write and sign off a short pre-carve runbook. +- Confirm videre:* is additively extensible so a post-cut abstraction fix is a non-breaking cross-repo change. + +## Done when +- Both cut gates (runtime venue-agnostic; cow on the generic seam) are closed green. +- All planned WIT reshapes are confirmed complete. +- The pre-carve runbook is signed off. +- The second-venue gate is explicitly not required pre-carve; it is post-cut acceptance under this milestone. +- videre:* is confirmed additively extensible so a post-cut abstraction fix is a non-breaking cross-repo change. diff --git a/docs/design/apply/bodies/s2-three-carves.md b/docs/design/apply/bodies/s2-three-carves.md new file mode 100644 index 00000000..bce923fb --- /dev/null +++ b/docs/design/apply/bodies/s2-three-carves.md @@ -0,0 +1,17 @@ +Execute the physical cut as three history-preserving git-filter-repo extractions, one per repo, run as a single coordinated operation once the cut gates are green. + +## Why +This is the physical cut. Once the cut gates are green, three git-filter-repo --path extractions preserve history, one per repo, reusing the keeper-rename template: range-limited git-filter-repo plus a byte-identical tip oracle plus jj/mergiraf. Cross-repo Rust is wired via git-tag pins and WIT via wit-deps git tags, and videre:* is kept additively extensible so the post-cut second venue can drive a non-breaking cross-repo fix. Part of milestone M5: The gated three-repo split. Blocked by: Cut go/no-go gate: assert runtime venue-agnostic and cow on the generic seam before any carve; Transitional path-dep cargo workspace in the three groupings (git-tag pin path plus dep-sync CI); WIT cross-repo consumption: wit-deps flip, git-tag sourcing, wkg/OCI registry convergence. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Carve nexum-runtime (L1): crates/nexum-runtime, nexum-sdk, nexum-sdk-test, nexum-world, nexum-module-macros, nexum-launch, the bare nexum bin, wit/nexum-host. +- Carve videre (L2): videre-sdk, videre-test, videre-macros, videre-host, wit/videre-*, echo-venue/echo-client. +- Carve shepherd (L3, the shepherd bundle): cow-venue, shepherd-sdk (absorbed into the bundle), shepherd-cow-host, shepherd-sdk-test, shepherd-backtest, the shepherd bin (a nexum-runtime host), wit/shepherd-cow. +- Wire cross-repo Rust via git-tag pins and WIT via wit-deps git tags. +- Keep videre:* additively extensible so the post-cut second venue can drive a non-breaking cross-repo fix. + +## Done when +- Three history-preserving repos are carved (byte-identical tip oracle per repo, full history preserved): nexum-runtime, videre, and shepherd (the shepherd bundle, shepherd-sdk absorbed). +- Each repo builds standalone on pinned cross-repo deps. +- The acyclic DAG holds. +- The L1 zero-leak gate is green in the nexum-runtime repo. diff --git a/docs/design/apply/bodies/s2-transitional-workspace.md b/docs/design/apply/bodies/s2-transitional-workspace.md new file mode 100644 index 00000000..098f6c79 --- /dev/null +++ b/docs/design/apply/bodies/s2-transitional-workspace.md @@ -0,0 +1,14 @@ +Reorganize the monorepo crates into the three prospective repo groupings as a single cargo workspace with intra-grouping path-deps, and define how cross-repo dependencies will be sourced after the carve. + +## Why +The split must not lose the single hoisted dependency table, the shared Cargo.lock, or atomic folds. The mitigation is a transitional umbrella superproject with path-deps that converges to git-tag pins and then crates.io, so the monorepo can keep building as one workspace right up to the physical cut. Part of milestone M5: The gated three-repo split. Blocked by: s1-gate-runtime-venue-agnostic; s1b-gate-cow-on-generic-seam. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Reorganize the monorepo crates into the three prospective groupings as workspace members with intra-grouping path-deps. +- Define the post-carve cross-repo Rust dependency medium: git-tag pins first, with a documented path to crates.io. +- Add a dep-sync CI check. + +## Done when +- The three-grouping path-dep workspace builds with the acyclic crate DAG verified. +- The cross-repo dependency medium is documented (git-tag to crates.io). +- The dep-sync CI check is green and enforcing. diff --git a/docs/design/apply/bodies/s2-wit-cross-repo-consumption.md b/docs/design/apply/bodies/s2-wit-cross-repo-consumption.md new file mode 100644 index 00000000..4242e958 --- /dev/null +++ b/docs/design/apply/bodies/s2-wit-cross-repo-consumption.md @@ -0,0 +1,15 @@ +Move all WIT resolution to crate-local wit-deps and source cross-package WIT from pinned git tags, so each repo resolves its own WIT plus its cross-repo dependencies after the carve. + +## Why +Today every bindgen! and the venue macro resolve WIT via a workspace-ancestor walk into a shared ../../wit/* tree. After the carve each repo must resolve its own WIT plus cross-repo deps, following the dependency DAG nexum:host (leaf) <- videre:value-flow <- videre:intent / videre:venue <- videre:adapter <- shepherd:cow. Part of milestone M5: The gated three-repo split. Blocked by: Transitional path-dep cargo workspace in the three groupings (git-tag pin path plus dep-sync CI). See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Introduce wit-deps (deps.toml) per prospective repo; flip every bindgen! path list and the macro WIT-root; rewrite find_wit_root (lib.rs:512). +- Source cross-package WIT from git tags and check in the lockfiles. +- Document the convergence to wkg/OCI plus per-package semver. + +## Done when +- All bindgen and macro WIT resolution is crate-local (wit/ + wit/deps/). +- Cross-repo WIT is sourced from pinned git tags with lockfiles checked in. +- The acyclic DAG builds both in-repo and cross-repo. +- The registry and semver policy is documented. diff --git a/docs/design/apply/bodies/shepherd-cow-event-abi-wits.md b/docs/design/apply/bodies/shepherd-cow-event-abi-wits.md new file mode 100644 index 00000000..cde17aa8 --- /dev/null +++ b/docs/design/apply/bodies/shepherd-cow-event-abi-wits.md @@ -0,0 +1,15 @@ +Consolidate the CoW on-chain event ABIs the keepers watch into the shepherd-cow WIT package so they are owned only at the shepherd L3 repo. + +## Why +All CoW protocol knowledge, including the on-chain event ABIs the keepers decode, belongs to the shepherd L3 repo and must never leak into L1 or L2. Today the ABI surfaces are entangled with the legacy host-extension surface that is retiring. Consolidating them under wit/shepherd-cow gives a single L3-owned CoW WIT surface and keeps the generic layers free of CoW specifics. Part of milestone M4: CoW on the generic seam (the shepherd bundle). Blocked by: cow-onvidere-epic. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Consolidate the CoW event-ABI surfaces under wit/shepherd-cow: the ConditionalOrderCreated topic-0, EthFlow.OrderPlacement, and any other CoW on-chain event surfaces the keepers decode. +- Ensure the composable-cow and ethflow keepers resolve their event ABIs from this package. +- Mark the legacy host-extension interface in shepherd:cow as retiring; it is deleted at the fork-gated poll wire-swap. + +## Done when +- The shepherd-cow event-ABI WITs (ConditionalOrderCreated, EthFlow.OrderPlacement, and any other CoW on-chain event surfaces the keepers decode) live under wit/shepherd-cow and are consumed only by L3 crates. +- No videre:* or nexum:host package uses shepherd:cow. +- The composable-cow and ethflow keepers resolve their event ABIs from this package. +- The legacy host-extension surface in shepherd:cow is clearly marked as retiring. diff --git a/docs/design/apply/bodies/videre-body-versions-handshake.md b/docs/design/apply/bodies/videre-body-versions-handshake.md new file mode 100644 index 00000000..24c792d0 --- /dev/null +++ b/docs/design/apply/bodies/videre-body-versions-handshake.md @@ -0,0 +1,16 @@ +Add an install-time handshake so a keeper and its adapter agree on a body schema version before booting together. Bodies are opaque `list` with a guest-side borsh version tag, so schema agreement is never a checked property today. + +## Why +Because schema agreement is never checked, a keeper and adapter can install with mismatched body encodings and fail obscurely at runtime. An install-time capability handshake catches the mismatch at boot instead: the venue host contributes an install predicate through the generalized `Extension` seam, and the supervisor refuses any pair whose supported versions do not intersect. This is chosen over freezing the WIT surface. Part of milestone M2: Generic venue-agnostic host. Blocked by: videre-wit-surface; Grow the Extension seam to carry worker/provider roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Wire `videre:venue/adapter.body-versions() -> list` (reserved in the surface pin) into the adapter interface. +- Add a `body_version` field to the module and adapter `module.toml` manifests. +- Have the venue host contribute an install predicate via the generalized `Extension` seam. +- Make `Supervisor::install` assert version intersection and refuse mismatched pairs, failing fast with a logged error. + +## Done when +- `videre:venue/adapter.body-versions() -> list` exists. +- The module and adapter manifests declare a supported `body_version` or set. +- `Supervisor::install` refuses to boot a keeper/adapter pair whose versions do not intersect, failing fast with a logged error. +- A mismatched-pair test asserts the refusal. diff --git a/docs/design/apply/bodies/videre-conformance-kit.md b/docs/design/apply/bodies/videre-conformance-kit.md new file mode 100644 index 00000000..870b92ae --- /dev/null +++ b/docs/design/apply/bodies/videre-conformance-kit.md @@ -0,0 +1,17 @@ +Rename the venue conformance kit to videre-test and harden it so a venue's cargo test fails whenever its wire shape drifts. The kit exists as nexum-venue-test but is mis-named and its codec golden passes vacuously on an empty vector. + +## Why +The conformance kit is the gate that holds every venue to portable codec vectors and header goldens, but it is mis-named for the split and its codec golden currently passes on an empty vector, so drift can slip through. Renaming and hardening it makes wire-shape drift a hard cargo-test failure. Part of milestone M3: Videre SDK, macros and DX. Blocked by: videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Rename nexum-venue-test to videre-test; keep `CodecVectors`, `HeaderGoldens` and `MockTransport`. +- Harden the goldens with a version discriminator, reject-unknown handling and a non-empty-vector assertion. +- Regenerate the goldens under the `videre:*` namespace and re-assert the tip oracle. +- Align mock-grant fidelity to the host to close the known divergence. + +## Done when +- nexum-venue-test is renamed to videre-test. +- The kit ships `CodecVectors`, `HeaderGoldens` and `MockTransport`. +- A venue's cargo test fails on any wire-shape drift. +- The goldens carry the version discriminator, reject-unknown and a non-empty-vector assertion. +- The goldens regenerate under the `videre:*` namespace and the tip oracle holds. diff --git a/docs/design/apply/bodies/videre-consumable-release-graduation.md b/docs/design/apply/bodies/videre-consumable-release-graduation.md new file mode 100644 index 00000000..88093d4c --- /dev/null +++ b/docs/design/apply/bodies/videre-consumable-release-graduation.md @@ -0,0 +1,15 @@ +Cut the first externally consumable videre-sdk and videre:* WIT release, graduate videre off the umbrella path-deps for external consumers, and add a fresh-clone smoke test that builds a trivial venue against only published deps. + +## Why +The second venue in this milestone is the first consumer of videre from outside the transitional umbrella superproject, via published and tagged deps only with no path-dep fallback. shepherd proves the videre WIT edges earlier but does so inside the umbrella (path-deps during stabilization) and may not publish, since it is an app-level bundle. The design describes videre's convergence to a genuinely consumable artifact (git-tag to crates.io plus wkg/OCI) only as "once stable", with no owner and no milestone, so nothing guarantees a fresh external repo can cargo add videre-sdk plus wit-deps videre:* and build a venue before this milestone needs it to. Part of milestone M5: The gated three-repo split. Blocked by: Three history-preserving git-filter-repo carves: nexum-runtime / videre / shepherd. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Tag and publish videre-sdk, videre-test, and the videre:* WIT packages (git-tag, wkg/OCI, per-package semver). +- Graduate videre off the umbrella path-deps for external consumers. +- Add a fresh-clone external-consumer smoke test that builds a trivial venue against only published videre deps (no path-dep, no umbrella). + +## Done when +- The first consumable videre-sdk, videre-test, and videre:* WIT release is cut (git-tag plus wkg/OCI, per-package semver). +- videre is graduated off the umbrella path-deps for external consumers. +- A fresh-clone external-consumer smoke test builds a trivial venue against only published videre deps (no path-dep, no umbrella) and passes in CI. +- This is documented as the precondition for the second-venue acceptance in this milestone. diff --git a/docs/design/apply/bodies/videre-keeper-macro.md b/docs/design/apply/bodies/videre-keeper-macro.md new file mode 100644 index 00000000..5f5df3e5 --- /dev/null +++ b/docs/design/apply/bodies/videre-keeper-macro.md @@ -0,0 +1,15 @@ +Add `#[videre::keeper]`, the keeper-author mirror of the venue macro, letting authors drive a venue through a typed `VenueClient` instead of hand-writing `list` marshalling. + +## Why +The venue author gets `#[videre::venue]`; the keeper author has no equivalent and must hand-write byte marshalling to drive a venue over `videre:venue/client`. A macro plus a typed, alloy-style client closes that gap and keeps the hot path free of boxing. Part of milestone M3: Videre SDK, macros and DX. Blocked by: #[videre::venue]: single blessed authoring path emitting impl VenueAdapter, videre-quote. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add `#[videre::keeper]`: the author writes logic against a typed `VenueClient`, and the macro wires the event subscriptions and the `videre:venue/client` import. +- Provide `VenueClient` as an alloy-style typed wrapper exposing quote, submit, status and cancel, with typed bodies via the venue's `IntentBody`, keyed by `VenueId`. +- Use native AFIT for the hot traits under MSRV 1.94 so there is zero boxing on the hot path. +- Prove the path by driving echo-venue from a keeper. + +## Done when +- `#[videre::keeper]` emits a worker that drives a venue via a typed `VenueClient` (alloy-style, typed rather than `list`) wrapping `videre:venue/client` and wiring the event subscriptions. +- A keeper written against `VenueClient` compiles and calls quote, submit, status and cancel with typed bodies. +- Dispatch is static via native AFIT, with zero boxing on the hot path. diff --git a/docs/design/apply/bodies/videre-quote.md b/docs/design/apply/bodies/videre-quote.md new file mode 100644 index 00000000..17191221 --- /dev/null +++ b/docs/design/apply/bodies/videre-quote.md @@ -0,0 +1,15 @@ +Add quoting to the videre:venue contract, which today exposes only submit, status, and cancel. + +## Why +The vision is settlement plus quoting, but the contract has no quoting at all. It is free to add now and a wire break later, so it lands in this window. Firm and RFQ quotes and maker-side offers are out of scope and tracked in #355. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Pin the videre WIT surface. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Add quote to both faces of videre:venue. +- Define the quote record in videre:types, thin and value-flow-typed ({gives, wants, fee, valid-until-ms}). +- Add the SDK IntentClient.quote(&body)?.submit()? typestate. + +## Done when +- videre:venue/client.quote(venue, body) and videre:venue/adapter.quote(body) exist and return a value-flow-typed quote. +- IntentClient.quote(&body)?.submit()? typestate compiles. +- echo-venue implements quote. +- The quote record is thin (gives, wants, fee, valid-until-ms) and EVM-only. diff --git a/docs/design/apply/bodies/videre-sdk-crate.md b/docs/design/apply/bodies/videre-sdk-crate.md new file mode 100644 index 00000000..8826be38 --- /dev/null +++ b/docs/design/apply/bodies/videre-sdk-crate.md @@ -0,0 +1,16 @@ +Rename the venue-author SDK to videre-sdk and add its missing keeper sweep assembler and typed venue client. The persona crate is shipped as nexum-venue-sdk/nexum-venue-test but is mis-named for the split and lacks its assembler. + +## Why +The venue-author SDK exists but is mis-named and incomplete: it has no generic sweep assembler, so keeper authors have nothing to assemble a sweep from. Renaming to videre-sdk and giving it the assembler lets a keeper compile against one crate with no cow or host dependency. Part of milestone M3: Videre SDK, macros and DX. Blocked by: videre-wit-surface, host-extension-seam-roles. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Rename nexum-venue-sdk to videre-sdk; own `VenueAdapter`, the `IntentBody` codec plus `BodyError`, `IntentClient

` and `VenueId`. +- Add the generic `Keeper::sweep` assembler wiring `WatchSet` to `Gates` to `source.poll` to `Retrier` to `Journal`, plus a shared `Sweep` outcome that resolves the dangling `ConditionalSource::Outcome`. +- Keep the world-neutral keeper primitives (`WatchSet`, `Gates`, `Journal`, `Retrier`, `ConditionalSource`) in nexum-sdk. +- Apply DX polish: `VenueFault` with `Display` and `IntoStaticStr`, `#[non_exhaustive]`, and sealed extension traits. + +## Done when +- nexum-venue-sdk is renamed to videre-sdk. +- The crate exports `VenueAdapter`, `IntentBody`, `IntentClient

`, `VenueId` and a generic `Keeper::sweep` assembler over a `Sweep` outcome that resolves the dangling `ConditionalSource::Outcome`. +- The world-neutral keeper primitives stay in nexum-sdk. +- A keeper compiles against videre-sdk alone with no cow or host dependency. diff --git a/docs/design/apply/bodies/videre-venue-macro.md b/docs/design/apply/bodies/videre-venue-macro.md new file mode 100644 index 00000000..55643144 --- /dev/null +++ b/docs/design/apply/bodies/videre-venue-macro.md @@ -0,0 +1,17 @@ +Make `#[videre::venue]` the single blessed venue authoring path, fixed to emit `impl VenueAdapter`. Two authoring paths currently fork the one arrangement. + +## Why +Today `#[venue]` emits `impl Guest` over raw bindgen and bypasses the typed `VenueAdapter` trait, while `export_venue_adapter!` routes through it on a differently-named world that imports chain and messaging unconditionally. Two paths for one job is a fork; collapsing to one blessed macro that always emits `impl VenueAdapter` removes the ambiguity and the hand-copied bridge boilerplate. Part of milestone M3: Videre SDK, macros and DX. Blocked by: videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Fix `#[videre::venue]` to emit `impl VenueAdapter`, the `videre:venue/adapter` export and the manifest kind, not a raw `Guest` impl. +- Demote `export_venue_adapter!` to the internal codegen the macro expands to, so there is no public second path. +- Narrow imports by construction via the synthesized venue world, rather than dead-import elision. +- Remove the roughly 80-line `*_to_golden` bridges; port echo-venue to the macro. +- Land the macro in videre-macros after the macro split. + +## Done when +- `#[videre::venue]` emits `impl VenueAdapter` plus the `videre:venue/adapter` export and the manifest kind, not a raw `Guest` impl. +- `export_venue_adapter!` is demoted to the internal codegen the macro expands to, with no public second path. +- Import narrowing is by construction, with no dead-import elision. +- echo-venue uses the macro and the `*_to_golden` bridge boilerplate is gone. diff --git a/docs/design/apply/bodies/videre-wit-normalize.md b/docs/design/apply/bodies/videre-wit-normalize.md new file mode 100644 index 00000000..cd5fe0af --- /dev/null +++ b/docs/design/apply/bodies/videre-wit-normalize.md @@ -0,0 +1,13 @@ +Reset every WIT package version string and every use reference to a single @0.1.0. + +## Why +The current version strings (nexum:host@0.2.0, nexum:intent@0.1.0, shepherd:cow@0.2.0) are pre-release cruft, not compatibility boundaries; no external consumer pins any. After the cut, each package adopts independent per-package semver, so the shared baseline must be a clean @0.1.0. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Rename the nexum:intent WIT packages and symbols to videre. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Reset every package and every use reference across nexum:host, videre:* (post-rename), and shepherd:cow to @0.1.0 as one fold with the tip oracle. +- Delete the migration cruft: docs/migration/0.1-to-0.2.md and the "Migration from 0.1" prose in docs/08-platform-generalisation.md. + +## Done when +- Every WIT package and every `use ...@` reference reads @0.1.0. +- docs/migration/0.1-to-0.2.md and the "Migration from 0.1" prose in docs/08 are deleted. +- The tree builds and the byte-identical tip oracle holds. diff --git a/docs/design/apply/bodies/videre-wit-rename.md b/docs/design/apply/bodies/videre-wit-rename.md new file mode 100644 index 00000000..94882e2e --- /dev/null +++ b/docs/design/apply/bodies/videre-wit-rename.md @@ -0,0 +1,19 @@ +Rename the pre-release intent WIT packages and their Rust symbols into the videre namespace as one mechanical fold. + +## Why +The pre-release intent contract still carries the nexum:intent branding and a single fused pool face; the videre reshape needs a clean, venue-neutral namespace and separate worker and provider faces before the surface is pinned. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Decouple nexum:host from nexum:intent so the host event carries opaque status bytes. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Rename WIT packages: nexum:intent to videre:venue (plus videre:types); nexum:value-flow to videre:value-flow. +- Retire nexum:adapter; its venue-adapter world folds into videre:venue. +- Leave nexum:host and shepherd:cow untouched. +- Split the pool face into a worker face videre:venue/client and a provider face videre:venue/adapter. +- Rename Rust symbols: PoolRouter to VenueRegistry, AdapterActor to VenueActor, GuardPolicy/AllowAllGuard to EgressGuard; introduce a VenueId newtype. +- Regenerate goldens and re-assert the tip oracle. + +## Done when +- No nexum:intent, nexum:value-flow, or nexum:adapter package or use reference remains. +- The tree builds. +- echo-venue and echo-client pass against regenerated goldens. +- The byte-identical tip oracle holds across the train. +- The nexum:host and shepherd:cow brands are untouched. diff --git a/docs/design/apply/bodies/videre-wit-surface.md b/docs/design/apply/bodies/videre-wit-surface.md new file mode 100644 index 00000000..7f399b85 --- /dev/null +++ b/docs/design/apply/bodies/videre-wit-surface.md @@ -0,0 +1,19 @@ +Pin the shape of the videre contract surface across videre:types, videre:venue, and videre:value-flow. The 0.1 surface is EVM-only. + +## Why +The rename moves the namespace; this issue pins the shape so every later venue and keeper compiles against a stable contract. Both ride one fold. Part of milestone M1: Videre contract reshape and host-intent decoupling. Blocked by: Rename the nexum:intent WIT packages and symbols to videre. See docs/design/videre-split-plan.md and docs/design/issue-milestone-plan.md. + +## Scope +- Pin videre:types: intent-header, auth-scheme {eip1271, eip712}, settlement {chain:u64}, receipt = list, submit-outcome {accepted, requires-signing}, unsigned-tx, intent-status, venue-error with rate-limit. +- Pin videre:venue: the worker client face and the provider adapter face mirror each other. +- Pin videre:value-flow: asset-amount, asset {native, erc20}, named records with no anonymous tuples. +- Add codec goldens with a version discriminator, reject-unknown, and a non-empty assertion. +- Document caveats: valid-until renamed to valid-until-ms, denied() MUST-NOT-retry, derive-header purity, gives is adapter-attested not host-verified. + +## Done when +- The three packages match the pinned shapes in docs/design/videre-split-plan.md. +- The worker client face and the provider adapter face mirror. +- venue-error carries rate-limited{retry-after-ms} and denied. +- value-flow uses named records with no anonymous ERC tuples. +- Codec goldens carry a version discriminator, reject-unknown, and a non-empty-vector assertion. +- echo-venue implements the pinned adapter face and passes. diff --git a/docs/design/issue-milestone-groom.raw.json b/docs/design/issue-milestone-groom.raw.json new file mode 100644 index 00000000..78fb7b9b --- /dev/null +++ b/docs/design/issue-milestone-groom.raw.json @@ -0,0 +1,630 @@ +{ + "summary": "Draft the videre-plan issues, categorize the 61 open tracker issues + 8 milestones (read-only), then reconcile into a deduped, idiomatically-organised issue+milestone plan with a preserved JSON + report", + "agentCount": 12, + "logs": [], + "result": { + "outJson": "/code/nxm/runtime/docs/design/issue-milestone-plan.json", + "outMd": "/code/nxm/runtime/docs/design/issue-milestone-plan.md", + "draftedCount": 64, + "openIssues": 61, + "reconcile": { + "milestone_plan": [ + { + "name": "P0 — Videre contract reshape & the R6 master gate", + "existing_number": 8, + "charter": "Repurposes old M1 (intent core is delivered: #137/#135/#131/#222 closed). Now owns the free, in-monorepo pre-release WIT fold that every later phase depends on. Land R6 host<->intent decouple (the master gate: host event carries opaque status bytes); rename nexum:intent/value-flow/adapter -> videre:*; normalize all packages to @0.1.0; add quoting (client+adapter); reshape venue-error (rate-limited{retry-after-ms}+denied) and value-flow (named records); spec the opaque-status destructuring contract and the install-time body-versions handshake key; ship the advisory-only M1 guard posture; and finish the M1 train to a single green linear tip. Executed as ONE oracle-validated git-filter-repo/jj fold. Nothing is pinned so every change is a free recompile; this phase MUST complete before any carve.", + "new_issue_keys": [ + "split-epic-p0", + "host-r6-decouple", + "gap-opaque-status-contract-spec", + "videre-epic", + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "videre-body-versions-handshake", + "gap-handshake-manifest-key-decision", + "gap-p0-wit-fold-execution", + "gap-p0-fold-tail-hygiene", + "p0-acyclicity-scaffold", + "guard-advisory-m1", + "guard-deny-quota", + "gap-m1-green-tip-gate" + ], + "moved_issues": [] + }, + { + "name": "S1 — Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "existing_number": 7, + "charter": "Repurposes M0 (library-first composable runtime) and absorbs the dissolved old-M2 execution/lifecycle hardening. Now the long-pole S1 phase: make nexum-runtime venue-agnostic by growing Extension to worker/provider roles (service+provider, HostService, ProviderKind), extracting PoolRouter->VenueRegistry as an extension-owned service and DELETING the privileged HostState.pool_router field (the forcing-function acceptance test), extracting the generic supervised-component/host-actor primitive from AdapterActor (folds R8), de-hardcoding the KNOWN table into nexum-world, the bare Ext=() launcher, and the permanent zero-leak CI gate. Plus L1 execution/resource/lifecycle hardening that survives the split unchanged (per-module quotas, fuel accounting, graceful drain, pluggable log seam, WASI allowlist, handler-DoS, router watch-set bound).", + "new_issue_keys": [ + "host-generalize-epic", + "split-epic-s1", + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind", + "host-nexum-world-registry", + "host-zero-leak-ci-gate", + "host-generic-launcher-bin", + "s1-gate-runtime-venue-agnostic", + "gap-videre-host-platform-crate", + "adapter-supervision-sweeps" + ], + "moved_issues": [ + 294, + 266, + 265, + 244, + 107, + 53, + 51, + 273, + 321 + ] + }, + { + "name": "S1b — CoW on the generic seam (concrete venue + keepers)", + "existing_number": 3, + "charter": "Repurposes M2 'Concrete CoW modules'. Prove the generic seam carries a REAL venue, not just echo. Cleave cow-venue into an orderbook-only venue vs a composable-cow keeper (CI-gated clean); build the cow adapter cdylib (#[videre::venue] over wasi:http, #324); settle the idempotency seam before assembly moves into the adapter; port the composable-cow keeper (#327) and ethflow keeper (#328) onto videre:venue/client; retire CowApiHost/cow-api/cow-ext (#293, the biggest lever); own the shepherd-cow event-ABI WITs at L3; rewrite docs/05+08 source-of-truth. The fork-gated poll wire-swap (delete composable.rs) rides here but stays deferred/decoupled. Carries the still-live cow module bugs (#320/#121/#75/#48/#54) into the ported keeper.", + "new_issue_keys": [ + "split-epic-s1b", + "s1b-gate-cow-on-generic-seam", + "cleave-cow-venue", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "composable-poll-wire-swap", + "gap-docs-source-of-truth-rewrite" + ], + "moved_issues": [ + 138, + 324, + 327, + 328, + 293, + 323, + 320, + 121, + 75, + 48, + 54, + 64 + ] + }, + { + "name": "S2 — The gated three-repo cut & delivery infrastructure", + "existing_number": 4, + "charter": "Repurposes M3 'Infrastructure'. The physical split, gated on all three cut gates (a runtime venue-agnostic, b cow on the generic seam, c genuine second-protocol venue). Transitional path-dep workspace in the three groupings; wit-deps flip + git-tag sourcing + wkg/OCI convergence; the cut go/no-go checklist; three history-preserving git-filter-repo carves (nexum-runtime / videre / CoW-on-videre) with the byte-identical tip oracle. Plus operator delivery: CI/CD hardening (incl. the sccache fork-PR fail-open #337), the multi-chain provider map + docs, ghcr packaging, and the Swarm remote-store backend.", + "new_issue_keys": [ + "split-epic-s2", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "host-wit-deps-flip-carve" + ], + "moved_issues": [ + 274, + 337, + 151, + 125, + 124 + ] + }, + { + "name": "S3 — Second-venue acceptance & vocabulary freeze", + "existing_number": 10, + "charter": "Repurposes M6 'Second venue and vocabulary freeze'. The acceptance phase that de-risks R1: build a genuine non-cow second-protocol venue (rfq or amm-router, #140) against videre-sdk alone, exercising quote and surfaces CoW does not, feeding contract fixes back pre-cut. Cut gate (c). Then the videre:value-flow 1.0 freeze decisions (#330, retitled) and the curated adapter registry + consent surface (#141).", + "new_issue_keys": [ + "split-epic-s3" + ], + "moved_issues": [ + 140, + 330, + 141 + ] + }, + { + "name": "videre SDK, macros & reth/alloy DX", + "existing_number": 5, + "charter": "Repurposes M4 'SDK and DX'. The venue/keeper author front door and DX build-out: videre-sdk (renamed from nexum-venue-sdk, + Keeper::sweep assembler + VenueClient), #[videre::venue] single blessed path, #[videre::keeper] + typed VenueClient, the videre-test conformance kit, guest SDK seams+Mocks for identity/messaging/remote-store, the alloy Provider chain seam, and the reth/alloy DX polish cluster (VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const). Plus the grant-scoped DX deliverables and residual nexum-sdk/macro consolidation.", + "new_issue_keys": [ + "videre-sdk-crate", + "videre-venue-macro", + "videre-keeper-macro", + "videre-conformance-kit", + "host-backend-guest-seams", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster" + ], + "moved_issues": [ + 291, + 264, + 127, + 136, + 322 + ] + }, + { + "name": "Egress guard (real, teeth)", + "existing_number": 9, + "charter": "Keeps M5 'Egress guard' as the home for the REAL (non-AllowAll) guard, deferred wholly per decision 3 (M1 is advisory-only). Single-decode / derive-before-guard TOCTOU fix, move the checkpoint to the signed unsigned-tx / identity boundary, GuardPolicy::check sync->async, bring http egress under the compile-time world guarantee, messaging.query scope enforcement, mock-grant fidelity. Anchored by the rescoped guard epic #139 and gated on the real keystore identity backend #52.", + "new_issue_keys": [ + "guard-derive-before-guard", + "guard-signing-boundary", + "guard-policy-async", + "guard-egress-cap-world-guarantee", + "messaging-query-scope", + "mock-grant-fidelity" + ], + "moved_issues": [ + 139, + 52 + ] + }, + { + "name": "Post-v1 hardening & debt (rolling)", + "existing_number": 6, + "charter": "Keeps M7 as the rolling, non-gating debt bucket. Deferred videre concepts (maker-side offer #355, RFQ firm-quote additive, Materialiser), doc-consistency passes, typed-fault/backend debt, test-harness/clock-seam debt, perf (parking_lot, bulk getLogs), the deferred messaging/remote-store backends and payload-codec convention, and the grant soak/reporting items.", + "new_issue_keys": [ + "rfq-firm-quote-additive", + "materialiser-source-venue" + ], + "moved_issues": [ + 355, + 341, + 302, + 289, + 288, + 286, + 285, + 284, + 283, + 280, + 269, + 212, + 152, + 105, + 65 + ] + } + ], + "new_issues": [ + "host-generalize-epic", + "host-r6-decouple", + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind", + "host-nexum-world-registry", + "host-zero-leak-ci-gate", + "host-generic-launcher-bin", + "host-wit-deps-flip-carve", + "host-backend-guest-seams", + "videre-epic", + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "videre-body-versions-handshake", + "videre-sdk-crate", + "videre-venue-macro", + "videre-keeper-macro", + "videre-conformance-kit", + "cleave-cow-venue", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "composable-poll-wire-swap", + "split-epic-p0", + "p0-acyclicity-scaffold", + "split-epic-s1", + "s1-gate-runtime-venue-agnostic", + "split-epic-s1b", + "s1b-gate-cow-on-generic-seam", + "split-epic-s2", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "split-epic-s3", + "guard-advisory-m1", + "guard-derive-before-guard", + "guard-signing-boundary", + "guard-deny-quota", + "guard-policy-async", + "guard-egress-cap-world-guarantee", + "adapter-supervision-sweeps", + "messaging-query-scope", + "mock-grant-fidelity", + "rfq-firm-quote-additive", + "materialiser-source-venue", + "gap-opaque-status-contract-spec", + "gap-p0-wit-fold-execution", + "gap-p0-fold-tail-hygiene", + "gap-m1-green-tip-gate", + "gap-videre-host-platform-crate", + "gap-handshake-manifest-key-decision", + "gap-docs-source-of-truth-rewrite", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster" + ], + "close": [ + { + "number": 339, + "reason": "Obsolete: fixes nexum.toml->module.toml inside docs/migration/0.1-to-0.2.md, which is already deleted (HEAD 7c66b6c) as Phase-0 migration cruft. Fixing a deleted file is moot." + }, + { + "number": 287, + "reason": "Targets shepherd-cow-host ext_cow.rs — the legacy cow-api extension that cow-api-retire (#293) deletes. The timeout/429->typed-fault requirement is carried forward by the cow-venue adapter's errorType->venue-error projection + the R1 venue-error reshape; the host-chain equivalent lives on as #269." + }, + { + "number": 222, + "reason": "Delivered by #239: ConditionalSource/Retrier/RetryAction/cow::run landed in nexum-sdk/keeper.rs with the M1 train. Forward Verdict/Keeper::sweep rework is carried by videre-sdk-crate." + }, + { + "number": 137, + "reason": "Delivered by the M1 train (#226-#234): nexum:value-flow+nexum:intent WIT, venue-adapter world, PoolRouter, nexum-venue-sdk, conformance kit, echo-venue. Successor is the drafted videre-epic (rename/quote/normalize/R6), not a reopen." + }, + { + "number": 135, + "reason": "Delivered by the M1 train (#146/#222/#148/#147/#149): the keeper primitives + single-venue loop landed in nexum-sdk/keeper.rs. Deferred generalization is videre-sdk-crate (Keeper::sweep) + materialiser-source-venue (M7)." + }, + { + "number": 131, + "reason": "Docs-only, delivered by PR #132 (docs 08/09 + egress-guard ADR); the design docs now exist. Go-forward doc work is gap-docs-source-of-truth-rewrite." + }, + { + "number": 7, + "reason": "Stale pre-restructure roadmap epic, no milestone. Its goal is largely delivered (chain/local-store/logging live) and its workstreams are decomposed into M0-M7 + individual issues (#52/#152/#151/#285). Nothing tracks against it." + } + ], + "modify": [ + { + "number": 139, + "change": "Rescope to fold the venue-platform R3/R5/R8 router/capability/lifecycle hardening the guard engine epic did not enumerate (single-decode/derive-before-guard, signed-tx boundary, GuardPolicy async, http-under-world-guarantee, messaging.query scope, adapter sweeps, mock fidelity) as children. Keep M5. This IS the drafted egress-guard-hardening-epic; nothing shipped. Depends on #52 (identity) and stays advisory-only for M1." + }, + { + "number": 330, + "change": "Retitle the package under the videre rename: nexum:value-flow -> videre:value-flow. Keep the two freeze-gate ontology decisions (minimal-length canonical amount encoding; native-token representable-but-invalid) — NOT addressed by the Phase-0 named-records reshape. Stays M6/S3 as the freeze gate, distinct from videre-wit-surface (reshape, not freeze)." + }, + { + "number": 289, + "change": "Drop the docs/migration/0.1-to-0.2.md bullet (that file is deleted as Phase-0 cruft). Keep the still-valid items: ADR-0011 {errorType,description,data} restoration, docs/07 rpc data-payload callout, docs/production.md house-style pass, stale .mmd/.png regen. Stays M7. Distinct from gap-docs-source-of-truth-rewrite (docs/05+08 venue-persona)." + }, + { + "number": 274, + "change": "Rescope from a TWO-repo split (core + shepherd instantiation) to the THREE-repo umbrella (nexum-runtime <- videre <- CoW-on-videre) spanning the drafted split-epic family (split-epic-p0/s1/s1b/s2/s3; closest single correspondent split-epic-s2, the physical cut). Update the two-way partition to three-way; fold its post-split preset items into the generalization. Re-milestone M7->M3." + }, + { + "number": 273, + "change": "Rescope: the preset/Runtime-trait launch-surface ask is subsumed by the S1 seam generalization (host-extension-seam-roles + gap-videre-host-platform-crate grow Extension; host-generic-launcher-bin retires the backwards nexum-cli->cow dep with a bare Ext=() bin). Residual = the MockRuntime preset path (rolls into host-backend-guest-seams/#80). Re-milestone M7->M0." + }, + { + "number": 136, + "change": "Rescope: premise is now wrong — the videre split does NOT retire shepherd-sdk (recast as the L3 CoW keeper crate, kept). Residual: (a) nexum-venue-sdk->videre-sdk rename is videre-sdk-crate; (b) 'clean break / workspace-member removal' becomes the S2 carve (s2-three-carves). Retitle to the surviving nexum-sdk/macro consolidation; re-milestone M1->M4." + } + ], + "merge": [ + { + "from": [ + 325, + 326 + ], + "into": 324 + }, + { + "from": [ + 329 + ], + "into": 293 + } + ], + "dedup": [ + { + "drafted_key": "host-identity-signing-backend", + "existing": 52 + }, + { + "drafted_key": "egress-guard-hardening-epic", + "existing": 139 + }, + { + "drafted_key": "cow-onvidere-epic", + "existing": 138 + }, + { + "drafted_key": "cow-venue-cdylib", + "existing": 324 + }, + { + "drafted_key": "cow-api-retire", + "existing": 293 + }, + { + "drafted_key": "ethflow-keeper", + "existing": 328 + }, + { + "drafted_key": "composable-cow-keeper-port", + "existing": 327 + }, + { + "drafted_key": "s3-gate-second-protocol-venue", + "existing": 140 + } + ], + "summary": "The reorg reshapes the eight legacy milestones (M0-M7) into a set that reads top-to-bottom as the videre execution order — P0 (contract reshape/master gate) -> S1 (generic host) -> S1b (CoW on the seam) -> S2 (the gated cut) -> S3 (second venue) — followed by three cross-cutting buckets (videre SDK/DX, the real egress guard, and the rolling debt pile). The biggest structural move is repurposing old M1 (#8): the intent-core it was chartered for is delivered (#137/#135/#131/#222 all close on the M1 train), so #8 becomes the P0 free-WIT-fold milestone anchored on R6 (the acyclicity master gate) and the videre:* rename/normalize/quote work, executed as one oracle-validated git-filter-repo fold. M0 (#7) is repurposed as the S1 long pole — making nexum-runtime venue-agnostic (grow Extension, extract VenueRegistry, delete the pool_router field, zero-leak CI gate) — absorbing the dissolved old-M2 lifecycle hardening. The largest dedup/close wins: eight drafted issues collapse onto existing tracker issues rather than being created (host-identity-signing-backend=#52, egress-guard-hardening-epic=#139, cow-onvidere-epic=#138, cow-venue-cdylib=#324, cow-api-retire=#293, ethflow-keeper=#328, composable-cow-keeper-port=#327, s3-gate-second-protocol-venue=#140), and seven existing issues close as already-delivered-by-the-M1-train or obsoleted by the Phase-0 migration-cruft deletion. #325/#326 fold into #324 (one cow-adapter deliverable) and #329 folds into #293 (the cow-cone retirement). The concrete CoW cone (venue cleave, keeper ports, cow-api retirement) is re-milestoned M1->M2/S1b so it lands after the generic seam it depends on, while venue-agnostic L1 hardening (#294/#266/#53/#51/#107/#244) consolidates under S1. Net: 56 net-new issues across the reshaped set, with two internal-overlap seams flagged for implementation-time coordination — host-generic-component-kind vs adapter-supervision-sweeps (both fold R8) and host-wit-deps-flip-carve vs s2-wit-cross-repo-consumption/s2-three-carves (the L1 slice of the carve). Two existing epics (#274 split, #273 preset-trait) are rescoped rather than duplicated to sit under the drafted split/seam epics, and #136 is rescoped away from its now-wrong 'retire shepherd-sdk' premise since the split keeps shepherd-sdk as the L3 keeper crate." + }, + "report": "Wrote both deliverables (nothing touched on GitHub — data only):\n\n- `/code/nxm/runtime/docs/design/issue-milestone-plan.json` (valid JSON; `generated_note` + 64 full `drafted_issues` + `tracker{open_count:61, milestones[9]}` + full `reconcile`)\n- `/code/nxm/runtime/docs/design/issue-milestone-plan.md` (sections a–h: exec summary, repurposed-milestone plan, new-issues table, close/modify/merge/dedup tables, ordered apply sequence)\n\nEXEC SUMMARY: The reorg repurposes all 8 live milestones (61 open issues) in place so they read as the videre execution order P0 → S1 → S1b → S2 → S3, then three cross-cutting buckets (videre SDK/DX, real egress guard, rolling debt). Biggest structural move: old M1 (#8) becomes P0 (its intent-core is delivered — #137/#135/#131/#222 close on the M1 train), anchored on R6 (the acyclicity master gate: `nexum:host` must stop importing `nexum:intent`) plus the videre:* rename/normalize/quote fold; M0 (#7) becomes the S1 long pole (grow `Extension`, extract `VenueRegistry`, delete `HostState.pool_router`, zero-leak CI gate), absorbing dissolved-M2 lifecycle hardening. 56 net-new issues to create; 8 drafted issues dedup onto existing tracker #s (#52/#139/#138/#324/#293/#328/#327/#140); 7 close as delivered/obsolete; 6 rescope (#139/#330/#289/#274/#273/#136); 2 merge groups (#325/#326→#324, #329→#293). Hard constraints encoded in the apply sequence: no S1+ work before R6 lands, and no repo carve before all three cut gates (a runtime venue-agnostic, b CoW on the seam, c genuine second-protocol venue) are green. Two internal-overlap seams flagged for impl-time coordination: `host-generic-component-kind` vs `adapter-supervision-sweeps` (both fold R8), and `host-wit-deps-flip-carve` vs `s2-wit-cross-repo-consumption`/`s2-three-carves` (L1 carve slice)." + }, + "workflowProgress": [ + { + "type": "workflow_phase", + "index": 1, + "title": "Draft" + }, + { + "type": "workflow_phase", + "index": 2, + "title": "Categorize" + }, + { + "type": "workflow_phase", + "index": 3, + "title": "Reconcile" + }, + { + "type": "workflow_phase", + "index": 4, + "title": "Report" + }, + { + "type": "workflow_agent", + "index": 1, + "label": "draft:host", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "a35011d5e9297bada", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123342635, + "queuedAt": 1784123342622, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "nexum-runtime host layer (L1) — making it a generic, venue-…", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784123638509, + "tokens": 67025, + "toolCalls": 9, + "durationMs": 295874, + "resultPreview": "{\"area\":\"nexum-runtime host layer (L1) — making it a generic, venue-agnostic component host\",\"issues\":[{\"key\":\"host-generalize-epic\",\"title\":\"Epic: make nexum-runtime a generic, venue-agnostic component host\",\"body\":\"## Context\\n\\nThe target platform is three layers with one acyclic dependency edge each: `nexum-runtime` (L1, universal host) ← `videre` (L2, generic intent/venue) ← `CoW-on-videre` (…" + }, + { + "type": "workflow_agent", + "index": 2, + "label": "draft:videre", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "aa43b8a79b0030998", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123342636, + "queuedAt": 1784123342622, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "VIDERE", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784123721321, + "tokens": 84515, + "toolCalls": 9, + "durationMs": 378685, + "resultPreview": "{\"area\":\"VIDERE\",\"issues\":[{\"key\":\"videre-epic\",\"title\":\"Epic: videre — the generic intent-venue abstraction (L2)\",\"kind\":\"epic\",\"phase\":\"P0\",\"milestone_suggestion\":\"M1: Intent core and CoW venue adapter\",\"labels\":[\"epic\",\"component/wit-abi\",\"component/sdk\",\"feature\",\"effort/weeks\"],\"depends_on\":[\"host-r6-decouple\",\"host-seam-generalize\"],\"acceptance\":\"All child issues closed; the videre:* WIT DAG…" + }, + { + "type": "workflow_agent", + "index": 3, + "label": "draft:cow", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "afa2aef8da5283d36", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123342636, + "queuedAt": 1784123342622, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "CoW-on-videre (the concrete reference venue)", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784123684625, + "tokens": 77236, + "toolCalls": 11, + "durationMs": 341988, + "resultPreview": "{\"area\":\"CoW-on-videre (the concrete reference venue)\",\"issues\":[{\"key\":\"cow-onvidere-epic\",\"title\":\"Epic: CoW-on-videre — the concrete reference venue\",\"kind\":\"epic\",\"phase\":\"S1b\",\"milestone_suggestion\":\"M1: Intent core and CoW venue adapter\",\"labels\":[\"epic\",\"component/cow-integration\"],\"depends_on\":[\"host-seam-generalize\"],\"acceptance\":\"- [ ] cow-venue is cleaved into an orderbook-only venue vs…" + }, + { + "type": "workflow_agent", + "index": 4, + "label": "draft:split-seq", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "adb6a1297ea60e738", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123342636, + "queuedAt": 1784123342622, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "Three-repo split + sequencing (nexum-runtime / videre / CoW…", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784123857150, + "tokens": 74960, + "toolCalls": 8, + "durationMs": 514514, + "resultPreview": "{\"area\":\"Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)\",\"issues\":[{\"key\":\"split-epic-p0\",\"title\":\"Epic (P0): free monorepo reshape — land the master gate that makes an acyclic split possible\",\"kind\":\"epic\",\"phase\":\"P0\",\"milestone_suggestion\":\"M1: Intent core and CoW venue adapter\",\"labels\":[\"epic\",\"component/wit-abi\",\"effort/weeks\"],\"depends_on\":[],\"body\":\"## Context\\nP0 i…" + }, + { + "type": "workflow_agent", + "index": 5, + "label": "draft:guard-defer", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "ad1154968200adc93", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123342636, + "queuedAt": 1784123342622, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "egress guard + deferred/debt", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784123811248, + "tokens": 90762, + "toolCalls": 14, + "durationMs": 468612, + "resultPreview": "{\"area\":\"egress guard + deferred/debt\",\"issues\":[{\"key\":\"egress-guard-hardening-epic\",\"title\":\"epic: egress-guard hardening — real non-AllowAll guard, single-decode, signing-boundary, capability/lifecycle teeth (R3/R5/R8)\",\"body\":\"## Context\\n\\nThe router's entire `derive → guard → submit` shape is justified by an egress checkpoint that today does not exist. Only `AllowAllGuard` ships (`pool_route…" + }, + { + "type": "workflow_agent", + "index": 6, + "label": "draft:critic", + "phaseIndex": 1, + "phaseTitle": "Draft", + "agentId": "ac8ab2da2f625fbaf", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784123859179, + "queuedAt": 1784123859177, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "gap", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124187148, + "tokens": 67295, + "toolCalls": 5, + "durationMs": 327969, + "resultPreview": "{\"area\":\"gap\",\"issues\":[{\"key\":\"gap-opaque-status-contract-spec\",\"title\":\"Spec the opaque-status destructuring contract (versioned discriminator) that host `event` commits to — blocks R6\",\"body\":\"## Context\\nThe R6 host-intent decouple (host-r6-decouple, the P0 MASTER GATE) drops `wit/nexum-host/types.wit:8` `use nexum:intent/types.{receipt,intent-status}` and has the host `event` stream carry **o…" + }, + { + "type": "workflow_agent", + "index": 7, + "label": "cat:0-16", + "phaseIndex": 2, + "phaseTitle": "Categorize", + "agentId": "a438dfa3f9c069503", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124189351, + "queuedAt": 1784124189339, + "attempt": 1, + "lastToolName": "StructuredOutput", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124593755, + "tokens": 94875, + "toolCalls": 11, + "durationMs": 404404, + "resultPreview": "{\"verdicts\":[{\"number\":355,\"title\":\"videre: add 'offer' / provide-liquidity (maker-side, two-sided venues) — post-0.1\",\"verdict\":\"keep\",\"current_milestone\":\"none\",\"new_milestone\":\"M7: Post-v1 hardening and debt\",\"rationale\":\"Valid deferred videre concept and already grounded as the 'offer to #355 deferred' item in the pinned-WIT decision. 0.1 is taker-only/EVM-only (doc decision 5); the maker face…" + }, + { + "type": "workflow_agent", + "index": 8, + "label": "cat:16-32", + "phaseIndex": 2, + "phaseTitle": "Categorize", + "agentId": "a0445c3b0e7f18bdc", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124189353, + "queuedAt": 1784124189339, + "attempt": 1, + "lastToolName": "StructuredOutput", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124456916, + "tokens": 90906, + "toolCalls": 8, + "durationMs": 267563, + "resultPreview": "{\"verdicts\":[{\"number\":294,\"title\":\"runtime: complete execution and lifecycle hardening\",\"verdict\":\"keep\",\"current_milestone\":\"M0: Runtime architecture and lifecycle\",\"rationale\":\"Valid venue-agnostic L1 (nexum-runtime) epic: resource enforcement, chain/subscription robustness, secret redaction, lifecycle correctness, soak/uptime evidence — all pure host-engine hardening that stays in nexum-runtim…" + }, + { + "type": "workflow_agent", + "index": 9, + "label": "cat:32-47", + "phaseIndex": 2, + "phaseTitle": "Categorize", + "agentId": "a50293566ea2104c8", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124189353, + "queuedAt": 1784124189339, + "attempt": 1, + "lastToolName": "StructuredOutput", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124469213, + "tokens": 98842, + "toolCalls": 9, + "durationMs": 279860, + "resultPreview": "{\"verdicts\":[{\"number\":264,\"title\":\"sdk: convert the bind-macro error/level shims to From impls\",\"verdict\":\"keep\",\"current_milestone\":\"M4: SDK and DX\",\"rationale\":\"Narrow, still-valid DX-debt: convert convert_err/sdk_err_into_wit/convert_level in crates/nexum-sdk/src/wit_bindgen_macro.rs to From impls. Not in any M1-train child list (#223/#225 touched nexum-macros but not this shim), so the train …" + }, + { + "type": "workflow_agent", + "index": 10, + "label": "cat:47-61", + "phaseIndex": 2, + "phaseTitle": "Categorize", + "agentId": "a5b49803153f7ef71", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124189353, + "queuedAt": 1784124189339, + "attempt": 1, + "lastToolName": "StructuredOutput", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124377929, + "tokens": 84077, + "toolCalls": 7, + "durationMs": 188576, + "resultPreview": "{\"verdicts\":[{\"number\":125,\"title\":\"packaging: ghcr image name mismatch breaks fresh-server docker compose pull\",\"verdict\":\"keep\",\"current_milestone\":\"M3: Infrastructure\",\"rationale\":\"Concrete effort/minutes packaging bug (compose/docs pull ghcr.io/bleu/nullis-shepherd but the publish workflow pushes ghcr.io/nullislabs/shepherd). Orthogonal to the videre/intent reshape — the M1 train touches WIT/a…" + }, + { + "type": "workflow_agent", + "index": 11, + "label": "reconcile", + "phaseIndex": 3, + "phaseTitle": "Reconcile", + "agentId": "a53f34dc436b2d692", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124595538, + "queuedAt": 1784124595522, + "attempt": 1, + "lastToolName": "StructuredOutput", + "lastToolSummary": "The reorg reshapes the eight legacy milestones (M0-M7) into…", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784124977247, + "tokens": 140847, + "toolCalls": 6, + "durationMs": 381708, + "resultPreview": "{\"milestone_plan\":[{\"name\":\"P0 — Videre contract reshape & the R6 master gate\",\"existing_number\":8,\"charter\":\"Repurposes old M1 (intent core is delivered: #137/#135/#131/#222 closed). Now owns the free, in-monorepo pre-release WIT fold that every later phase depends on. Land R6 host<->intent decouple (the master gate: host event carries opaque status bytes); rename nexum:intent/value-flow/adapter …" + }, + { + "type": "workflow_agent", + "index": 12, + "label": "report", + "phaseIndex": 4, + "phaseTitle": "Report", + "agentId": "ae79dfdf8e74e936e", + "model": "claude-opus-4-8[1m]", + "state": "done", + "startedAt": 1784124979126, + "queuedAt": 1784124979125, + "attempt": 1, + "lastToolName": "Bash", + "lastToolSummary": "cd /code/nxm/runtime/docs/design && python3 -c \"import json…", + "promptPreview": "HARD CONSTRAINT: the GitHub remote is READ-ONLY. NEVER run `gh issue create`/`edit`/`close`/`comment`, never edit a milestone, never `git push`. Only READ: `gh issue list`, `gh issue view `, `gh api` GET, `git show`, Read. All output is DATA (returned or written to LOCAL files under /code/nxm/runtime/docs/design or /home/mfw78/.claude/jobs/808c5e27/tmp). Actual issue/milestone changes are the u…", + "lastProgressAt": 1784125647686, + "tokens": 152891, + "toolCalls": 12, + "durationMs": 668560, + "resultPreview": "Wrote both deliverables (nothing touched on GitHub — data only):\n\n- `/code/nxm/runtime/docs/design/issue-milestone-plan.json` (valid JSON; `generated_note` + 64 full `drafted_issues` + `tracker{open_count:61, milestones[9]}` + full `reconcile`)\n- `/code/nxm/runtime/docs/design/issue-milestone-plan.md` (sections a–h: exec summary, repurposed-milestone plan, new-issues table, close/modify/merge/dedu…" + } + ], + "totalTokens": 1124231, + "totalToolCalls": 109 +} \ No newline at end of file diff --git a/docs/design/issue-milestone-plan.json b/docs/design/issue-milestone-plan.json new file mode 100644 index 00000000..4bf26231 --- /dev/null +++ b/docs/design/issue-milestone-plan.json @@ -0,0 +1,2042 @@ +{ + "refined": { + "version": 3, + "decisions": [ + "D1 cut-before-2nd-venue", + "D2 chronological M0-M7 + SDK-early/guard-late", + "D3 shepherd=cow-bundle", + "D4 epic-decomposition + Project #1 Component + no-Version" + ], + "note": "PASS-2 of the reorg plan for nullislabs/shepherd. Transforms PASS-1 (issue-milestone-plan.v1.json) to encode owner decisions D1+D2+D3 (mfw78, 2026-07-15). Milestone TITLES relabelled M0..M7 in chronological execution order, renamed in place preserving each GitHub milestone NUMBER + all issue links; every drafted/moved issue re-milestoned to its new M-label. The three-repo cut is gated on gates (a)+(b) only; the genuine second venue is a post-cut acceptance milestone (M5). The 3rd repo is named shepherd (CoW-on-videre bundle); shepherd-sdk absorbed. DATA ONLY - nothing applied to GitHub." + }, + "generated_note": "Videre three-layer split issue and milestone reorganisation plan for nullislabs/shepherd (v3), generated 2026-07-16 against the develop tracker (61 open issues). DATA ONLY: nothing was created, closed, edited, or re-milestoned on GitHub; the user applies apply-plan.sh after review. The authoritative structure is epics[]: nine milestones M0 to M8 as a native sub-issue tree with one Component each. reconcile carries the closes, modifies, merges and dedups. Design source: docs/design/venue-platform-architecture.md and docs/design/videre-split-plan.md.", + "drafted_issues": [ + { + "key": "host-generalize-epic", + "title": "Epic: make nexum-runtime a generic, venue-agnostic component host", + "body": "## Context\n\nThe target platform is three layers with one acyclic dependency edge each: `nexum-runtime` (L1, universal host) <- `videre` (L2, generic intent/venue) <- `shepherd` (L3, concrete CoW). See `docs/design/venue-platform-architecture.md` s2 and `docs/design/videre-split-plan.md` s1-s2.\n\nToday the host is *not* venue-agnostic: `wit/nexum-host/types.wit:8` imports `nexum:intent/types.{receipt, intent-status}`; the `PoolRouter` is a privileged `HostState.pool_router` field (`host/state.rs:54`); `ModuleKind` is a hardcoded `EventModule | VenueAdapter` enum with a `match kind` in the supervisor; and the KNOWN capability table bakes in `pool` and `cow-api` rows. Until these are removed the engine cannot compile without L2's WIT and an acyclic repo split is physically impossible.\n\nThis epic tracks the work to make `nexum-runtime` know only two generic component roles - worker and provider - plus a generic `Extension` seam (`link`, `capabilities`, `service`, `provider`), so anything venue/intent/cow-shaped is contributed from outside. See the pinned seam design in `docs/design/videre-split-plan.md` s7.2 and the pinned sequencing in s8.\n\n## Scope\n\nChild issues, in sequence: R6 host<->intent WIT decouple (P0 master gate); grow the Extension seam to carry service+provider (S1); extract PoolRouter->VenueRegistry and delete the privileged field; extract the generic supervised-component/host-actor primitive from AdapterActor (folds R8); de-hardcode the KNOWN table into nexum-world; add the zero-leak CI gate; extract the generic launcher + bare Ext=() engine bin (S1); flip nexum:host WIT to crate-local wit-deps + carve nexum-runtime as L1 repo (S2); guest SDK seams + identity signing backend (deferred).\n\n## Acceptance\n\nSee acceptance field. A second platform would be just another impl Extension.\n\nCross-ref: `docs/design/venue-platform-architecture.md` s6, s8; `docs/design/videre-split-plan.md` s7.2, s8.", + "kind": "epic", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "epic", + "component/engine-runtime", + "needs-design" + ], + "acceptance": "Zero-leak CI gate green (no intent/venue/cow symbols or crate edges in nexum-runtime); echo-venue installs and a worker submits through the generic Extension seam; PoolRouter field deleted; a second platform is a plain impl Extension.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Runtime/Lifecycle" + }, + { + "key": "host-r6-decouple", + "title": "R6: decouple nexum:host from nexum:intent - host event carries opaque status bytes (MASTER GATE)", + "body": "See docs/design/venue-platform-architecture.md s6 R6, s8 dec-2 and docs/design/videre-split-plan.md s1, s5 (Phase 0), s8 P0.1. wit/nexum-host/types.wit:8 does `use nexum:intent/types@0.1.0.{receipt, intent-status}` and the host event variant carries an intent-status-update, so the L1 host world imports the L2 intent package; until that use is gone nexum-runtime cannot compile without L2 WIT and an acyclic three-repo split is physically impossible. This is the master gate: move #1, before any crate moves. Drop the use; redefine the host event stream to carry opaque status bytes; specify (needs-design) the versioned destructuring contract those bytes commit to; regenerate goldens; re-assert the byte-identical tip oracle; land in the Phase-0 fold.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "breaking", + "component/wit-abi", + "needs-design", + "effort/days" + ], + "acceptance": "nexum:host WIT no longer uses nexum:intent (leaf package); host event carries opaque status bytes with a documented versioned destructuring contract; cargo tree -p nexum-runtime reaches no intent crate; goldens + tip oracle re-validated.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "WIT/ABI" + }, + { + "key": "host-extension-seam-roles", + "title": "Grow the Extension seam to carry worker/provider roles (service + provider, ProviderKind, HostService)", + "body": "See docs/design/videre-split-plan.md s7.2, s7.3, s8 S1.1. The extension seam today is Extension { link, capabilities } (host/extension.rs): it can add host interfaces a worker imports but cannot register a component kind (ModuleKind is a hardcoded enum) or a host service (PoolRouter is a privileged field). The fix: the runtime knows only two generic roles - worker (host pushes events at it) and provider (host holds it behind a serialized actor). Extension grows to contribute namespace/capabilities/link/service/provider; add HostService (type-erased, on HostState.services[ns]) and ProviderKind (link + async install). MSRV 1.94 async strategy: native AFIT for hot static-dispatch guest traits; async_trait only for the one dyn cold-path ProviderKind::install; keep HostService sync so it stays dyn-compatible. This is the long pole and has no prior ADR.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "feature", + "component/engine-runtime", + "needs-design", + "effort/weeks" + ], + "acceptance": "Extension seam carries namespace/capabilities/link/service/provider; HostService + ProviderKind traits exist; HostState.services is a typed per-namespace map; compiles on MSRV 1.94 with native AFIT for hot traits and async_trait only for ProviderKind::install; existing worker boot path unchanged.", + "depends_on": [ + "host-r6-decouple" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Runtime/Lifecycle" + }, + { + "key": "host-venue-registry-extract", + "title": "Extract PoolRouter -> VenueRegistry as an extension-owned service; delete the privileged supervisor field", + "body": "See docs/design/videre-split-plan.md s5 (Phase S1), s7.2, s8 S1.2, s6.2 D1. The router is a privileged field HostState.pool_router (host/state.rs:54), built and cloned through supervisor.rs. Once the seam carries a service, the router becomes an extension-owned HostService: videre registers it as the (renamed, un-privileged) VenueRegistry behind HostState.services[ns]. Forcing-function acceptance: deleting HostState.pool_router and still booting echo-venue proves L1 is intent-free. This issue covers the L1 half: making the core hold the router only through the generic services map and removing the named field. The VenueRegistry implementation itself is owned by the L2 extension.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "debt", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "HostState.pool_router field deleted; router carried only via HostState.services as a type-erased HostService (renamed VenueRegistry); no PoolRouter symbol in nexum-runtime/src; echo-venue still boots and routes a submit.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Runtime/Lifecycle" + }, + { + "key": "host-generic-component-kind", + "title": "Extract a generic supervised-component/host-actor primitive from AdapterActor; collapse match kind (folds R8)", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1), s7.2, s6.2 D2 and venue-platform-architecture.md s6 R8. The supervisor hardcodes component kinds (enum ModuleKind { EventModule | VenueAdapter } with a match kind dispatch) and AdapterActor is a special-cased provider actor. Extract the generic supervised-component/host-actor primitive (fuel refuel, trap->error projection, async-mutex serialization, restart/poison-sweep membership) into nexum-runtime; collapse the match kind to a generic role loop; fold R8 so provider components join the restart/poison sweeps and expose adapters_alive/providers_alive.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "feature", + "component/lifecycle", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "Generic supervised-component/host-actor primitive extracted from AdapterActor into nexum-runtime (fuel/trap-projection/serialization/sweeps); supervisor match-kind collapsed to a generic role loop; provider components join restart/poison sweeps with a liveness query (R8 folded); no venue-named kind arm in the core.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Runtime/Lifecycle" + }, + { + "key": "host-nexum-world-registry", + "title": "De-hardcode the KNOWN capability table (drop baked pool + cow-api rows); extract world synthesis to nexum-world lib", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1 step 3), s6.2 D3. The capability model and per-component world synthesis already shipped, but the KNOWN table still bakes a pool row (world.rs:74) and a cow-api -> shepherd:cow row (world.rs:80, an L1->L3 name leak). Delete the baked rows; source per-namespace rows from registered extensions; extract world.rs synthesis + the KNOWN table into a new plain lib nexum-world (L1); rewrite find_wit_root from workspace-ancestor-walk to crate-local wit/ + wit/deps resolution; keep #[module] in nexum-module-macros; leave venue/intent macros to videre-macros.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "debt", + "component/capabilities", + "component/sdk", + "effort/days" + ], + "acceptance": "Baked pool and cow-api/shepherd:cow rows removed from the KNOWN table; capability rows are registry-driven from registered extensions; world synthesis + table extracted to a plain nexum-world L1 lib; find_wit_root resolves crate-local wit/. No venue/cow string in nexum-world.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Engine" + }, + { + "key": "host-zero-leak-ci-gate", + "title": "CI gate: nexum-runtime has zero venue/intent/cow symbols", + "body": "See docs/design/videre-split-plan.md s2.2, s8 S1.3, s6.1 risk 3. Once the host is generic, the venue-agnostic invariant must be enforced permanently. Add a permanent CI check that fails if the host regains intent/venue/cow knowledge: rg 'nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' crates/nexum-runtime/src must return empty; cargo tree -p nexum-runtime must not reach any videre-*/intent/cow crate. Wire it as a required check; document the invariant in the crate charter.", + "kind": "chore", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "debt", + "component/engine-runtime", + "effort/hours" + ], + "acceptance": "CI has a required check that greps nexum-runtime/src for intent/venue/cow symbols and runs cargo tree -p nexum-runtime for forbidden crate edges, failing on any hit; green at the generalized tip; invariant documented in the crate charter.", + "depends_on": [ + "host-venue-registry-extract", + "host-nexum-world-registry", + "host-generic-component-kind" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Engine" + }, + { + "key": "host-generic-launcher-bin", + "title": "Extract a generic launcher lib + bare Ext=() nexum engine bin (retire the backwards nexum-cli -> cow dep)", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1 step 4), s6.2 D7. The L1 charter calls for a generic launcher lib (nexum-launch) plus a bare Ext=() engine binary (nexum). Today the CLI composition root wires cow in directly (launch.rs:16,47 shepherd_cow_host::extension / with_extensions), making nexum-cli depend on shepherd-cow-host - a backwards L1->L3 crate edge. Extract nexum-launch; add a bare nexum bin with Ext=(); remove the cow wiring from the generic path; the cow composition root becomes a separate shepherd bin destined for L3.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "debt", + "component/sdk", + "effort/days" + ], + "acceptance": "Generic nexum-launch lib composes a runtime from a supplied extension list; a bare Ext=() nexum bin boots with zero cow/venue deps; no L1 crate depends on shepherd-cow-host; cow composition root moved out to a shepherd bin (L3).", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Runtime/Lifecycle" + }, + { + "key": "host-wit-deps-flip-carve", + "title": "Flip nexum:host WIT to crate-local wit-deps and carve nexum-runtime as the L1 repo", + "body": "See docs/design/videre-split-plan.md s5 (Phase S2), s8 S2, s6.2 D8/D9/D10. After the host is proven venue-agnostic (zero-leak gate green), the L1 slice can be physically extracted. Introduce wit-deps (deps.toml); flip every bindgen! path list and the macro WIT-root off ../../wit/* to crate-local wit/ + wit/deps/ (S2a). History-preserving git-filter-repo --path extraction of nexum-runtime as the L1 repo, reusing the keeper-rename template (byte-identical tip oracle + jj/mergiraf). Adopt independent per-package semver for nexum:host (@0.1.x). Blocked on R6 + full S1 generalization landing first; the free-reshape window must be closed before the cut.", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "debt", + "component/wit-abi", + "component/tools", + "effort/weeks", + "blocked" + ], + "acceptance": "nexum-runtime builds against crate-local wit/ + wit/deps with lockfiles checked in; history-preserving git-filter-repo carve yields a standalone L1 repo passing the byte-identical tip oracle; zero-leak gate green in the carved repo; nexum:host on independent semver.", + "depends_on": [ + "host-zero-leak-ci-gate", + "host-generic-launcher-bin" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Tooling/Packaging" + }, + { + "key": "host-backend-guest-seams", + "title": "Add guest SDK seams + Mocks for identity/messaging/remote-store, wired to the stub backends", + "body": "See docs/design/venue-platform-architecture.md s4 gap #5, s6 R5 context, s7 Phase 4. Three of the six L1 host interfaces have no guest seam: identity, messaging, remote-store have adapter:None in the macro KNOWN table, no *Host trait, and no Mock*. ADR-0009's 'each interface becomes a trait + MockX' is unfulfilled for all three. Add IdentityHost/MessagingHost/RemoteStoreHost guest traits + Mock*; wire the bind-macro slices; widen the Host supertrait to all six (or opt-in subset supertraits). No change to backend liveness scope.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "dx", + "feature", + "component/sdk", + "effort/days" + ], + "acceptance": "IdentityHost/MessagingHost/RemoteStoreHost guest traits + Mock* exist and are recognised by the bind macros; wired to the stub backends; Host supertrait covers all six (or documented subset supertraits); modules host-free unit-test against all three.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "SDK/DX" + }, + { + "key": "host-identity-signing-backend", + "title": "Wire the identity signing backend (accounts / sign / sign_typed_data) with the egress guard", + "body": "See docs/design/venue-platform-architecture.md s6 R3, s8 decision 7, s7 (Phase 3). The identity host interface is a 0.3 stub with an empty roster (accounts()->Ok(vec![])); the real value-movement signing path for requires-signing intents runs through it, currently unenforced and unimplemented. Per decision 7, identity signing lands with the egress guard (Phase 3). Implement the keystore-backed backend (accounts/sign/sign_typed_data); land it alongside the egress-guard epic so the guard checkpoint sits at the signed-tx boundary; realise or retract the chain-delegates-to-identity claim. Blocked on the egress-guard epic (M5).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "feature", + "security", + "component/identity", + "effort/days" + ], + "acceptance": "Keystore-backed identity backend implements accounts/sign/sign_typed_data with a non-empty roster; signing integrates with the egress-guard checkpoint at the requires-signing boundary; the doc-08 signing-delegation claim is realised or retracted.", + "depends_on": [ + "host-backend-guest-seams" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host", + "component": "Host Backends" + }, + { + "key": "videre-epic", + "title": "Epic: videre - the generic intent-venue abstraction (L2)", + "kind": "epic", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "epic", + "component/wit-abi", + "component/sdk", + "feature", + "effort/weeks" + ], + "depends_on": [ + "host-r6-decouple" + ], + "acceptance": "All child issues closed; the videre:* WIT DAG (value-flow <- types <- venue) builds acyclically over nexum:host; echo-venue + a keeper compile against videre-sdk alone; cargo tree -p nexum-runtime reaches no videre/intent crate (host-area gate).", + "body": "Epic tracking videre L2 (the venue-neutral intent-settlement + quoting abstraction). Today L2 lives under nexum:intent / nexum:value-flow / nexum:adapter WIT + nexum-venue-sdk / nexum-venue-test / nexum-macros. Per decision 8 the whole WIT set is pre-release cruft pinned only by echo-venue, so reshaping now costs an internal recompile + a train fold, never a wire break. Scope: rename to videre:*; pin the videre:* surface (types/venue/value-flow); add quote (client + adapter) + install-time body-versions handshake; build videre-sdk + #[videre::venue]/#[videre::keeper] + typed VenueClient; conformance kit; @0.1.0 normalization. Out of scope: maker-side offer (#355, post-0.1); 0.1 is EVM-only (decision 5). See docs/design/venue-platform-architecture.md s2, s6, s8; docs/design/videre-split-plan.md s2.3, s3, s7.4-s7.6, s8.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-wit-rename", + "title": "videre: rename nexum:intent/* WIT + readability renames -> videre:*", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "component/wit-abi", + "breaking", + "debt", + "effort/days" + ], + "depends_on": [ + "host-r6-decouple" + ], + "acceptance": "No nexum:intent / nexum:value-flow / nexum:adapter package or use reference remains; the tree builds; echo-venue/echo-client pass against regenerated goldens; the byte-identical tip oracle holds across the train; nexum:host and shepherd:cow brands are untouched.", + "body": "One mechanical rename folded into the Phase-0 oracle-validated git-filter-repo/jj pass. WIT packages: nexum:intent -> videre:venue (+ videre:types); nexum:value-flow -> videre:value-flow; retire nexum:adapter (its venue-adapter world folds into videre:venue). Keep nexum:host and shepherd:cow. pool face splits into worker face videre:venue/client and provider face videre:venue/adapter. Rust symbols: PoolRouter->VenueRegistry, AdapterActor->VenueActor, GuardPolicy/AllowAllGuard->EgressGuard. Introduce VenueId newtype. Regenerate goldens; re-assert tip oracle. See videre-split-plan.md s3.1, s8 P0.2; venue-platform-architecture.md s5.2.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-wit-surface", + "title": "videre: pin the videre:* WIT surface (types / venue / value-flow)", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "component/wit-abi", + "breaking", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-rename" + ], + "acceptance": "The three packages match the pinned shapes in videre-split-plan.md s7.4; the worker client face and provider adapter face mirror; venue-error carries rate-limited{retry-after-ms} + denied; value-flow uses named records (no anonymous ERC tuples); codec goldens carry a version discriminator + reject-unknown + a non-empty-vector assertion; echo-venue implements the pinned adapter face and passes.", + "body": "Pin the shape of the videre contract (the rename issue does the namespace move; both ride one Phase-0 fold). 0.1 is EVM-only (decision 5). Pin videre:types (intent-header, auth-scheme {eip1271,eip712}, settlement {chain:u64}, receipt=list, submit-outcome {accepted/requires-signing}, unsigned-tx, intent-status, venue-error with rate-limit); videre:venue (worker client + provider adapter mirror faces); videre:value-flow (asset-amount, asset {native, erc20}, named records - no anonymous tuples). Codec goldens: version discriminator + reject-unknown + non-empty assertion. Doc caveats: valid-until->valid-until-ms, denied() MUST-NOT-retry, derive-header purity, gives adapter-attested not host-verified. See videre-split-plan.md s7.4; venue-platform-architecture.md s6 R1/R3, s7 Phase 0, s8 decision 5.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-wit-normalize", + "title": "videre: normalize all WIT packages to a single @0.1.0", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "component/wit-abi", + "debt", + "effort/hours" + ], + "depends_on": [ + "videre-wit-rename" + ], + "acceptance": "Every WIT package and every use ...@ reference reads @0.1.0; the docs/migration/0.1-to-0.2.md file and the 'Migration from 0.1' prose in docs/08 are deleted; the tree builds and the byte-identical tip oracle holds.", + "body": "The WIT package version strings (nexum:host@0.2.0, nexum:intent@0.1.0, shepherd:cow@0.2.0, ...) are pre-release cruft, not compatibility boundaries; no external consumer pins any (decision 8). Reset every package + use reference across nexum:host, videre:* (post-rename), shepherd:cow to @0.1.0 as one git-filter-repo/jj fold with the tip oracle. Delete migration cruft: docs/migration/0.1-to-0.2.md and the 'Migration from 0.1' prose in docs/08-platform-generalisation.md. After the cut, adopt independent per-package semver. See venue-platform-architecture.md s7 Phase 0, s8 decision 8; videre-split-plan.md s8 P0.3.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-quote", + "title": "videre: add quote to videre:venue (client + adapter) + IntentClient typestate", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "component/wit-abi", + "component/sdk", + "breaking", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface" + ], + "acceptance": "videre:venue/client.quote(venue, body) and videre:venue/adapter.quote(body) exist and return a value-flow-typed quote; IntentClient.quote(&body)?.submit()? typestate compiles; echo-venue implements quote; the quote record is thin (gives/wants/fee/valid-until-ms) and EVM-only.", + "body": "The vision is settlement + quoting but the contract exposes only submit/status/cancel - quoting does not exist. Free to add now (decision 8), a wire break later, so it lands in the Phase-0 window. Add quote to both faces of videre:venue; define the quote record in videre:types thin and value-flow-typed ({gives,wants,fee,valid-until-ms}); SDK IntentClient.quote(&body)?.submit()? typestate. Firm/RFQ quotes and maker-side offers out of scope -> #355. See videre-split-plan.md s3.3, s7.4, s8 P0.4, D6; venue-platform-architecture.md s4 gap 1, s8 decision 5.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-body-versions-handshake", + "title": "videre: install-time body-versions schema handshake", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "component/wit-abi", + "component/manifest", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface", + "host-extension-seam-roles" + ], + "acceptance": "videre:venue/adapter.body-versions() -> list exists; module and adapter manifests declare a supported body_version (or set); Supervisor::install refuses to boot a keeper/adapter pair whose versions do not intersect, failing fast with a logged error; a mismatched-pair test asserts the refusal.", + "body": "Bodies are opaque list with a guest-side borsh version tag; schema agreement is never a checked property (R7/B4). Decision 4: an install-time capability handshake, not WIT-freeze-gated. WIT: videre:venue/adapter.body-versions() -> list (reserved in the surface pin, wired here). Manifest: a body_version field in module and adapter module.toml. Enforcement: videre-host contributes an install predicate via the generalized Extension seam; Supervisor::install asserts intersection and refuses mismatched pairs. Depends on the host-area seam generalization for the install predicate. See venue-platform-architecture.md s6 R7, s8 decision 4; videre-split-plan.md s3.3.", + "area": "VIDERE", + "component": "WIT/ABI" + }, + { + "key": "videre-sdk-crate", + "title": "videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "component/sdk", + "dx", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface", + "host-extension-seam-roles" + ], + "acceptance": "nexum-venue-sdk -> videre-sdk; the crate exports VenueAdapter, IntentBody, IntentClient

, VenueId, and a generic Keeper::sweep assembler over a Sweep outcome resolving the dangling ConditionalSource::Outcome; the world-neutral keeper primitives stay in nexum-sdk; a keeper compiles against videre-sdk alone with no cow/host dep.", + "body": "The venue-author SDK persona is shipped (nexum-venue-sdk/nexum-venue-test) but mis-named for the split and missing its assembler. Rename to videre-sdk; own VenueAdapter, IntentBody codec + BodyError, IntentClient

, VenueId. Add the generic Keeper::sweep assembler (WatchSet->Gates->source.poll->Retrier->Journal) + a shared Sweep outcome resolving ConditionalSource::Outcome. Keep world-neutral primitives (WatchSet/Gates/Journal/Retrier/ConditionalSource) in nexum-sdk (D4). DX polish: VenueFault with Display+IntoStaticStr; #[non_exhaustive]; seal extension traits. See videre-split-plan.md s2.3, D4, s8 S1; venue-platform-architecture.md s4 gap 6, s5.3.", + "area": "VIDERE", + "component": "SDK/DX" + }, + { + "key": "videre-venue-macro", + "title": "#[videre::venue]: single blessed authoring path emitting impl VenueAdapter", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "component/sdk", + "dx", + "effort/days" + ], + "depends_on": [ + "videre-sdk-crate" + ], + "acceptance": "#[videre::venue] emits impl VenueAdapter (not a raw Guest impl) + the videre:venue/adapter export + the manifest kind; export_venue_adapter! is demoted to the internal codegen the macro expands to (no public second path); import-narrowing is by construction (no dead-import elision); echo-venue uses the macro and the *_to_golden bridge boilerplate is gone.", + "body": "Two authoring paths fork the one clear arrangement (R4): #[venue] emits impl Guest over raw bindgen and bypasses the typed VenueAdapter trait, while export_venue_adapter! routes through it on a differently-named world importing chain+messaging unconditionally. Decision 6: #[videre::venue] is the single blessed path, fixed to emit impl VenueAdapter. Demote export_venue_adapter! to internal codegen; narrow imports by construction (synthesize_venue); kill the ~80-line *_to_golden bridges. Lives in videre-macros after the S1 macro split. See venue-platform-architecture.md s6 R4, s8 decision 6; videre-split-plan.md s3.2, s7.5, s8 S1.3.", + "area": "VIDERE", + "component": "SDK/DX" + }, + { + "key": "videre-keeper-macro", + "title": "#[videre::keeper] macro + typed VenueClient", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "component/sdk", + "dx", + "feature", + "effort/weeks" + ], + "depends_on": [ + "videre-venue-macro", + "videre-quote" + ], + "acceptance": "#[videre::keeper] emits a worker that drives a venue via a typed VenueClient (alloy-style, typed not list) wrapping videre:venue/client, wiring the event subs; a keeper written against VenueClient compiles and calls quote/submit/status/cancel with typed bodies; static-dispatch (native AFIT), zero boxing on the hot path.", + "body": "The venue author gets #[videre::venue]; the keeper author needs the mirror: a macro-driven, reth/alloy-grade way to drive a venue over videre:venue/client without hand-writing list marshalling. #[videre::keeper] - write logic against a typed VenueClient; the macro wires event subs and the videre:venue/client import. VenueClient - alloy-style typed wrapper (quote/submit/status/cancel), typed bodies via the venue's IntentBody, VenueId-keyed. MSRV 1.94: native AFIT for hot traits, zero boxing. Prove: a keeper drives echo-venue. See videre-split-plan.md s7.1, s7.3, s7.5, s8 S1b.3.", + "area": "VIDERE", + "component": "SDK/DX" + }, + { + "key": "videre-conformance-kit", + "title": "videre-test: conformance kit + cargo-test-fails-if-wire-drifts gate", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "component/sdk", + "dx", + "effort/days" + ], + "depends_on": [ + "videre-sdk-crate" + ], + "acceptance": "nexum-venue-test -> videre-test; the kit ships CodecVectors / HeaderGoldens / MockTransport; a venue's cargo test fails on any wire-shape drift; goldens carry the version discriminator + reject-unknown + a non-empty-vector assertion; goldens regenerate under the videre:* namespace and the tip oracle holds.", + "body": "The conformance kit (nexum-venue-test) is the cargo-test-fails-if-wire-drifts gate holding every venue to portable codec vectors + header goldens. Well-built but mis-named and its codec golden passes vacuously on an empty vector. Rename to videre-test; keep CodecVectors/HeaderGoldens/MockTransport; harden goldens (version discriminator + reject-unknown + non-empty assertion); regenerate under videre:* and re-assert tip oracle; align mock-grant fidelity to the host (the #297 divergence). See venue-platform-architecture.md s2, s4 gap 9, s6 R1; videre-split-plan.md s2.3, s8 S1.", + "area": "VIDERE", + "component": "SDK/DX" + }, + { + "key": "cow-onvidere-epic", + "title": "Epic: shepherd - the shepherd bundle (cow adapter cdylib + composable-cow + ethflow keepers + a nexum-runtime host, one deployable)", + "kind": "epic", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "epic", + "component/cow-integration" + ], + "depends_on": [ + "host-extension-seam-roles" + ], + "acceptance": "cow-venue cleaved into orderbook-only venue vs composable-cow keeper (CI-gated); a cow adapter cdylib exports videre:venue/adapter and settles real orders over wasi:http; CowApiHost/cow-api/cow-ext retired from the hot path; composable-cow and ethflow keepers run on videre:venue/client; the shepherd-cow event-ABI WITs are the sole L3-owned cow surface; shepherd-sdk absorbed into the shepherd bundle (no standalone member); no L1/L2 crate compiles any cow symbol; the whole slice ships as one deployable shepherd bundle.", + "body": "shepherd is the third repo and the shepherd bundle: the concrete CoW slice shipped as ONE deployable - the cow-venue adapter cdylib + the composable-cow keeper + the ethflow keeper + a nexum-runtime host (the shepherd bin) - proving videre L2 is genuinely venue-neutral. Per decision D3 (2026-07-15) shepherd-sdk is NOT kept standalone: it folds INTO the shepherd bundle (absorbed). Today the CoW hot path bypasses the generic seam: shepherd-sdk/src/cow/run.rs submits via CowApiHost, never videre:venue. crates/cow-venue is a body-only [lib] mixing orderbook and composable concerns. Scope (child issues): cleave cow-venue; build the cow adapter cdylib; settle the idempotency seam; port composable-cow + ethflow keepers onto videre:venue/client; retire CowApiHost/cow-api/cow-ext; own the shepherd-cow event-ABI WITs at L3; absorb shepherd-sdk into the bundle; (deferred, fork-gated) the poll wire-swap deleting composable.rs. All depends on the generalized runtime seam. See videre-split-plan.md s4, s7.6, s8 S1b; venue-platform-architecture.md s6 R2; decision D3 (2026-07-15).", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "cleave-cow-venue", + "title": "Cleave cow-venue: orderbook-only venue vs composable-cow keeper", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "debt", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic" + ], + "acceptance": "crates/cow-venue contains only orderbook concerns (OrderBody, borsh codec, classification.toml/rs, orderbook client); ComposableBody/composable.rs/getTradeableOrderWithSignature/COMPOSABLE_COW/ConditionalOrderCreated/revert-selector/LegacyRevertAdapter/Verdict live in a separate composable-cow keeper crate/module; a CI gate asserts the venue crate has zero Composable*/getTradeableOrder/revert-selector symbols; a new CoW keeper producing OrderBodys can be written without importing composable machinery; workspace green; goldens regenerated.", + "body": "crates/cow-venue mixes both sides: lib.rs re-exports OrderBody (order.rs) and ComposableBody (composable.rs). The load-bearing rule (s7.6): the cow venue is only the CoW orderbook - submit/quote/status/cancel of an OrderBody on api.cow.fi, mapping orderbook errors to venue-error, never heard of ComposableCoW/getTradeableOrderWithSignature/revert selectors/TWAP/EthFlow. Split into (a) venue: orderbook + OrderBody + classification; (b) composable-cow keeper: ComposableBody, COMPOSABLE_COW addr + topic-0, getTradeableOrderWithSignature, revert-selector + LegacyRevertAdapter + Verdict (ADR-0013). Drop Composable variant from the venue body. Add CI gate. Pure-Rust re-split, zero contract dependency, lands now (s4.4). See videre-split-plan.md s7.6, s4.1-s4.3; ADR-0013.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "cow-idempotency-seam", + "title": "Settle the CoW idempotency seam before order assembly moves into the adapter", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue" + ], + "acceptance": "the keeper can derive/obtain a deterministic intent-id pre-submit without assembling OrderCreation itself; chosen mechanism implemented (adapter.derive-header returns a deterministic intent-id the keeper journals, or SubmitOutcome carries receipt = UID); the submitted: Journal check remains effective across the keeper->adapter boundary (no double-post window); a regression test exercises resubmit-after-restart and asserts a single orderbook POST.", + "body": "shepherd-sdk/src/cow/run.rs today derives the client-side order UID and checks the submitted: Journal before the network call. Once OrderCreation/UID assembly moves into the adapter's submit, the keeper can no longer derive the UID pre-submit - a double-post risk (s4.3, s6.1 risk 6). Settle this before assembly moves. Pick one: adapter.derive-header returns a deterministic intent-id the keeper journals; or SubmitOutcome carries receipt = UID. Re-route the Journal idempotency check onto that identifier. See videre-split-plan.md s4.3, s6.1 risk 6.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "cow-venue-cdylib", + "title": "Build the cow adapter cdylib (#[videre::venue] over wasi:http)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "component/cow-integration", + "component/wit-abi" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue", + "cow-idempotency-seam", + "host-extension-seam-roles" + ], + "acceptance": "crates/cow-venue builds as crate-type=[cdylib] and exports videre:venue/adapter; #[videre::venue] impl Venue for CowVenue implements derive-header/quote/submit/status/cancel + body-versions; declared capabilities are [chain, http] (transport-only), no host cow interface imported; build_order_creation/order_uid_hex/gpv2_to_order_data move into the adapter's submit; classification.toml moves into the adapter and projects orderbook errorType->venue-error; the adapter POSTs OrderCreation over wasi:http; videre-test golden vectors pass; the venue installs as a provider through the generalized seam and settles an order end-to-end; client.quote drives the real CoW /quote endpoint end-to-end pre-cut, validating the quote wire shape against a real venue before it is frozen through the M4 cut.", + "body": "The cow venue becomes a real cdylib targeting videre:venue/adapter, replacing the body-only [lib]. Per s4.1, cow enters as an adapter cdylib on the venue-adapter world - reaching the orderbook as opaque bytes over wasi:http + nexum:host/chain, needing no separate composable-cow world (the R2 category error). Grow crates/cow-venue to a cdylib exporting videre:venue/adapter via #[videre::venue]; move order assembly + classification.toml into submit; capabilities [chain, http] only; wire body-versions. Depends on the idempotency seam and the generalized runtime seam. See videre-split-plan.md s4.1-s4.3, s7.5-s7.6; venue-platform-architecture.md s6 R2, s5.4.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "shepherd-cow-event-abi-wits", + "title": "Own the shepherd-cow event-ABI WITs at L3", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "component/wit-abi", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic" + ], + "acceptance": "the shepherd-cow event-ABI WITs (ConditionalOrderCreated, EthFlow.OrderPlacement, and any other cow on-chain event surfaces the keepers decode) live under wit/shepherd-cow and are consumed only by L3 crates; no videre:* or nexum:host package uses shepherd:cow; the composable-cow and ethflow keepers resolve their event ABIs from this package; the legacy host-ext surface in shepherd:cow is clearly marked as retiring.", + "body": "All cow protocol knowledge, including on-chain event ABIs the keepers watch, belongs to the shepherd L3 repo and must never appear in L1/L2 (s2.4, s7.6). The shepherd-cow WIT package holds the event-ABI surfaces (ConditionalOrderCreated topic-0; EthFlow.OrderPlacement) plus the legacy host-ext surface that is retiring. Consolidate under wit/shepherd-cow as the sole L3-owned cow WIT surface; ensure keepers resolve ABIs from here; mark the legacy host-extension interface retiring (deleted at the fork-gated poll wire-swap). See videre-split-plan.md s2.4, s7.6, s8 (S1b).", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "WIT/ABI" + }, + { + "key": "composable-cow-keeper-port", + "title": "Port the composable-cow keeper onto videre:venue/client (ADR-0013 Verdict)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "component/modules", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue", + "cow-venue-cdylib", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "videre-keeper-macro" + ], + "acceptance": "the composable-cow keeper is a strategy event-module targeting nexum:host/event-module and imports videre:venue/client (not CowApiHost); it watches ConditionalOrderCreated, polls getTradeableOrderWithSignature, maps the ADR-0013 Verdict to a Sweep outcome, emitting a plain OrderBody/CowIntentBody via client.submit(CowVenue::ID, bytes); run.rs:~138 no longer calls host.submit_order(...)/CowApiHost; authored with #[videre::keeper] against a typed VenueClient; the coarse venue-error retry hint survives classification; dev/m1 green with the keeper driving the cdylib adapter end-to-end (excluding the fork-gated poll wire-swap).", + "body": "The composable-cow keeper's job (s4.1-s4.3, s7.6): watch conditional orders, poll them, produce a plain OrderBody, submit through videre:venue/client - driving the cow venue with opaque bodies, never importing the venue's world. The concrete embodiment of gap #2: flip the legacy CowApiHost submit onto the generic seam. The Rust seam re-split has zero contract dependency and lands now (s4.4); only the poll wire-swap deleting composable.rs/LegacyRevertAdapter is fork-gated and tracked separately. Keep the poll/decide/classify logic (Verdict->Sweep); route submission through videre:venue/client; author with #[videre::keeper]. See videre-split-plan.md s4.1-s4.4, s7.6, s8 S1b.3; ADR-0013.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "ethflow-keeper", + "title": "Ethflow keeper: observe indexed orders via cow.status", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "feature", + "component/modules", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cow-venue-cdylib", + "shepherd-cow-event-abi-wits", + "composable-cow-keeper-port" + ], + "acceptance": "the ethflow keeper is a second worker on the same shared cow venue - it does not submit; it watches EthFlow.OrderPlacement (EthFlow consts live in the keeper), computes the order UID, and calls client.status(CowVenue::ID, uid) to verify the orderbook indexed the on-chain EthFlow order; no ethflow specifics leak into the cow venue; authored with #[videre::keeper] against the typed VenueClient; a test drives the observe/verify path against orderbook-mock.", + "body": "Ethflow falls out for free as a second keeper on the same shared venue (s7.6): it doesn't submit, it statuses a computed UID to verify the orderbook indexed the on-chain EthFlow order. Same venue, different verb - proves the shared-venue platform model (one connection, one quota, many keepers). A worker that watches EthFlow.OrderPlacement, holds the EthFlow contract consts, computes the UID, observes via client.status. Reuses the typed VenueClient and the shepherd-cow event-ABI WITs. See videre-split-plan.md s7.6, s7.1, s8 (S1b.3).", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "cow-api-retire", + "title": "Retire CowApiHost / cow-api / cow-ext (the biggest lever)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "debt", + "component/cow-integration", + "breaking" + ], + "depends_on": [ + "cow-onvidere-epic", + "cow-venue-cdylib", + "composable-cow-keeper-port" + ], + "acceptance": "no keeper or module submits via CowApiHost/host.submit_order(...); the hot path runs entirely through videre:venue/client; the CowApiHost trait and the cow-api/cow-ext host-extension surfaces are removed from the default build (or reduced to a deprecated read-only shim); the KNOWN capability table no longer bakes a cow-api->shepherd:cow row (registry-driven); docs/08 no longer documents the host-extension submit model as the live path; workspace green with the extension gone from the hot path.", + "body": "Retiring CowApiHost/cow-api/cow-ext is the design doc's explicitly-named biggest lever (s7.6, s4.2). The live CoW submit path today bypasses the generic seam: module -> shepherd:cow/cow-api host extension -> cowprotocol, assembling OrderCreation on the strategy side. Once the cdylib adapter exists and the composable-cow keeper submits through videre:venue/client, the legacy extension is dead weight and must retire. Delete the CowApiHost submit trait and the cow-api/cow-ext host extension from the hot path (retain a deprecated read-only shim only if the legacy read path still needs it); remove the baked cow-api->shepherd:cow KNOWN row; update docs/08. See videre-split-plan.md s4.2, s7.6, s8 S1b.2; venue-platform-architecture.md s3.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "composable-poll-wire-swap", + "title": "Composable-cow poll wire-swap: delete composable.rs / LegacyRevertAdapter (fork-gated)", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "debt", + "component/modules", + "component/cow-integration", + "blocked", + "needs-design" + ], + "depends_on": [ + "cow-onvidere-epic", + "composable-cow-keeper-port" + ], + "acceptance": "gated: proceeds only once the fork's deployments/networks.json is non-empty on a shepherd target chain; composable.rs/LegacyRevertAdapter deleted; Verdict::Post is fully populated by the structured non-reverting poll; the Verdict::NeedsInput arm is wired and dispatch-tested; Verdict::Post.next_poll_timestamp modelled as Option/NextPoll (not the 0 sentinel that collides with the fork wire); the legacy host-ext surface in wit/shepherd-cow deleted.", + "body": "ADR-0013's structured non-reverting poll cannot be instantiated until the ComposableCoW fork is deployed: Verdict::Post is the one variant LegacyRevertAdapter never produces (composable.rs), and Verdict::NeedsInput is dead surface until IOrderModule/the fork lands. Per s4.4, this poll wire-swap is hard-blocked on the fork's deployments/networks.json being non-empty - and must NOT be coupled to the keeper port, or the whole shepherd split freezes behind a third-party deployment clock. Delete composable.rs/LegacyRevertAdapter and switch the poll onto the fork's structured non-reverting getTradeableOrderWithSignature; fully populate Verdict::Post; wire/test NeedsInput; replace the 0-sentinel next_poll_timestamp; delete the legacy host-ext surface. See videre-split-plan.md s4.4, s8 (deferred); venue-platform-architecture.md s6 R2, s7 Phase 1-Wave-1; ADR-0013.", + "area": "shepherd (the CoW-on-videre bundle ; concrete reference venue)", + "component": "CoW" + }, + { + "key": "split-epic-p0", + "title": "Epic (P0): free monorepo reshape - land the master gate that makes an acyclic split possible", + "kind": "epic", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "epic", + "component/wit-abi", + "effort/weeks" + ], + "depends_on": [], + "body": "P0 is the free reshape phase: while everything lives in one repo and no external consumer pins any WIT package, every contract change is a recompile, not a wire break (decision 8). The master gate for the whole split is the R6 host<->intent WIT decouple: wit/nexum-host/types.wit does use nexum:intent/types@0.1.0.{receipt, intent-status}, so the L1 host world imports L2. Until that use is gone, nexum-runtime cannot compile without videre's WIT and an acyclic split is physically impossible. This epic owns the split-enabling outcome of P0: the acyclicity invariant made visible and CI-verifiable. See videre-split-plan.md s8 (P0), s5 Phase 0; venue-platform-architecture.md s8 decisions 2 and 8.", + "acceptance": "R6 decouple landed; cargo tree -p nexum-runtime reaches no intent/cow crate; WIT DAG builds acyclically in one repo; all WIT normalized to @0.1.0.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "WIT/ABI" + }, + { + "key": "p0-acyclicity-scaffold", + "title": "Land the acyclicity / zero-leak CI gate for nexum-runtime (advisory in P0, blocking in S1)", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "component/tools", + "component/wit-abi", + "effort/hours" + ], + "depends_on": [], + "body": "The entire split rests on one invariant: nexum-runtime (L1) knows nothing about intents, venues, or CoW. The design makes deleting the HostState.pool_router field the forcing-function acceptance test for that invariant. We need that invariant continuously checkable long before the physical cut. Add a CI job (and a just/cargo xtask entrypoint) that asserts L1 is venue-agnostic: cargo tree -p nexum-runtime must not reach videre-*/intent/cow crates; rg symbol scan must return empty; assert the WIT DAG resolves with nexum:host as a leaf. Land it advisory (non-blocking) in P0, then promote to a blocking gate in S1. See videre-split-plan.md s1, s5 Phase S1, s2.2; venue-platform-architecture.md s6 R6.", + "acceptance": "CI + local command assert cargo tree and rg symbol checks on nexum-runtime and the leaf-ness of nexum:host; wired advisory in P0 with a tracked flip-to-blocking at S1.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Engine" + }, + { + "key": "split-epic-s1", + "title": "Epic (S1): make nexum-runtime venue-agnostic - generalize the Extension seam (the long pole)", + "kind": "epic", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "epic", + "component/engine-runtime", + "component/engine-supervisor", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "split-epic-p0" + ], + "body": "S1 is the only non-free work in the plan and the true long pole: making nexum-runtime venue-agnostic is runtime surgery with no prior ADR. Today the runtime hardcodes venue knowledge in two places - ModuleKind is a fixed EventModule | VenueAdapter enum, and PoolRouter is a privileged field (HostState.pool_router). The pinned fix (s7.2): the runtime knows only two generic roles - worker and provider. Extension grows from {link, capabilities} to {link, capabilities, service, provider}, adding HostService (the ex-PoolRouter, now VenueRegistry) and ProviderKind (the ex-AdapterActor lifecycle). videre becomes one impl Extension. This epic's split-owned deliverable is the runtime-venue-agnostic gate. See videre-split-plan.md s5 Phase S1, s7.2, s6 D1/D2; venue-platform-architecture.md s6 R8.", + "acceptance": "Extension hosts worker+provider roles; pool_router field deleted; zero-leak gate blocking and green; echo-venue boots through the generalized seam with no cow code in L1.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Engine" + }, + { + "key": "s1-gate-runtime-venue-agnostic", + "title": "GATE (a): prove nexum-runtime is venue-agnostic - flip the zero-leak CI check to blocking", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "component/engine-runtime", + "component/engine-supervisor", + "component/tools", + "effort/days" + ], + "depends_on": [ + "p0-acyclicity-scaffold", + "host-zero-leak-ci-gate" + ], + "body": "First of the three cut gates (s8 Phase S2 gate (a); s1 go/no-go). Cutting repos before L1 is proven venue-agnostic freezes an intent-shaped host into a repo boundary. The forcing function: deleting the HostState.pool_router field (state.rs:54) and carrying the router in a composite Ext lattice. Depends on the seam-generalization implementation landing. Promote the P0 acyclicity/zero-leak CI check from advisory to blocking; add an echo-venue boot integration test as the oracle. See videre-split-plan.md s1, s5 Phase S1, s8 Phase S2 gate (a), s2.2.", + "acceptance": "pool_router field deleted; zero-leak CI check blocking + green on nexum-runtime; echo-venue boots through the generalized seam with no intent/cow crate in the graph.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Engine" + }, + { + "key": "split-epic-s1b", + "title": "Epic (S1b): CoW on the generic seam - real adapter cdylib + keeper on videre:venue/client", + "kind": "epic", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "epic", + "component/cow-integration", + "component/sdk", + "effort/weeks" + ], + "depends_on": [ + "split-epic-s1" + ], + "body": "With L1 venue-agnostic (S1), S1b proves the generic seam carries a real venue, not just the echo-venue toy. Today the live CoW path bypasses the generic seam - shepherd-sdk/src/cow/run.rs:138 submits via CowApiHost, never pool - which is R1 unretired. The cleave rule (s7.6, load-bearing): the cow venue is only the CoW orderbook. All composable-cow specifics live in the composable-cow keeper and leak nowhere else. This epic's split-owned deliverable is the cow-on-generic-seam gate. See videre-split-plan.md s4, s7.6, s8 Phase S1b; venue-platform-architecture.md s6 R1/R2.", + "acceptance": "cow-venue cleaved (venue = orderbook only, CI-gated clean); real cow adapter cdylib on videre:adapter; keeper ported onto videre:venue/client; CowApiHost retired; idempotency seam settled.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "CoW" + }, + { + "key": "s1b-gate-cow-on-generic-seam", + "title": "GATE (b): CoW rides the generic seam - keeper on videre:venue/client, CowApiHost retired", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "component/cow-integration", + "component/sdk", + "effort/days" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic", + "cow-venue-cdylib", + "composable-cow-keeper-port", + "cow-api-retire" + ], + "body": "Second of the three cut gates (s8 Phase S2 gate (b); s1 go/no-go). The split must not codify the generic L2 contract into a repo boundary while the flagship CoW venue still bypasses it. The concrete embodiment: flip run.rs:138 from CowApiHost::submit_order(...) to pool.submit(CowVenue::ID, cow_body_bytes), retiring the CowApiHost trait and the cow-api host extension. Note the fork-gate boundary (s4.4): the Rust seam re-split lands now; only the poll wire-swap is fork-blocked. Do NOT couple them. This gate covers the seam port, not the fork-gated poll swap. See videre-split-plan.md s4.2, s4.3, s4.4, s7.6, s8 Phase S1b/S2 gate (b).", + "acceptance": "cow adapter cdylib on videre:adapter; keeper submits through videre:venue/client not CowApiHost; venue-crate symbol gate green; idempotency seam in place; seam port not coupled to the fork-gated poll swap.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "CoW" + }, + { + "key": "split-epic-s2", + "title": "Epic (S2): the gated repo cut - transitional workspace, WIT plumbing, three history-preserving carves", + "kind": "epic", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "epic", + "component/tools", + "component/wit-abi", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "split-epic-s1b" + ], + "body": "S2 is the physical split, and per decision D1 (2026-07-15) it is gated on TWO gates, not three: (a) nexum-runtime venue-agnostic, (b) CoW on the generic seam with a real adapter. The genuine second-protocol venue is DE-GATED from the cut and becomes a post-cut acceptance milestone (M5) built against the already-split videre-sdk. Cutting before the second venue trades one risk for another: a wrong videre abstraction found post-cut is a cross-repo change rather than an in-monorepo fold - mitigated by keeping videre:* additively extensible through the cut and holding the videre:value-flow 1.0 freeze until M5. The end state is three repos with one acyclic edge each: nexum-runtime <- videre <- shepherd. The cut proceeds reshape-then-extract: flip WIT and Rust resolution to crate-local while still one repo (S2a), then three git-filter-repo --path extractions preserving history (S2b), held together by a transitional umbrella superproject with path-deps during stabilization. See videre-split-plan.md s2.1, s5 Phase S2, s6 D8/D9/D10, s8 Phase S2; decision D1 (2026-07-15).", + "acceptance": "Gates (a)+(b) green pre-carve (second venue de-gated to post-cut M5); transitional workspace + WIT plumbing landed; three history-preserving carves produce building repos with tip oracle passing and the acyclic DAG intact; videre:* left additively extensible so the post-cut second venue can drive a non-breaking cross-repo abstraction fix.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Tooling/Packaging" + }, + { + "key": "s2-transitional-workspace", + "title": "Transitional path-dep cargo workspace in the 3 groupings (+ git-tag pin path + dep-sync CI)", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "component/tools", + "effort/days" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic", + "s1b-gate-cow-on-generic-seam" + ], + "body": "The split must not lose the single hoisted dependency table, the shared Cargo.lock, or atomic folds (s6 R7/D10). The mitigation is a transitional umbrella superproject with path-deps through S1-S3, converging to git-tag pins then crates.io. Reorganize the monorepo crates into the three prospective groupings as workspace members with intra-grouping path-deps; define the post-carve cross-repo Rust dep medium (git-tag pins first, path to crates.io); add a dep-sync CI check. See videre-split-plan.md s2.1, s5 Phase S2b, s6 R7/D9/D10.", + "acceptance": "Three-grouping path-dep workspace builds with the acyclic crate DAG verified; cross-repo dep medium documented (git-tag -> crates.io); dep-sync CI check green and enforcing.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Tooling/Packaging" + }, + { + "key": "s2-wit-cross-repo-consumption", + "title": "WIT cross-repo consumption: wit-deps flip + git-tag sourcing + wkg/OCI registry convergence", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "component/wit-abi", + "component/tools", + "effort/days" + ], + "depends_on": [ + "s2-transitional-workspace" + ], + "body": "Today every bindgen! and the venue macro resolve WIT via a workspace-ancestor walk into a shared ../../wit/* tree. After the carve each repo must resolve its own WIT plus cross-repo deps, with the DAG nexum:host (leaf) <- videre:value-flow <- videre:intent/videre:venue <- videre:adapter <- shepherd:cow. S2a (one repo): introduce wit-deps (deps.toml) per prospective repo; flip every bindgen! path list and the macro WIT-root; rewrite find_wit_root (lib.rs:512). S2b (cross-repo): source cross-package WIT from git tags; check in lockfiles. Convergence: document the move to wkg/OCI + per-package semver. See videre-split-plan.md s2.1, s3.4, s5 Phase S2, s6 R4/D9.", + "acceptance": "All bindgen + macro WIT resolution is crate-local (wit/ + wit/deps/); cross-repo WIT sourced from pinned git tags with lockfiles; acyclic DAG builds in-repo and cross-repo; registry + semver policy documented.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Tooling/Packaging" + }, + { + "key": "s2-cut-gate-checklist", + "title": "Cut go/no-go gate: assert (a) runtime venue-agnostic + (b) cow on the generic seam before any carve (D1: gate (c) second venue is now POST-CUT acceptance, not a pre-carve gate)", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "component/tools", + "effort/hours" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic", + "s1b-gate-cow-on-generic-seam" + ], + "body": "The physical repo cut is gated (refactor now, cut later; s6 D8) but per the 2026-07-15 decision (D1) the cut is gated on gates (a)+(b) ONLY - it is NOT gated on a second venue. A single tracking/checklist issue (and a short pre-carve runbook) that must be closed before the three-carves issue may start: (a) nexum-runtime venue-agnostic - S1 zero-leak gate blocking+green, pool_router deleted; (b) real shepherd cow-venue cdylib + keeper ported off CowApiHost onto videre:venue/client; confirm all Phase-0 WIT reshapes complete. The genuine non-cow second-protocol venue is DE-GATED from the cut and becomes a post-cut acceptance milestone (M5) built against the already-split videre-sdk - so it can no longer freeze the carve behind an unbuilt venue. RISK carried by D1: a wrong videre abstraction discovered by the post-cut second venue is a cross-repo change, not an in-monorepo fold - so keep videre:* additively extensible through the cut and hold the videre:value-flow 1.0 freeze until the second venue proves the abstraction (M5). See videre-split-plan.md s1, s6 D8, s8 Phase S2 gate; decision D1 (2026-07-15).", + "acceptance": "Gates (a) runtime venue-agnostic and (b) cow on the generic seam both closed green; all Phase-0 WIT reshapes confirmed complete; pre-carve runbook signed off. Gate (c) second venue is explicitly NOT required pre-carve (post-cut acceptance, M5). videre:* confirmed additively extensible so a post-cut abstraction fix is a non-breaking cross-repo change.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Tooling/Packaging" + }, + { + "key": "s2-three-carves", + "title": "Three history-preserving git-filter-repo carves: nexum-runtime / videre / shepherd", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "component/tools", + "breaking", + "effort/weeks" + ], + "depends_on": [ + "s2-cut-gate-checklist", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption" + ], + "body": "The physical cut: three git-filter-repo --path extractions preserving history, one per repo, executed as a single coordinated operation once the cut gate (a+b) is green (s5 Phase S2b; s2.1; decision D1). Reuse the keeper-rename template: range-limited git-filter-repo + a byte-identical tip oracle + jj/mergiraf, per repo. nexum-runtime (L1): crates/nexum-runtime, nexum-sdk, nexum-sdk-test, nexum-world, nexum-module-macros, nexum-launch, the bare nexum bin, wit/nexum-host. videre (L2): videre-sdk, videre-test, videre-macros, videre-host, wit/videre-*, echo-venue/echo-client. shepherd (L3, the shepherd bundle): cow-venue, shepherd-sdk (absorbed into the bundle per D3), shepherd-cow-host, shepherd-sdk-test, shepherd-backtest, the shepherd bin (a nexum-runtime host), wit/shepherd-cow. Wire cross-repo Rust via git-tag pins and WIT via wit-deps git tags; keep videre:* additively extensible so the post-cut second venue can drive a non-breaking cross-repo fix. See videre-split-plan.md s2.1, s5 Phase S2b, s6 D9/D10; decisions D1/D3.", + "acceptance": "Three history-preserving repos carved (byte-identical tip oracle per repo, full history preserved): nexum-runtime, videre, and shepherd (the shepherd bundle, shepherd-sdk absorbed); each builds standalone on pinned cross-repo deps; acyclic DAG holds; L1 zero-leak gate green in nexum-runtime repo.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "Tooling/Packaging" + }, + { + "key": "split-epic-s3", + "title": "Epic (S3): second-venue acceptance - prove videre is genuinely venue-neutral", + "kind": "epic", + "phase": "S3", + "milestone_suggestion": "M5 ; S3: second-venue acceptance & vocab freeze (post-cut)", + "labels": [ + "epic", + "component/sdk", + "component/modules", + "effort/weeks" + ], + "depends_on": [ + "s2-three-carves" + ], + "body": "S3 is the POST-CUT acceptance phase (decision D1, 2026-07-15): the cut ships gated only on gates (a)+(b), and the genuine second venue is proved AFTER the carve, against the already-split videre-sdk / videre-test repo and the cross-repo WIT deps. echo-venue is a toy and cannot prove venue-neutrality; the live CoW path bypasses videre; 0.1 is EVM-only - so a genuine, dissimilar second protocol (rfq or amm-router) is what actually de-risks R1. Because it now runs post-cut, any videre:* shape mismatch it surfaces is a cross-repo change, not an in-monorepo fold: this is the accepted D1 trade. Therefore videre:* MUST stay additively extensible through the cut, and the videre:value-flow 1.0 freeze (#330) lands ONLY here, AFTER the second venue proves the abstraction - together with the curated adapter registry + consent surface (#141). See videre-split-plan.md s5 Phase S3, s8 Phase S3, s6 R1/R3/D8; venue-platform-architecture.md s8 decision 5; decision D1 (2026-07-15).", + "acceptance": "A genuine non-cow second-protocol venue merged POST-CUT, built against the split videre-sdk/videre-test repo alone (cross-repo WIT deps); videre proven venue-neutral by two dissimilar real venues; any resulting videre:* corrections shipped as additive, non-breaking cross-repo changes; the videre:value-flow 1.0 freeze applied only after the abstraction is proven.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "SDK/DX" + }, + { + "key": "s3-gate-second-protocol-venue", + "title": "Second-venue acceptance (post-cut): build a genuine second-protocol venue (rfq or amm-router) against the split videre-sdk (de-risks R1; D1 de-gates it from the carve)", + "kind": "task", + "phase": "S3", + "milestone_suggestion": "M5 ; S3: second-venue acceptance & vocab freeze (post-cut)", + "labels": [ + "component/sdk", + "component/modules", + "feature", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "s2-three-carves", + "videre-consumable-release-graduation" + ], + "body": "Per decision D1 (2026-07-15) the second venue is DE-GATED from the repo cut and becomes a post-cut acceptance deliverable built against the already-split videre-sdk repo (not a pre-carve gate). It still de-risks R1: the generic videre:* contract is exercised only by echo-venue and bypassed by the live CoW path, and 0.1 is EVM-only, so only a genuine dissimilar second venue proves venue-neutrality - a second cow keeper does not count. Pick rfq (firm-quote) or amm-router; author with the blessed path only (#[videre::venue] impl Venue, #[derive(IntentBody)], caps transport-only, no host code, no runtime dep, no cow knowledge); run against the videre-test golden kit consumed cross-repo; feed any shape mismatches back as ADDITIVE videre:* corrections (cross-repo, non-breaking) - and only then apply the videre:value-flow 1.0 freeze (#330). See videre-split-plan.md s2.5, s5 Phase S3, s6 R1/D8, s8 Phase S3; venue-platform-architecture.md s6 R1, s8 decision 5; decision D1.", + "acceptance": "A genuine second-protocol venue (rfq or amm-router) compiles + passes videre-test against the split videre-sdk repo alone (no runtime/cow deps), POST-CUT; exercises quote and other non-cow surfaces; resulting videre:* fixes land as additive cross-repo changes; unblocks the videre:value-flow 1.0 freeze.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / shepherd = the CoW-on-videre bundle)", + "component": "SDK/DX" + }, + { + "key": "videre-consumable-release-graduation", + "title": "videre: cut the first consumable videre-sdk + videre:* WIT release and graduate off the umbrella path-deps (external-consumer smoke test)", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M4 ; S2: the gated three-repo cut (gates a+b)", + "labels": [ + "component/sdk", + "component/wit-abi", + "component/tools", + "dx", + "effort/days" + ], + "depends_on": [ + "s2-three-carves" + ], + "body": "Per decision D1 the M5 second venue is the FIRST consumer of videre from OUTSIDE the transitional umbrella superproject - via published/tagged deps only, with no path-dep fallback. shepherd proves the videre WIT edges at M3/M4 but does so INSIDE the umbrella (path-deps during stabilization, per split-plan D10) and may not publish (L3 app-level). The design describes videre's convergence to a genuinely consumable artifact (git-tag -> crates.io + wkg/OCI) only as 'once stable' (split-plan S2b/D9) with no owner and no milestone - so nothing guarantees a fresh external repo can `cargo add videre-sdk` + wit-deps videre:* and build a venue before M5 needs to. Cut the first consumable release at end-of-cut (M4): tag/publish videre-sdk + videre-test + the videre:* WIT packages (git-tag, wkg/OCI, per-package semver); graduate videre off the umbrella path-deps for external consumers; add a fresh-clone external-consumer smoke test that builds a trivial venue against ONLY published videre deps (no path-dep, no umbrella). Precondition of the M5 second-venue acceptance (s3-gate-second-protocol-venue), which lives in its own external repo/crate consuming published videre - the actual venue-neutrality test D1 defers post-cut. See videre-split-plan.md s2.1, s2.5, s5 Phase S2b, s6 D9/D10; decision D1 (2026-07-15).", + "acceptance": "First consumable videre-sdk + videre-test + videre:* WIT release cut (git-tag + wkg/OCI, per-package semver); videre graduated off the umbrella path-deps for external consumers; a fresh-clone external-consumer smoke test builds a trivial venue against ONLY published videre deps (no path-dep, no umbrella) and passes in CI; documented as the precondition for the M5 second-venue acceptance.", + "area": "VIDERE", + "component": "Tooling/Packaging" + }, + { + "key": "egress-guard-hardening-epic", + "title": "epic: egress-guard hardening - real non-AllowAll guard, single-decode, signing-boundary, capability/lifecycle teeth (R3/R5/R8)", + "body": "The router's entire derive -> guard -> submit shape is justified by an egress checkpoint that today does not exist. Only AllowAllGuard ships (pool_router.rs:104-110), a no-op. It inspects the adapter's own derive-header output while submit re-decodes the body independently (TOCTOU), and does not cover the requires-signing signing path at all. Per the 2026-07-14 decisions, M1 ships no guard teeth (advisory-only). This epic owns the real guard's trust model and location plus surrounding capability/lifecycle hardening (R5/R8). This is the design-doc hardening addendum to the existing M5 guard epic #139; #139 builds the guard engine, this epic captures the router/capability/lifecycle fixes the venue-platform review surfaced. Reconcile by folding these as sub-issues of #139 or keeping this as a paired sub-epic. See venue-platform-architecture.md s3, s4 gap #3, s6 R3/R5/R8, s7 Phase 2, s8 decisions 1/3/4/7; videre-split-plan.md s3, s5 Phase 2, s7.2.", + "kind": "epic", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "epic", + "security", + "component/engine-supervisor", + "needs-design", + "effort/weeks" + ], + "acceptance": "A real EgressGuard runs at the signed-tx boundary with single-decode, teeth on the requires-signing path, adapters under the supervisor sweeps, and http/messaging egress enforced like chain/local-store; children below all closed.", + "depends_on": [ + "#139", + "#52" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-advisory-m1", + "title": "guard: ship advisory-only posture for M1 (keep AllowAll, feature-gate the pool import, document the checkpoint as not-yet-enforcing)", + "body": "The egress guard is AllowAllGuard, a no-op (pool_router.rs:104-110), and the real guard is deferred wholly to the egress-guard epic. M1 must not advertise a boundary it does not enforce. Decision 3 (2026-07-14): M1 is advisory-only. Keep AllowAllGuard as the default; feature-gate the nexum:intent/pool import so the advertised derive->guard->submit checkpoint is not shipped as enforcing in the default build; document at the router seam and in venue docs that the checkpoint is advisory-only / not yet enforcing, with a forward pointer to the egress-guard epic. See venue-platform-architecture.md s6 R3, s8 decision 3; videre-split-plan.md s3 (dec-3), s5 Phase 2.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "security", + "docs", + "component/engine-supervisor", + "effort/hours" + ], + "acceptance": "AllowAll kept as default; pool import feature-gated; router-seam + venue docs mark the checkpoint advisory-only for M1 with a link to the guard epic.", + "depends_on": [], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-derive-before-guard", + "title": "guard: close the derive-header-before-guard side-effect escape and the TOCTOU double-decode (single-decode the body through the checkpoint)", + "body": "Two coupled R3 defects in pool_router.rs: (1) Side-effect escape - the router runs the adapter's derive-header before guard.check, so any side effect in derivation escapes policy; the honest fix is a guarded sub-world or moving derivation behind the checkpoint. (2) TOCTOU double-decode - the guard inspects the adapter's own derive-header output while submit re-decodes the body independently, so a buggy/hostile adapter can show a benign gives and settle something else; the derived header must be passed into submit for a single decode. (The WIT hygiene change - softening host-verified gives to adapter-attested - rides the Phase-0 fold, tracked in the WIT area.) See venue-platform-architecture.md s6 R3; videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "security", + "component/engine-supervisor", + "component/wit-abi", + "effort/days" + ], + "acceptance": "Derivation cannot side-effect before guard.check; the body is decoded once and submit consumes the guard-vetted header; a divergent-re-decode test proves no bypass.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-signing-boundary", + "title": "guard: move the checkpoint to the signed unsigned-tx / identity boundary so the requires-signing path is covered", + "body": "The guard does not cover the requires-signing class at all. For that class the real value movement is the unsigned-tx calldata returned by submit and signed on the identity path - which is a 0.3 stub today (accounts() -> Ok(vec![])). Decision 7 (2026-07-14): identity signing lands with the guard (later). Add the guard checkpoint at the signed unsigned-tx / identity boundary; wire to the real identity backend (#52); coordinate with #139's identity-boundary checkpoint child so there is one checkpoint, not two. See venue-platform-architecture.md s6 R3, s8 decision 7; videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "security", + "component/identity", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "requires-signing submits are gated at the decoded unsigned-tx boundary against a real identity backend; single checkpoint shared with #139.", + "depends_on": [ + "egress-guard-hardening-epic", + "#52", + "#139" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-deny-quota", + "title": "guard: charge quota on guard-deny to close the busy-loop DoS", + "body": "When the guard denies a submission, the router does not charge the caller's quota, so a module can retry a denied submission in a tight loop for free - a DoS against the guard/router. Latent today because only AllowAllGuard ships, but cheap to fix now and required the moment a real guard denies. On a guard-deny verdict, charge the caller's rate/quota exactly as an accepted submit would, before returning the denial; add a test that a repeated denied submit exhausts quota rather than looping for free. See venue-platform-architecture.md s7 triage (guard-deny no quota / DoS #250); videre-split-plan.md s5 Phase 1.", + "kind": "bug", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "security", + "debt", + "component/engine-supervisor", + "effort/minutes" + ], + "acceptance": "Guard-deny charges quota; a repeated-deny loop is rate-limited; regression test present.", + "depends_on": [], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-policy-async", + "title": "guard: make GuardPolicy::check async (the real guard needs I/O: simulate, remote analyzers)", + "body": "GuardPolicy::check is synchronous. The real guard performs I/O - simulate over provider-pool state, fact assembly, possibly a remote analyzer/policy backend - none of which a sync trait can express without blocking the supervisor. Convert GuardPolicy::check (and the guard seam it fronts) to async; apply the s7.3 async strategy (native AFIT for static-dispatch guest traits; async_trait only for cold dyn boot paths; keep dyn-required guard/service traits object-safe); update AllowAllGuard and all call sites. See venue-platform-architecture.md s7 triage (GuardPolicy::check sync #250); videre-split-plan.md s5 Phase 2, s7.3.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "breaking", + "debt", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "GuardPolicy::check is async and awaited without blocking the loop; async-dispatch split matches s7.3; green on MSRV 1.94.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "guard-egress-cap-world-guarantee", + "title": "guard: bring venue egress capabilities (http + import-narrowing) under the compile-time world guarantee", + "body": "Two coupled R5/adapter-contract gaps let egress escape the capability model: (1) http escapes the compile-time guarantee (R5) - in the KNOWN table http has import: None (world.rs:88-92); wasi:http is linked out-of-band and gated only by the engine.toml allowlist, so the undeclared-cap-is-a-compile-error guarantee covers chain/messaging/logging but not http. (2) Blanket shims / two adapter contracts (#296) - export_venue_adapter! imports chain+messaging unconditionally and leans on wasm-tools dead-import elision, while synthesize_venue narrows by construction. Bring http under the synthesised-world guarantee (or document loudly that http egress is allowlist-gated); canonicalise one adapter import-narrowing contract; retire the blanket shim path. See venue-platform-architecture.md s6 R5, s6 R4; videre-split-plan.md s5 Phase 2, s3.4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "security", + "dx", + "component/capabilities", + "component/http", + "effort/days" + ], + "acceptance": "Undeclared http egress is a build error (or the allowlist-only story is documented at the seam); exactly one adapter contract with import narrowing by construction.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "adapter-supervision-sweeps", + "title": "supervisor: fold venue adapters into the restart/poison sweeps and expose adapters_alive (R8)", + "body": "Adapters boot once and install but are not in the restart/poison-recovery sweeps (supervisor.rs:61-66); a trapped adapter stays dead until process restart, and the router only projects the trap to internal-error. A strategy cannot distinguish unknown-venue (never installed) from venue-temporarily-dead (trapped, recoverable). Naturally addressed by the S1 seam generalization which extracts the generic supervised-component primitive from AdapterActor. Fold venue adapters (the generalized provider/component kind) into the restart and poison-recovery sweeps; expose adapters_alive (or equivalent). See venue-platform-architecture.md s6 R8; videre-split-plan.md s2.2, s5 Phase S1.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "debt", + "component/lifecycle", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "Trapped adapters are swept and restarted; a liveness signal distinguishes unknown-venue from venue-temporarily-dead; trap->recovery test present.", + "depends_on": [ + "host-generic-component-kind" + ], + "area": "egress guard + deferred/debt", + "component": "Runtime/Lifecycle" + }, + { + "key": "messaging-query-scope", + "title": "messaging: enforce messaging.query scope (goes live with the 0.3 Waku backend)", + "body": "The messaging.query path is not scope-checked - a module/adapter can query messaging outside its declared scope. Latent today because the messaging backend is a 0.3 stub; the hole goes live the moment the 0.3 Waku backend lands. Enforce the declared messaging scope on messaging.query (reject/deny out-of-scope queries), consistent with how publish scope is enforced; land the enforcement with (or ahead of) the 0.3 Waku backend. See venue-platform-architecture.md s7 triage (messaging.query not scoped #249); videre-split-plan.md s5 Phase 2.", + "kind": "bug", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "security", + "component/messaging", + "component/capabilities", + "effort/hours" + ], + "acceptance": "Out-of-scope messaging.query is denied, in-scope succeeds; enforcement wired into the 0.3 Waku backend with tests for both cases.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt", + "component": "Host Backends" + }, + { + "key": "mock-grant-fidelity", + "title": "capabilities: align mock capability-grant fidelity to the real host grant", + "body": "The mock capability grant diverges from the host's real grant, so tests can pass while real enforcement differs - a fidelity gap that hides capability regressions. As the real guard replaces the shims, the mock and host grant must be canonicalised to one behaviour. Reconcile the mock capability-grant behaviour with the host CapabilityRegistry grant so a capability the host would deny is also denied under mock; ideally derive both from one source-of-truth (the KNOWN capability table). See venue-platform-architecture.md s5, s7 triage (mock grant fidelity diverges #297); videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M6 ; Egress guard (real, teeth) ; deferred", + "labels": [ + "debt", + "component/capabilities", + "effort/hours" + ], + "acceptance": "Mock and host agree on grant/deny for every KNOWN capability; a skew-guard test exists; no mock-only pass that the host would reject.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt", + "component": "Host Backends" + }, + { + "key": "rfq-firm-quote-additive", + "title": "videre: RFQ firm-quote - additive firm: option on the quote record (taker-side, when a real RFQ venue appears)", + "body": "videre 0.1 quoting returns a plain indicative quote record. A market-maker/RFQ venue instead returns a signed, time-limited firm price the taker accepts. This is the smaller, taker-side cousin of the maker-side offer work deferred in #355 - and per that issue it slots into the existing quote record additively as a firm: option field, rather than a new interface. Deferred until a real RFQ venue exists so the firm-quote shape is not guessed. Add firm: option to videre:types quote plus the accept/settle path on the client/adapter faces; keep it additive and EVM-only; gate on a real RFQ venue. See venue-platform-architecture.md s6 R1, s8 decision 5; videre-split-plan.md s3.3, s7.4. Related: #355 (maker-side offer, distinct).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M7 ; Post-v1 hardening & debt (rolling)", + "labels": [ + "feature", + "needs-design", + "component/wit-abi", + "effort/days" + ], + "acceptance": "quote.firm added additively (present only for RFQ), exercised by a real RFQ venue against goldens, with no breaking change to the indicative quote path.", + "depends_on": [ + "#355" + ], + "area": "egress guard + deferred/debt", + "component": "WIT/ABI" + }, + { + "key": "materialiser-source-venue", + "title": "videre: Materialiser - the venue-neutral keeper materialiser (M7)", + "body": "The generic Keeper::sweep assembler resolves the dangling ConditionalSource::Outcome and gives strategy authors an assembler over the parts. The fully venue-neutral Materialiser - a source-agnostic, venue-agnostic keeper that materialises a Source's outcomes onto any Venue - is the explicit M7 destination, past the first stable runtime. It wants a second real venue and a settled keeper->pool port before it is generalized. Generalize the Keeper::sweep assembler to a Materialiser parameterized over both source and target venue, with the shared Sweep outcome; prove venue-neutrality against at least the CoW keeper and one second venue. See venue-platform-architecture.md s5.3, s7; videre-split-plan.md s5 triage (Keeper materialiser M7).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M7 ; Post-v1 hardening & debt (rolling)", + "labels": [ + "dx", + "needs-design", + "component/sdk", + "effort/weeks" + ], + "acceptance": "Materialiser drives two distinct (Source,Venue) pairs through one assembler with no venue-specific branches in the materialiser.", + "depends_on": [], + "area": "egress guard + deferred/debt", + "component": "SDK/DX" + }, + { + "key": "gap-opaque-status-contract-spec", + "title": "Spec the opaque-status destructuring contract (versioned discriminator) that host event commits to - blocks R6", + "body": "The R6 host-intent decouple (host-r6-decouple, the P0 MASTER GATE) drops wit/nexum-host/types.wit:8 use nexum:intent/types.{receipt,intent-status} and has the host event stream carry opaque status bytes. But how those bytes destructure is still an OPEN decision: venue-platform-architecture.md s8 explicitly lists the exact wording + versioning scheme of the documented opaque-status destructuring contract as unresolved, and videre-split-plan.md s6.1 ranks this as risk #2. No drafted issue owns this design decision; host-r6-decouple is the implementation, which cannot land correctly until the contract shape exists. Define the wire form (version discriminator + destructuring rule); decide schema ownership (bytes host-emitted but meaning videre-owned); land as a short design note/ADR under docs/design/.", + "kind": "docs", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "gap", + "wit", + "design", + "P0", + "blocker" + ], + "acceptance": "A committed design note/ADR pins the opaque-status byte format (version discriminator + destructuring rule + schema ownership); host-r6-decouple lists it as a dependency and cites it. s8's open item is closed.", + "depends_on": [], + "area": "gap", + "component": "WIT/ABI" + }, + { + "key": "gap-p0-wit-fold-execution", + "title": "Execute Phase 0 as ONE oracle-validated git-filter-repo/jj fold across the M1 train (regenerate goldens, re-assert tip oracle)", + "body": "Every P0 content issue (host-r6-decouple, videre-wit-rename, videre-wit-normalize, videre-quote, videre-wit-surface, plus the fold-tail hygiene) must land as a single train-wide fold, not as per-car edits: a WIT type touched in an early car is imported by all downstream cars, so editing car-by-car desyncs the stack. The drafted set has the epic (split-epic-p0) and the content issues, but no issue owns the mechanical fold execution - the range-limited git-filter-repo pass replayed across the stack, jj-driven per-car rebases, mergiraf conflict resolution, videre-test golden regeneration, byte-identical tip-oracle re-assertion, and the single force-push. This is the proven keeper-rename template and it is load-bearing. Assemble all P0 content changes on refactor/intent-contract-reshape; run the fold across the M1 stack (#239->#260 + Wave-1 #334/#335); regenerate goldens; re-assert tip oracle; single force-push. See venue-platform-architecture.md s7; videre-split-plan.md s5 Phase 0.", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "gap", + "wit", + "train-fold", + "tooling", + "P0" + ], + "acceptance": "The full P0 reshape lands as one force-pushed fold; tip oracle byte-identical across the two rebuild paths; videre-test goldens regenerated and green; all stack branches MERGEABLE.", + "depends_on": [ + "host-r6-decouple", + "gap-opaque-status-contract-spec", + "videre-wit-rename", + "videre-wit-normalize", + "videre-quote", + "videre-wit-surface", + "gap-p0-fold-tail-hygiene" + ], + "area": "gap", + "component": "Tooling/Packaging" + }, + { + "key": "gap-p0-fold-tail-hygiene", + "title": "P0 fold tail: codec version discriminator (#297), migration-cruft deletion, denied() MUST-NOT-retry doc", + "body": "venue-platform-architecture.md s7 Phase 0 enumerates several contract-hygiene items that must ride the P0 fold but are not covered by any drafted issue (videre-wit-surface pins the shape; the conformance-kit is S1, too late for the fold). Specifically: codec version discriminator + reject-unknown and a non-empty-vector assertion on the cross-language codec goldens (#297); delete the migration cruft (docs/migration/0.1-to-0.2.md and the Migration from 0.1 prose in docs/08-platform-generalisation.md); MUST-NOT-retry doc caveat on venue-error.denied(); verify the valid-until -> valid-until-ms rename and the named-ERC-record lift actually land in the fold. These are cheap now (echo-only pinning) and must precede the true 0.1.0 cut. Fold-tail changes bundled into gap-p0-wit-fold-execution.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "gap", + "wit", + "codec", + "docs", + "P0" + ], + "acceptance": "Codec goldens carry a version discriminator, reject unknown versions, and assert a non-empty vector; docs/migration/0.1-to-0.2.md + the docs/08 migration prose are deleted; denied() has a MUST-NOT-retry doc; valid-until-ms + named ERC records confirmed in-tree.", + "depends_on": [ + "videre-wit-surface" + ], + "area": "gap", + "component": "Tooling/Packaging" + }, + { + "key": "gap-m1-green-tip-gate", + "title": "Gate: finish M1 to a single green linear dev/m1 tip before any carve", + "body": "videre-split-plan.md s5 Phase 1 is an explicit, un-owned gate: Land the doc's Phase-1 Rust amends (#249/#250/#251/#296), the Wave-1 #334 verdict-seam fixes, the R7 install-time handshake, and the approved cars - to a single green linear dev/m1 tip. Do not begin the carve until this tip exists (carving mid-train triples the fold surgery across three repos). The drafted set covers guard-deny-quota (#250) and videre-body-versions-handshake (R7), but there is no umbrella gate asserting the green linear tip, and several required M1 cars are un-referenced: #249 supervisor missing-manifest error, #251 RateLimited fold test, #296 wit-bindgen 0.59 bump, and #334 Wave-1 (model Verdict::Post.next_poll_timestamp as Option/NextPoll not a 0-sentinel; add a NeedsInput dispatch test). Track landing of #249/#251/#296 + the two #334 Wave-1 fixes + approved cars to one green linear tip (amend-in-place, no fold). See venue-platform-architecture.md s7 Phase 1 / Phase 1-Wave-1; videre-split-plan.md s5 Phase 1.", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "gap", + "gate", + "M1", + "release-blocker" + ], + "acceptance": "dev/m1 is a green linear tip with #249/#251/#296 + both #334 Wave-1 fixes + approved cars merged; CI green; explicitly signed off as the precondition for beginning the S2 carve.", + "depends_on": [ + "gap-p0-wit-fold-execution", + "guard-deny-quota" + ], + "area": "gap", + "component": "Tooling/Packaging" + }, + { + "key": "gap-videre-host-platform-crate", + "title": "Build the videre-host crate + videre::platform() registration (VenueRegistry + provider-kind + EgressGuard seam + bindgens)", + "body": "videre-split-plan.md s2.3 names videre-host as a new crate and calls it the split's core enabling work; s7.2 and s8 S1.3 specify that videre becomes one extension - builder.with_extension(videre::platform()) - that registers the venue-adapter provider-kind, the VenueRegistry service (the un-privileged ex-PoolRouter), the EgressGuard seam, the videre:venue/client interface, and the install predicate (R7 body-versions handshake), all through the generalized runtime seam. The drafted host-side issues cover growing the seam, extracting the service and the generic component-kind, and R8 sweeps - but no issue owns the L2 videre-host crate assembly + the videre::platform() entrypoint. In particular the s7.2 guard row (GuardPolicy/AllowAll -> EgressGuard, videre-owned) and the venue-adapter/pool-host bindgens + build_adapter_linker + adapter path of synthesize_venue have no home issue. Create videre-host (host-side L2 crate depending on nexum-runtime, the legal L2->L1 edge); land the VenueRegistry service, the venue-adapter ProviderKind + install predicate, the EgressGuard seam (advisory-only for M1), the videre:venue/client interface, and the bindgens; expose videre::platform(). See videre-split-plan.md s2.3, s7.2, s8 S1.1-S1.3.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "labels": [ + "gap", + "videre", + "host", + "seam", + "S1" + ], + "acceptance": "videre::platform() registers the provider-kind + VenueRegistry service + EgressGuard seam + videre:venue/client via the seam; videre-host depends on nexum-runtime only; echo-venue installs + a worker submits through it with the HostState.pool_router field deleted.", + "depends_on": [ + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind" + ], + "area": "gap", + "component": "Runtime/Lifecycle" + }, + { + "key": "gap-handshake-manifest-key-decision", + "title": "Decide the install-time handshake manifest key (body_version vs version-set) + supported-set match semantics", + "body": "venue-platform-architecture.md s8 lists as one of only two remaining open decisions: the precise manifest key name (body_version vs a version-set field) and the supported-set match semantics for the install-time handshake (decision 4). videre-body-versions-handshake owns the implementation but presumes this decision; the schema is videre's while Supervisor::install (which asserts agreement) lives in nexum-runtime and must stay venue-agnostic, so the videre-host install predicate supplies it. Pin the manifest key name and the module-version-in-adapter-supported-set match semantics (exact-set vs range); note where it slots (module + adapter manifests, asserted at install, fail-fast + logged).", + "kind": "docs", + "phase": "P0", + "milestone_suggestion": "M0 ; P0: Videre contract reshape & the R6 master gate", + "labels": [ + "gap", + "design", + "manifest", + "handshake" + ], + "acceptance": "A one-page decision fixes the manifest key name + supported-set match semantics; videre-body-versions-handshake references it.", + "depends_on": [], + "area": "gap", + "component": "WIT/ABI" + }, + { + "key": "gap-docs-source-of-truth-rewrite", + "title": "Rewrite docs/05 + docs/08 as source-of-truth: venue persona is shipped; adapters are THE extension mechanism; cow-api is legacy read-path", + "body": "venue-platform-architecture.md s4 gap #10 and s7 Phase 4 flag the source-of-truth docs as actively misleading - blocking, cheap, highest discovery-return change: docs/05 says the venue persona is not shipped (it is), and docs/08 documents only the deprecated Layer-3 host-extension model. Decision 1 (s8) further requires deleting the shepherd:cow/cow-api-as-adapter-extension ambiguity from the docs, keeping it only as the legacy event-module read path. No drafted issue owns this (the migration-cruft deletion is in gap-p0-fold-tail-hygiene; this is the affirmative rewrite). docs/05: document the venue persona as shipped - crate layout + a step-by-step author a venue on videre. docs/08: venue adapters (#[videre::venue]) are THE domain-extension mechanism; mark shepherd:cow/cow-api as the legacy read path; delete the adapter-extension ambiguity. See venue-platform-architecture.md s4 #10, s7 Phase 4, s8 decision 1.", + "kind": "docs", + "phase": "S1b", + "milestone_suggestion": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)", + "labels": [ + "gap", + "docs", + "discovery" + ], + "acceptance": "docs/05 documents the shipped venue persona with an author-a-venue walkthrough; docs/08 names venue adapters as the extension mechanism and marks cow-api legacy read-path with no adapter-extension ambiguity.", + "depends_on": [ + "cow-api-retire" + ], + "area": "gap", + "component": "Docs" + }, + { + "key": "gap-alloy-provider-seam", + "title": "Chain DX: alloy Provider seam over ChainHost::request (HostTransport: alloy Transport) + carry ChainMethod to the guest", + "body": "venue-platform-architecture.md s4 gap #4 and s5.1 call the missing alloy Provider seam the largest single DX gap from the alloy target and the flagship systemic move (s5 Phase 4). Today chain is raw stringly request(u64,&str,&str)->String; authors hand-build JSON-RPC params and parse strings. doc-08 promises a HostTransport: alloy Transport shim that is not in the SDK. The closed ChainMethod RPC enum already exists host-side but is not carried to the guest. No drafted issue covers this (host-backend-guest-seams is identity/messaging/remote-store only). Add HostTransport: alloy Transport over ChainHost::request; a guest host.provider(Chain) returning an alloy Provider; carry the typed ChainMethod surface to the guest; zero-cost Chain/ChainId newtypes. See venue-platform-architecture.md s5.1, s7 Phase 4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "gap", + "dx", + "chain", + "alloy", + "sdk" + ], + "acceptance": "A guest strategy can do host.provider(chain).get_block_number() / .call(&tx) through an alloy Provider backed by ChainHost; typed ChainMethod reaches the guest; no hand-rolled JSON-RPC at call sites.", + "depends_on": [], + "area": "gap", + "component": "SDK/DX" + }, + { + "key": "gap-dx-polish-cluster", + "title": "reth/alloy DX polish cluster: VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const, kill *_to_golden bridges", + "body": "venue-platform-architecture.md s5 (the systemic moves worth making) and s7 Phase 4 enumerate a DX-polish cluster that no drafted issue owns: mirror venue-error -> VenueFault (Display + IntoStaticStr label + From) so operator logs stop {0:?}-formatting and rate-limited{retry-after-ms} survives the fold; Order typestate builder replacing the bare 12-field OrderBody literal + CoW SellToken/BuyToken newtypes; uniform #[non_exhaustive] across public error/label enums; seal the extension traits (Host, HostFault, RuntimeTypes, Runtime, IntentPool) with a private Sealed supertrait; derive the mirrors from one source-of-truth const (the fault vocabulary is hand-mirrored in three places and the KNOWN table is duplicated); kill the ~80-line *_to_golden bridge boilerplate each #[venue] adapter hand-copies (R4 tail). One umbrella; land as convenient in Phase 4. See venue-platform-architecture.md s5, s7 Phase 4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M2 ; videre SDK, macros & reth/alloy DX", + "labels": [ + "gap", + "dx", + "sdk", + "ergonomics" + ], + "acceptance": "VenueError mirrored to a Display/IntoStaticStr VenueFault preserving retry-after-ms; Order typestate builder + sell/buy newtypes; #[non_exhaustive] uniform; extension traits sealed; fault vocab + KNOWN table emitted from single-source consts; *_to_golden bridges removed.", + "depends_on": [], + "area": "gap", + "component": "SDK/DX" + }, + { + "key": "risk-value-flow-freeze-hold", + "title": "RISK/track: hold the videre:value-flow 1.0 freeze until the post-cut second venue proves the abstraction (keep videre:* additively extensible through the cut)", + "body": "Decision D1 (2026-07-15) cuts the three repos on gates (a)+(b) BEFORE a genuine second venue exists. The accepted trade: a wrong videre abstraction discovered by the post-cut second venue (#140 / s3-gate-second-protocol-venue) is a cross-repo change, not an in-monorepo fold. This tracking issue makes the mitigation explicit and testable: (1) keep every videre:* package (types / venue / value-flow) ADDITIVELY extensible through the cut - no field is frozen or removed, so a post-cut correction is a non-breaking, additive cross-repo change; (2) HOLD the videre:value-flow 1.0 freeze (#330) until the post-cut second venue has compiled + passed videre-test against the split videre-sdk and fed back any shape corrections; the freeze lives in M5 and must NOT be applied in M4 or earlier. (3) OWN the operational ripple of any post-cut additive videre:* correction: the concrete re-tag videre -> re-pin the git-tag/registry version in nexum-runtime AND shepherd AND the second-venue repo -> regenerate goldens cross-repo -> re-run the byte-identical tip oracle per repo; reserve an M5 abstraction-rework budget for it. split-plan risk-4 warns cross-repo WIT versioning 'has no teeth during transition' (a mispinned tag silently drifts), so the M4 dep-sync/semver CI check (from s2-transitional-workspace / s2-wit-cross-repo-consumption) must stay enforcing through the M5 corrections. Owner sign-off: confirm at the cut that videre:* carries no freeze markers, and gate #330 on the second venue's acceptance. See decision D1; videre-split-plan.md s6.1 risk 3/risk 4, s6 D8, s8 Phase S3.", + "kind": "chore", + "phase": "S3", + "milestone_suggestion": "M5 ; S3: second-venue acceptance & vocab freeze (post-cut)", + "labels": [ + "risk", + "component/wit-abi", + "needs-design", + "P0-tracking" + ], + "acceptance": "A tracked checklist asserts: videre:* is additively extensible with no freeze markers at the cut; #330 (videre:value-flow 1.0 freeze) is blocked until the post-cut second venue (#140) proves the abstraction; any post-cut videre:* fix is landed as an additive, non-breaking cross-repo change. Owns the operational ripple: a documented re-tag -> re-pin (nexum-runtime + shepherd + second-venue repo) -> regen-goldens-cross-repo -> re-run-tip-oracle-per-repo runbook, a reserved M5 abstraction-rework budget, and confirmation the M4 dep-sync/semver CI check stays enforcing through M5 so an additive re-pin cannot silently drift. Signed off before M4 carve and before M5 freeze.", + "depends_on": [ + "s2-three-carves" + ], + "area": "shepherd (the shepherd bundle) ; post-cut second-venue acceptance & value-flow freeze", + "component": "WIT/ABI" + } + ], + "tracker": { + "open_count": 61, + "open_unmilestoned": 5, + "milestones": [ + { + "number": 8, + "title": "M1: Intent core and CoW venue adapter", + "open_issues": 17, + "repurposed_as": "M0 ; P0: Videre contract reshape & the R6 master gate" + }, + { + "number": 7, + "title": "M0: Runtime architecture and lifecycle", + "open_issues": 7, + "repurposed_as": "M1 ; S1: Generic venue-agnostic host (nexum-runtime L1) + lifecycle" + }, + { + "number": 3, + "title": "M2: Concrete CoW modules", + "open_issues": 5, + "repurposed_as": "M3 ; S1b: CoW on the generic seam (the shepherd bundle)" + }, + { + "number": 4, + "title": "M3: Infrastructure", + "open_issues": 3, + "repurposed_as": "M4 ; S2: the gated three-repo cut (gates a+b)" + }, + { + "number": 5, + "title": "M4: SDK and DX", + "open_issues": 3, + "repurposed_as": "M2 ; videre SDK, macros & reth/alloy DX" + }, + { + "number": 9, + "title": "M5: Egress guard", + "open_issues": 2, + "repurposed_as": "M6 ; Egress guard (real, teeth) ; deferred" + }, + { + "number": 10, + "title": "M6: Second venue and vocabulary freeze", + "open_issues": 3, + "repurposed_as": "M5 ; S3: second-venue acceptance & vocab freeze (post-cut)" + }, + { + "number": 6, + "title": "M7: Post-v1 hardening and debt", + "open_issues": 16, + "repurposed_as": "M7 ; Post-v1 hardening & debt (rolling)" + }, + { + "number": 1, + "title": "(dissolved) Host backends real", + "open_issues": 0, + "repurposed_as": "closed/dissolved (milestone #1 stays dissolved)" + } + ] + }, + "reconcile": { + "new_issues": [ + "host-generalize-epic", + "host-r6-decouple", + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind", + "host-nexum-world-registry", + "host-zero-leak-ci-gate", + "host-generic-launcher-bin", + "host-wit-deps-flip-carve", + "host-backend-guest-seams", + "videre-epic", + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "videre-body-versions-handshake", + "videre-sdk-crate", + "videre-venue-macro", + "videre-keeper-macro", + "videre-conformance-kit", + "cleave-cow-venue", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "composable-poll-wire-swap", + "split-epic-p0", + "p0-acyclicity-scaffold", + "split-epic-s1", + "s1-gate-runtime-venue-agnostic", + "split-epic-s1b", + "s1b-gate-cow-on-generic-seam", + "split-epic-s2", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "split-epic-s3", + "guard-advisory-m1", + "guard-derive-before-guard", + "guard-signing-boundary", + "guard-deny-quota", + "guard-policy-async", + "guard-egress-cap-world-guarantee", + "adapter-supervision-sweeps", + "messaging-query-scope", + "mock-grant-fidelity", + "rfq-firm-quote-additive", + "materialiser-source-venue", + "gap-opaque-status-contract-spec", + "gap-p0-wit-fold-execution", + "gap-p0-fold-tail-hygiene", + "gap-m1-green-tip-gate", + "gap-videre-host-platform-crate", + "gap-handshake-manifest-key-decision", + "gap-docs-source-of-truth-rewrite", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster", + "risk-value-flow-freeze-hold", + "videre-consumable-release-graduation" + ], + "close": [ + { + "number": 339, + "reason": "Obsolete: fixes nexum.toml->module.toml inside docs/migration/0.1-to-0.2.md, which is already deleted (HEAD 7c66b6c) as Phase-0 migration cruft. Fixing a deleted file is moot." + }, + { + "number": 287, + "reason": "Targets shepherd-cow-host ext_cow.rs - the legacy cow-api extension that cow-api-retire (#293) deletes. The timeout/429->typed-fault requirement is carried forward by the cow-venue adapter's errorType->venue-error projection + the R1 venue-error reshape; the host-chain equivalent lives on as #269." + }, + { + "number": 222, + "reason": "Delivered by #239: ConditionalSource/Retrier/RetryAction/cow::run landed in nexum-sdk/keeper.rs with the M1 train. Forward Verdict/Keeper::sweep rework is carried by videre-sdk-crate." + }, + { + "number": 137, + "reason": "Delivered by the M1 train (#226-#234): nexum:value-flow+nexum:intent WIT, venue-adapter world, PoolRouter, nexum-venue-sdk, conformance kit, echo-venue. Successor is the drafted videre-epic (rename/quote/normalize/R6), not a reopen." + }, + { + "number": 135, + "reason": "Delivered by the M1 train (#146/#222/#148/#147/#149): the keeper primitives + single-venue loop landed in nexum-sdk/keeper.rs. Deferred generalization is videre-sdk-crate (Keeper::sweep) + materialiser-source-venue (M7)." + }, + { + "number": 131, + "reason": "Docs-only, delivered by PR #132 (docs 08/09 + egress-guard ADR); the design docs now exist. Go-forward doc work is gap-docs-source-of-truth-rewrite." + }, + { + "number": 7, + "reason": "Stale pre-restructure roadmap epic, no milestone. Its goal is largely delivered (chain/local-store/logging live) and its workstreams are decomposed into M0-M7 + individual issues (#52/#152/#151/#285). Nothing tracks against it." + } + ], + "modify": [ + { + "number": 139, + "change": "Rescope to fold the venue-platform R3/R5/R8 router/capability/lifecycle hardening the guard engine epic did not enumerate (single-decode/derive-before-guard, signed-tx boundary, GuardPolicy async, http-under-world-guarantee, messaging.query scope, adapter sweeps, mock fidelity) as children. Keep M5. This IS the drafted egress-guard-hardening-epic; nothing shipped. Depends on #52 (identity) and stays advisory-only for M1." + }, + { + "number": 330, + "change": "Retitle the package under the videre rename: nexum:value-flow -> videre:value-flow. Keep the two freeze-gate ontology decisions (minimal-length canonical amount encoding; native-token representable-but-invalid) - NOT addressed by the Phase-0 named-records reshape. Per decision D1 the videre:value-flow 1.0 FREEZE is HELD until the POST-CUT second venue (#140) proves the abstraction: this freeze must not be applied in M4 (the cut) or earlier; it lands in M5 only after the second venue passes videre-test against the split videre-sdk. Stays in M5 as the freeze gate, distinct from videre-wit-surface (reshape, not freeze); gated by risk-value-flow-freeze-hold." + }, + { + "number": 289, + "change": "Drop the docs/migration/0.1-to-0.2.md bullet (that file is deleted as Phase-0 cruft). Keep the still-valid items: ADR-0011 {errorType,description,data} restoration, docs/07 rpc data-payload callout, docs/production.md house-style pass, stale .mmd/.png regen. Stays M7. Distinct from gap-docs-source-of-truth-rewrite (docs/05+08 venue-persona)." + }, + { + "number": 274, + "change": "Rescope from a TWO-repo split (core + shepherd instantiation) to the THREE-repo umbrella (nexum-runtime <- videre <- shepherd) spanning the drafted split-epic family (split-epic-p0/s1/s1b/s2/s3; closest single correspondent split-epic-s2, the physical cut). Update the two-way partition to three-way; fold its post-split preset items into the generalization. Re-milestone M7->M3." + }, + { + "number": 273, + "change": "Rescope: the preset/Runtime-trait launch-surface ask is subsumed by the S1 seam generalization (host-extension-seam-roles + gap-videre-host-platform-crate grow Extension; host-generic-launcher-bin retires the backwards nexum-cli->cow dep with a bare Ext=() bin). Residual = the MockRuntime preset path (rolls into host-backend-guest-seams/#80). Re-milestone M7->M1 (per milestone_plan moved_issues; supersedes the earlier M0 draft)." + }, + { + "number": 136, + "change": "Rescope (D3, premise corrected): the videre split does NOT keep shepherd-sdk standalone. shepherd IS the narrow CoW slice - the cow-venue adapter cdylib + composable-cow + ethflow keepers + a nexum-runtime host - bundled as the 3rd deployable repo; shepherd-sdk FOLDS INTO the shepherd bundle (absorbed, not kept as its own crate). Residual after the fold: (a) the nexum-venue-sdk->videre-sdk rename is videre-sdk-crate; (b) the surviving nexum-sdk/macro (nexum-venue-sdk->videre-sdk) consolidation; (c) the 'clean break / workspace-member removal' becomes absorbing shepherd-sdk into the shepherd bundle at the S2 carve (s2-three-carves / #293<-#329). Retitle to the nexum-sdk/macro consolidation + nexum-venue-sdk->videre-sdk rename; re-milestone into M2 (videre SDK/DX)." + } + ], + "merge": [ + { + "from": [ + 325, + 326 + ], + "into": 324, + "note": "#325 (golden vectors + conformance kit wiring) and #326 (bundle the cow adapter into the distribution) fold into #324 (build the cow adapter cdylib) - the single cow-adapter deliverable of the shepherd bundle." + }, + { + "from": [ + 329 + ], + "into": 293, + "note": "#329 (retire the shepherd-sdk workspace member and migrate remaining dependents) now reads, per decision D3, as: ABSORB shepherd-sdk INTO the shepherd bundle (the 3rd repo) - not a deletion of a kept crate but a fold into the deployable bundle. It stays merged into #293 (retire the legacy cow-api host shim and cow cone), the shepherd-bundle consolidation. Consistent with #136: shepherd-sdk is not kept standalone." + } + ], + "dedup": [ + { + "drafted_key": "host-identity-signing-backend", + "existing": 52 + }, + { + "drafted_key": "egress-guard-hardening-epic", + "existing": 139 + }, + { + "drafted_key": "cow-onvidere-epic", + "existing": 138 + }, + { + "drafted_key": "cow-venue-cdylib", + "existing": 324 + }, + { + "drafted_key": "cow-api-retire", + "existing": 293 + }, + { + "drafted_key": "ethflow-keeper", + "existing": 328 + }, + { + "drafted_key": "composable-cow-keeper-port", + "existing": 327 + }, + { + "drafted_key": "s3-gate-second-protocol-venue", + "existing": 140 + } + ], + "summary": "The reorg reshapes the eight legacy milestones (M0-M7) into a set that reads top-to-bottom as the videre execution order - P0 (contract reshape/master gate) -> S1 (generic host) -> S1b (CoW on the seam) -> S2 (the gated cut) -> S3 (second venue) - followed by three cross-cutting buckets (videre SDK/DX, the real egress guard, and the rolling debt pile). The biggest structural move is repurposing old M1 (#8): the intent-core it was chartered for is delivered (#137/#135/#131/#222 all close on the M1 train), so #8 becomes the P0 free-WIT-fold milestone anchored on R6 (the acyclicity master gate) and the videre:* rename/normalize/quote work, executed as one oracle-validated git-filter-repo fold. M0 (#7) is repurposed as the S1 long pole - making nexum-runtime venue-agnostic (grow Extension, extract VenueRegistry, delete the pool_router field, zero-leak CI gate) - absorbing the dissolved old-M2 lifecycle hardening. The largest dedup/close wins: eight drafted issues collapse onto existing tracker issues rather than being created (host-identity-signing-backend=#52, egress-guard-hardening-epic=#139, cow-onvidere-epic=#138, cow-venue-cdylib=#324, cow-api-retire=#293, ethflow-keeper=#328, composable-cow-keeper-port=#327, s3-gate-second-protocol-venue=#140), and seven existing issues close as already-delivered-by-the-M1-train or obsoleted by the Phase-0 migration-cruft deletion. #325/#326 fold into #324 (one cow-adapter deliverable) and #329 folds into #293 (the cow-cone retirement). The concrete CoW cone (venue cleave, keeper ports, cow-api retirement) is re-milestoned M1->M2/S1b so it lands after the generic seam it depends on, while venue-agnostic L1 hardening (#294/#266/#53/#51/#107/#244) consolidates under S1. Net: 58 net-new issues across the reshaped set (PASS-2 adds videre-consumable-release-graduation in M4, the fresh-external-consumer release-graduation precondition for the post-cut second venue), with two internal-overlap seams flagged for implementation-time coordination - host-generic-component-kind vs adapter-supervision-sweeps (both fold R8) and host-wit-deps-flip-carve vs s2-wit-cross-repo-consumption/s2-three-carves (the L1 slice of the carve). Two existing epics (#274 split, #273 preset-trait) are rescoped rather than duplicated to sit under the drafted split/seam epics, and #136 is rescoped away from its now-wrong 'retire shepherd-sdk' premise: per decision D3, shepherd-sdk is ABSORBED INTO the shepherd bundle (the 3rd deployable repo) at the S2 carve, not kept standalone as its own crate." + }, + "project": { + "number": 1, + "id": "PVT_kwDODm2Wqs4BcJ3a", + "note": "all issues added; Version intentionally unset" + }, + "epics": [ + { + "epic_key": "epic-m0-p0-master-gate-fold", + "reuse_existing_number": 0, + "from_drafted_key": "split-epic-p0", + "title": "wit: land the host-intent decouple master gate for an acyclic split", + "component": "WIT/ABI", + "milestone": "M1: Videre contract reshape and host-intent decoupling", + "charter": "The free in-monorepo reshape: land the host-intent WIT decouple (host event carries opaque status bytes) as the master gate, plus the single oracle-validated git-filter-repo fold to a green tip, so the acyclic split becomes CI-verifiable while nothing is pinned.", + "children": [ + "gap-opaque-status-contract-spec", + "host-r6-decouple", + "p0-acyclicity-scaffold", + "guard-advisory-m1", + "guard-deny-quota", + "gap-p0-fold-tail-hygiene", + "gap-p0-wit-fold-execution", + "gap-m1-green-tip-gate" + ] + }, + { + "epic_key": "epic-m0-videre-l2-contract", + "reuse_existing_number": 0, + "from_drafted_key": "videre-epic", + "title": "wit: reshape the intent contract into the videre venue abstraction", + "component": "WIT/ABI", + "milestone": "M1: Videre contract reshape and host-intent decoupling", + "charter": "Reshape the pre-release intent WIT into videre (rename, pin the surface, normalize to 0.1.0, add quote): the venue-neutral settlement and quoting contract every later venue and keeper compiles against, all riding the master-gate fold.", + "children": [ + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "gap-handshake-manifest-key-decision" + ] + }, + { + "epic_key": "epic-m1-generic-venue-agnostic-host", + "reuse_existing_number": 0, + "from_drafted_key": "host-generalize-epic", + "title": "runtime: make the host generic and venue-agnostic", + "component": "Runtime/Lifecycle", + "milestone": "M2: Generic venue-agnostic host", + "charter": "Grow the Extension seam to worker and provider roles, extract the venue registry and the generic supervised-component primitive from the adapter actor, de-hardcode the known table, land the bare launcher and the videre-host platform registration, so nothing venue, intent or cow shaped lives in the host layer.", + "children": [ + "host-extension-seam-roles", + "host-venue-registry-extract", + "#321", + "host-generic-component-kind", + "adapter-supervision-sweeps", + "host-nexum-world-registry", + "host-generic-launcher-bin", + "#273", + "gap-videre-host-platform-crate", + "videre-body-versions-handshake" + ] + }, + { + "epic_key": "epic-m1-s1-venue-agnostic-gate", + "reuse_existing_number": 0, + "from_drafted_key": "split-epic-s1", + "title": "runtime: prove the host is venue-agnostic (zero-leak gate)", + "component": "Engine", + "milestone": "M2: Generic venue-agnostic host", + "charter": "Land the permanent zero-leak and acyclicity CI check and flip it to blocking, proving the host is venue-agnostic once the pool router is deleted and the echo venue boots.", + "children": [ + "host-zero-leak-ci-gate", + "s1-gate-runtime-venue-agnostic" + ] + }, + { + "epic_key": "existing-294", + "reuse_existing_number": 294, + "from_drafted_key": null, + "title": "runtime: complete execution and lifecycle hardening", + "component": "Runtime/Lifecycle", + "milestone": "M0: Runtime architecture and lifecycle", + "charter": "Host execution, resource and lifecycle hardening that survives the split unchanged: WASI capability allowlist, per-module resource limits and local-store quota, fuel accounting, handler DoS, pluggable log seam, graceful drain.", + "children": [ + "#51", + "#53", + "#107", + "#244", + "#265", + "#266" + ] + }, + { + "epic_key": "epic-m2-videre-sdk-authoring", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "sdk: videre-sdk and the blessed venue and keeper authoring path", + "component": "SDK/DX", + "milestone": "M3: Videre SDK, macros and DX", + "charter": "Land the venue and keeper author front door: videre-sdk with the keeper sweep assembler, the single blessed venue and keeper macros with a typed venue client, and the videre conformance kit, additively extensible for the post-cut second venue.", + "children": [ + "#136", + "videre-sdk-crate", + "#322", + "#264", + "videre-venue-macro", + "videre-conformance-kit", + "videre-keeper-macro" + ] + }, + { + "epic_key": "epic-m2-reth-alloy-dx-seams", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "sdk: guest seams, alloy provider and the dx polish cluster", + "component": "SDK/DX", + "milestone": "M3: Videre SDK, macros and DX", + "charter": "Complete the guest-facing developer surface: identity, messaging and remote-store guest traits with mocks, richer local-store queries, an alloy provider seam over the chain host, and the alloy-grade DX polish cluster.", + "children": [ + "host-backend-guest-seams", + "#291", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster" + ] + }, + { + "epic_key": "existing-138", + "reuse_existing_number": 138, + "from_drafted_key": "cow-onvidere-epic", + "title": "intent: CoW venue adapter and flagship module ports", + "component": "CoW", + "milestone": "M4: CoW on the generic seam (the shepherd bundle)", + "charter": "Build the shepherd bundle cow adapter cdylib and port the flagship keepers onto the videre venue client, retiring the legacy cow-api cone, proving the generic seam carries a real venue.", + "children": [ + "cleave-cow-venue", + "cow-idempotency-seam", + "#324", + "shepherd-cow-event-abi-wits", + "#323", + "#327", + "#328", + "#293", + "composable-poll-wire-swap" + ] + }, + { + "epic_key": "epic-m3-s1b-seam-gate", + "reuse_existing_number": 0, + "from_drafted_key": "split-epic-s1b", + "title": "cow: run the cow keeper on the generic seam and rewrite the docs", + "component": "CoW", + "milestone": "M4: CoW on the generic seam (the shepherd bundle)", + "charter": "Close the seam gate: the keeper submits through the videre venue client with the cow-api host retired, and the source-of-truth docs are rewritten as the shipped-venue reference.", + "children": [ + "s1b-gate-cow-on-generic-seam", + "gap-docs-source-of-truth-rewrite" + ] + }, + { + "epic_key": "epic-m3-cow-keeper-bugfixes", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "cow: carry the live twap and composable keeper fixes into the port", + "component": "CoW", + "milestone": "M4: CoW on the generic seam (the shepherd bundle)", + "charter": "Land the still-live twap and composable keeper correctness fixes (dedup and retry classification, gate-marker leaks, revert-selector loops, signature-race retry, conditional-order removal) on the ported keeper, plus the grant deliverable-divergence reconcile.", + "children": [ + "#121", + "#48", + "#75", + "#320", + "#54", + "#64" + ] + }, + { + "epic_key": "existing-274", + "reuse_existing_number": 274, + "from_drafted_key": "split-epic-s2", + "title": "packaging: carve nexum-runtime, videre and shepherd into three repos", + "component": "Tooling/Packaging", + "milestone": "M5: The gated three-repo split", + "charter": "The physical three-repo split, gated only on gate (a) host venue-agnostic and gate (b) cow on the generic seam: transitional path-dep workspace, crate-local wit-deps with cross-repo git-tag sourcing, the go/no-go checklist, three history-preserving carves under the byte-identical tip oracle, and the first consumable videre-sdk release, with videre left additively extensible and the value-flow freeze held to the second-venue milestone.", + "children": [ + "s2-transitional-workspace", + "host-wit-deps-flip-carve", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "videre-consumable-release-graduation" + ] + }, + { + "epic_key": "epic-m4-operator-delivery", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "packaging: operator delivery, multi-chain and the swarm remote-store", + "component": "Tooling/Packaging", + "milestone": "M5: The gated three-repo split", + "charter": "Operator-facing delivery landed alongside the cut: green CI and CD (the sccache fork-PR fail-open fix), the multi-chain provider map and deployment docs, ghcr image packaging, and the real Swarm remote-store backend (an implementation item riding this epic for delivery convenience).", + "children": [ + "#337", + "#151", + "#125", + "#124" + ] + }, + { + "epic_key": "existing-140", + "reuse_existing_number": 140, + "from_drafted_key": "split-epic-s3", + "title": "sdk: prove venue-neutrality with a second venue and freeze the vocabulary", + "component": "SDK/DX", + "milestone": "M6: Second-venue acceptance and vocabulary freeze", + "charter": "Post-cut acceptance: build a genuine non-cow second-protocol venue against the already-split, published videre-sdk alone to prove venue-neutrality (this is the epic own deliverable, the deduped second-venue draft); then land the value-flow freeze and the curated adapter registry only after acceptance, holding videre additively extensible until the second venue proves the abstraction.", + "children": [ + "risk-value-flow-freeze-hold", + "#141", + "#330" + ] + }, + { + "epic_key": "existing-139", + "reuse_existing_number": 139, + "from_drafted_key": "egress-guard-hardening-epic", + "title": "guard: simulate, analyzers, policy, identity checkpoint", + "component": "Runtime/Lifecycle", + "milestone": "M7: Egress guard", + "charter": "The real egress guard with teeth: async policy over live state, a single decode through the checkpoint, and requires-signing coverage anchored on a real keystore identity backend.", + "children": [ + "guard-policy-async", + "guard-derive-before-guard", + "#52", + "guard-signing-boundary" + ] + }, + { + "epic_key": "epic-m6-egress-capability-teeth", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "guard: capability and egress enforcement teeth", + "component": "Runtime/Lifecycle", + "milestone": "M7: Egress guard", + "charter": "Bring http and messaging egress under real enforcement and align mock-grant fidelity so no capability escapes the compile-time world guarantee.", + "children": [ + "guard-egress-cap-world-guarantee", + "messaging-query-scope", + "mock-grant-fidelity" + ] + }, + { + "epic_key": "epic-m7-videre-deferred-concepts", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "sdk: deferred videre abstraction concepts", + "component": "SDK/DX", + "milestone": "M8: Post-v1 hardening and debt", + "charter": "The intentionally-parked videre abstractions, maker-side offer, taker-side RFQ firm-quote, and the venue-neutral materialiser, held until a real driving venue exists to shape them.", + "children": [ + "#355", + "rfq-firm-quote-additive", + "materialiser-source-venue" + ] + }, + { + "epic_key": "epic-m7-chain-typed-fault-debt", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "chain: robustness and typed-fault debt", + "component": "Chain", + "milestone": "M8: Post-v1 hardening and debt", + "charter": "Finish the typed-fault story across chain and the stub backends and clear the chain request-batch and backfill debt.", + "children": [ + "#269", + "#288", + "#286", + "#285", + "#289", + "#302" + ] + }, + { + "epic_key": "epic-m7-runtime-test-perf-debt", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "runtime: test-harness and performance debt", + "component": "Runtime/Lifecycle", + "milestone": "M8: Post-v1 hardening and debt", + "charter": "Host-internal debt: a multi-module test harness, a supervisor clock seam, lock performance, and state-seam batching.", + "children": [ + "#283", + "#284", + "#280", + "#105" + ] + }, + { + "epic_key": "epic-m7-messaging-docs-soak", + "reuse_existing_number": 0, + "from_drafted_key": null, + "title": "host: messaging backend, docs and soak evidence", + "component": "Host Backends", + "milestone": "M8: Post-v1 hardening and debt", + "charter": "The deferred Waku messaging backend and payload codec, the doc-consistency passes, and the unattended seven-day soak evidence.", + "children": [ + "#152", + "#212", + "#341", + "#65" + ] + }, + { + "epic_key": "existing-127", + "reuse_existing_number": 127, + "from_drafted_key": null, + "title": "docs: grant delivery plan and evidence tracker", + "component": "Docs", + "milestone": "M8: Post-v1 hardening and debt", + "charter": "Slim grant-delivery tracker: remaining PRs, evidence runs and sequencing. Kept as an epic per facts.md; its former children #121 and #125 are re-parented out (to M3 and M4) and referenced from the body only.", + "children": [] + } + ], + "component_map": { + "host-generalize-epic": "Runtime/Lifecycle", + "host-r6-decouple": "WIT/ABI", + "host-extension-seam-roles": "Runtime/Lifecycle", + "host-venue-registry-extract": "Runtime/Lifecycle", + "host-generic-component-kind": "Runtime/Lifecycle", + "host-nexum-world-registry": "Engine", + "host-zero-leak-ci-gate": "Engine", + "host-generic-launcher-bin": "Runtime/Lifecycle", + "host-wit-deps-flip-carve": "Tooling/Packaging", + "host-backend-guest-seams": "SDK/DX", + "host-identity-signing-backend": "Host Backends", + "videre-epic": "WIT/ABI", + "videre-wit-rename": "WIT/ABI", + "videre-wit-surface": "WIT/ABI", + "videre-wit-normalize": "WIT/ABI", + "videre-quote": "WIT/ABI", + "videre-body-versions-handshake": "WIT/ABI", + "videre-sdk-crate": "SDK/DX", + "videre-venue-macro": "SDK/DX", + "videre-keeper-macro": "SDK/DX", + "videre-conformance-kit": "SDK/DX", + "cow-onvidere-epic": "CoW", + "cleave-cow-venue": "CoW", + "cow-idempotency-seam": "CoW", + "cow-venue-cdylib": "CoW", + "shepherd-cow-event-abi-wits": "WIT/ABI", + "composable-cow-keeper-port": "CoW", + "ethflow-keeper": "CoW", + "cow-api-retire": "CoW", + "composable-poll-wire-swap": "CoW", + "split-epic-p0": "WIT/ABI", + "p0-acyclicity-scaffold": "Engine", + "split-epic-s1": "Engine", + "s1-gate-runtime-venue-agnostic": "Engine", + "split-epic-s1b": "CoW", + "s1b-gate-cow-on-generic-seam": "CoW", + "split-epic-s2": "Tooling/Packaging", + "s2-transitional-workspace": "Tooling/Packaging", + "s2-wit-cross-repo-consumption": "Tooling/Packaging", + "s2-cut-gate-checklist": "Tooling/Packaging", + "s2-three-carves": "Tooling/Packaging", + "split-epic-s3": "SDK/DX", + "s3-gate-second-protocol-venue": "SDK/DX", + "videre-consumable-release-graduation": "Tooling/Packaging", + "egress-guard-hardening-epic": "Runtime/Lifecycle", + "guard-advisory-m1": "Runtime/Lifecycle", + "guard-derive-before-guard": "Runtime/Lifecycle", + "guard-signing-boundary": "Runtime/Lifecycle", + "guard-deny-quota": "Runtime/Lifecycle", + "guard-policy-async": "Runtime/Lifecycle", + "guard-egress-cap-world-guarantee": "Runtime/Lifecycle", + "adapter-supervision-sweeps": "Runtime/Lifecycle", + "messaging-query-scope": "Host Backends", + "mock-grant-fidelity": "Host Backends", + "rfq-firm-quote-additive": "WIT/ABI", + "materialiser-source-venue": "SDK/DX", + "gap-opaque-status-contract-spec": "WIT/ABI", + "gap-p0-wit-fold-execution": "Tooling/Packaging", + "gap-p0-fold-tail-hygiene": "Tooling/Packaging", + "gap-m1-green-tip-gate": "Tooling/Packaging", + "gap-videre-host-platform-crate": "Runtime/Lifecycle", + "gap-handshake-manifest-key-decision": "WIT/ABI", + "gap-docs-source-of-truth-rewrite": "Docs", + "gap-alloy-provider-seam": "SDK/DX", + "gap-dx-polish-cluster": "SDK/DX", + "risk-value-flow-freeze-hold": "WIT/ABI", + "epic-m0-p0-master-gate-fold": "WIT/ABI", + "epic-m0-videre-l2-contract": "WIT/ABI", + "epic-m1-generic-venue-agnostic-host": "Runtime/Lifecycle", + "epic-m1-s1-venue-agnostic-gate": "Engine", + "existing-294": "Runtime/Lifecycle", + "epic-m2-videre-sdk-authoring": "SDK/DX", + "epic-m2-reth-alloy-dx-seams": "SDK/DX", + "existing-138": "CoW", + "epic-m3-s1b-seam-gate": "CoW", + "epic-m3-cow-keeper-bugfixes": "CoW", + "existing-274": "Tooling/Packaging", + "epic-m4-operator-delivery": "Tooling/Packaging", + "existing-140": "SDK/DX", + "existing-139": "Runtime/Lifecycle", + "epic-m6-egress-capability-teeth": "Runtime/Lifecycle", + "epic-m7-videre-deferred-concepts": "SDK/DX", + "epic-m7-chain-typed-fault-debt": "Chain", + "epic-m7-runtime-test-perf-debt": "Runtime/Lifecycle", + "epic-m7-messaging-docs-soak": "Host Backends", + "existing-127": "Docs", + "#294": "Runtime/Lifecycle", + "#266": "Runtime/Lifecycle", + "#265": "Observability", + "#244": "Runtime/Lifecycle", + "#107": "Runtime/Lifecycle", + "#53": "Runtime/Lifecycle", + "#51": "Runtime/Lifecycle", + "#273": "Runtime/Lifecycle", + "#321": "Runtime/Lifecycle", + "#291": "Storage", + "#264": "SDK/DX", + "#136": "SDK/DX", + "#322": "SDK/DX", + "#138": "CoW", + "#324": "CoW", + "#327": "CoW", + "#328": "CoW", + "#293": "CoW", + "#323": "CoW", + "#320": "CoW", + "#121": "CoW", + "#75": "CoW", + "#48": "CoW", + "#54": "CoW", + "#64": "CoW", + "#274": "Tooling/Packaging", + "#337": "Tooling/Packaging", + "#151": "Storage", + "#125": "Tooling/Packaging", + "#124": "Docs", + "#140": "SDK/DX", + "#330": "WIT/ABI", + "#141": "SDK/DX", + "#139": "Runtime/Lifecycle", + "#52": "Host Backends", + "#355": "WIT/ABI", + "#341": "Docs", + "#302": "Chain", + "#289": "Docs", + "#288": "Chain", + "#286": "SDK/DX", + "#285": "Observability", + "#284": "Runtime/Lifecycle", + "#283": "Runtime/Lifecycle", + "#280": "Runtime/Lifecycle", + "#269": "Chain", + "#212": "Host Backends", + "#152": "Host Backends", + "#105": "Storage", + "#65": "Tooling/Packaging", + "#127": "Docs" + }, + "apply_notes": [ + "D4-fix M5: s3-gate-second-protocol-venue dedups onto #140, so #140 IS the second-venue deliverable; M5 collapses to the single reused epic #140 holding [risk-value-flow-freeze-hold, #141, #330] (freeze #330 last). The old drafted split-epic-s3 folds into #140; no childless ghost epic.", + "D4-fix #136: strip the `epic` label and demote #136 to a normal issue before parenting it under epic-m2-videre-sdk-authoring (its former child #329 merges into #293, so it is childless). Set kind label per its rescope.", + "D4-fix #141: strip the `epic` label and demote #141 to a leaf under existing-140. facts.md line 40-41 parent-link reconciliation shows #141 owns no native sub-issues, so demotion strands nothing; D-decision authorises the demotion (facts.md is silent on #141).", + "D4-fix #127: per facts.md line 33, #127 stays a slim grant-tracker EPIC at M7 (keeps `epic` label, no functional children, references #121/#125 in body only). Moved out of M2 moved_issues into M7. Component Docs.", + "D4-fix #273: milestone is M1 per milestone_plan moved_issues (the reconcile.modify note is corrected from M0 to M1). Single parent = epic-m1-generic-venue-agnostic-host.", + "D4-fix gap-p0-fold-tail-hygiene: retagged WIT/ABI -> Tooling/Packaging to match its fold-execution sibling; tail-hygiene precedes fold-execution because fold-execution depends_on it (content assembled before the fold runs).", + "split-epic-s2 folds into reused #274; split-epic-s3 folds into reused #140; cow-onvidere-epic dedups to #138; egress-guard-hardening-epic dedups to #139. None are created as standalone epics.", + "Every issue has exactly one epic parent (single-parent) and exactly one of the 12 Project Components; epics carry the `epic` label + a Component + milestone + Project #1. Version field left unset everywhere.", + "Nine-milestone scheme (mfw78 2026-07-16): milestone #7 stays M0 'Runtime architecture and lifecycle' (only #294 + #51/#53/#107/#244/#265/#266); #8=M1, a NEW milestone M2 'Generic venue-agnostic host', #5=M3, #3=M4, #4=M5, #10=M6, #9=M7, #6=M8. Cluster-tree old labels remapped old-M0->M1, old-M1 host items->M2 (lifecycle epic #294 stays M0), old-M2->M3, old-M3->M4, old-M4->M5, old-M5->M6, old-M6->M7, old-M7->M8. Every epic 'milestone' field carries the NEW clean title; Version left unset." + ] +} diff --git a/docs/design/issue-milestone-plan.md b/docs/design/issue-milestone-plan.md new file mode 100644 index 00000000..90bc0454 --- /dev/null +++ b/docs/design/issue-milestone-plan.md @@ -0,0 +1,316 @@ +# Videre three-layer split: issue and milestone plan (v3) + +Reorganisation plan for `nullislabs/shepherd`, generated against the develop tracker state (61 open issues). This document is DATA and PLANNING only: nothing here has been created, closed, edited, or re-milestoned on GitHub. The machine-readable source of truth is `docs/design/issue-milestone-plan.json`; the architecture rationale lives in `docs/design/venue-platform-architecture.md` and `docs/design/videre-split-plan.md`. + +The target platform is three layers with one acyclic dependency edge each: `nexum-runtime` (the universal host) is consumed by `videre` (the generic intent and venue abstraction), which is consumed by `shepherd` (the concrete CoW bundle). The milestones below read top to bottom as the execution order that gets us there. + +Owner decisions folded in: + +- D1: cut the three repos on two proofs only (host venue-agnostic, cow on the generic seam); the genuine second venue is post-cut acceptance, not a pre-cut gate. +- D2: chronological milestone spine, SDK early, guard late. +- D3: `shepherd` is the CoW bundle; the old `shepherd-sdk` is absorbed into it, not kept standalone. +- D4: epic decomposition via native GitHub sub-issues, every issue placed in Project #1 with exactly one Component, the Version field left unset. + +Nine milestones are used (mfw78, 2026-07-16): milestone #7 stays as M0, the mostly-complete historical runtime; everything else stacks on top, and a brand-new milestone M2 holds the host-generalization work. + +## 1. Chronological milestone walk + +| Milestone | GitHub milestone | Focus | Epics | Child issues | +|-----------|------------------|-------|-------|--------------| +| M0: Runtime architecture and lifecycle | #7 (kept as-is) | The historical, mostly-complete runtime. Its only tracked open work is the execution and lifecycle hardening under epic #294. | 1 | 6 | +| M1: Videre contract reshape and host-intent decoupling | #8 (renamed) | The free in-monorepo contract reshape: decouple the host from intent (opaque status bytes), rename the intent packages to videre, normalize to 0.1.0, add quoting, land as one oracle-validated fold to a green tip. | 2 | 13 | +| M2: Generic venue-agnostic host | new | Make `nexum-runtime` a generic component host: grow the extension seam to worker and provider roles, extract the venue registry, delete the privileged router field, prove it with a blocking zero-leak CI check. | 2 | 12 | +| M3: Videre SDK, macros and DX | #5 (renamed) | The venue and keeper author front door lands before the CoW bundle and the split, because the keepers and the second venue are all authored against it: videre-sdk, the blessed macros, the conformance kit, guest seams, an alloy provider seam, the DX polish cluster. | 2 | 11 | +| M4: CoW on the generic seam (the shepherd bundle) | #3 (renamed) | Prove the generic seam carries a real venue: cleave the cow venue, build the adapter cdylib, port the keepers onto the videre venue client, retire the legacy cow-api cone, carry the live keeper bugfixes. | 3 | 17 | +| M5: The gated three-repo split | #4 (renamed) | The physical split, gated only on the two proofs: transitional workspace, crate-local WIT deps with cross-repo sourcing, the go/no-go checklist, three history-preserving carves, the first consumable videre release, plus operator delivery. | 2 | 17 | +| M6: Second-venue acceptance and vocabulary freeze | #10 (renamed) | Post-cut acceptance: build a genuine non-cow second venue against the published videre-sdk alone; only after it proves the abstraction do the value-flow freeze and the curated adapter registry land. | 1 | 3 | +| M7: Egress guard | #9 (renamed) | The real egress guard with teeth, deferred until after the SDK, the bundle and the split: async policy over live state, single-decode through the checkpoint, the signing boundary, capability and egress enforcement. | 2 | 7 | +| M8: Post-v1 hardening and debt | #6 (renamed) | The rolling, non-gating debt bucket: deferred videre concepts, typed-fault and chain debt, test-harness and perf debt, the deferred messaging backend, docs passes and soak evidence, and the slim grant tracker. | 5 | 17 | + +Milestone #1 ("Host backends real") stays dissolved. + +## 1b. Epic tree (native sub-issues) + +Every milestone owns two to four epics (thin milestones fewer), each a native GitHub sub-issue parent. Children are listed in execution order; the tag in parentheses is the Project Component. Reused existing epics keep their number; new epics are marked NEW. No epic is nested under another epic: the umbrella framing lives in the milestone charter, not a super-epic. + +### M0: Runtime architecture and lifecycle + +- Epic #294 (reuse) `runtime: complete execution and lifecycle hardening` (Runtime/Lifecycle) + - #51 extend the manifest capability allowlist to the WASI surface (Runtime/Lifecycle) + - #53 enforce per-module resource limits and local-store quota (Runtime/Lifecycle) + - #107 verify fuel accounting during host function calls (Runtime/Lifecycle) + - #244 handler denial-of-service hardening (Runtime/Lifecycle) + - #265 make the log pipeline pluggable through a builder seam (Observability) + - #266 graceful-shutdown drain for durable state (Runtime/Lifecycle) + +### M1: Videre contract reshape and host-intent decoupling + +- Epic NEW `wit: land the host-intent decouple master gate for an acyclic split` (WIT/ABI) + - wit: spec the opaque-status destructuring contract the host event commits to (WIT/ABI) + - wit: carry opaque status bytes so the host stops importing intent (WIT/ABI) + - engine: land the acyclicity and zero-leak CI check, advisory first (Engine) + - guard: ship the advisory-only checkpoint posture (Runtime/Lifecycle) + - guard: charge quota on a denied submit to close the busy-loop denial of service (Runtime/Lifecycle) + - packaging: fold-tail hygiene, codec discriminator, migration-cruft deletion, retry doc caveat (Tooling/Packaging) + - packaging: execute the contract reshape as one oracle-validated fold (Tooling/Packaging) + - packaging: finish the runtime train to a single green linear tip (Tooling/Packaging) +- Epic NEW `wit: reshape the intent contract into the videre venue abstraction` (WIT/ABI) + - wit: rename the intent packages to videre (WIT/ABI) + - wit: pin the videre surface (types, venue, value-flow) (WIT/ABI) + - wit: normalize every package to a single 0.1.0 (WIT/ABI) + - wit: add quote to the videre venue client and adapter (WIT/ABI) + - wit: decide the install-time handshake manifest key and match semantics (WIT/ABI) + +### M2: Generic venue-agnostic host + +- Epic NEW `runtime: make the host generic and venue-agnostic` (Runtime/Lifecycle) + - runtime: grow the extension seam to worker and provider roles (Runtime/Lifecycle) + - runtime: extract the venue registry and delete the privileged router field (Runtime/Lifecycle) + - #321 bound the intent-router status-watch set with eviction and config (Runtime/Lifecycle) + - runtime: extract a generic supervised-component primitive from the adapter actor (Runtime/Lifecycle) + - runtime: fold venue adapters into the restart and poison sweeps (Runtime/Lifecycle) + - engine: de-hardcode the known table and extract world synthesis into nexum-world (Engine) + - runtime: extract a generic launcher and a bare engine binary (Runtime/Lifecycle) + - #273 finish the runtime preset trait capability gaps for extensions and pre-built instances (Runtime/Lifecycle) + - runtime: build the videre-host crate and platform registration (Runtime/Lifecycle) + - wit: install-time body-versions schema handshake (WIT/ABI) +- Epic NEW `runtime: prove the host is venue-agnostic (zero-leak gate)` (Engine) + - engine: fail CI when the host regains venue, intent or cow knowledge (Engine) + - runtime: prove the host is venue-agnostic by flipping the zero-leak check to blocking (Engine) + +### M3: Videre SDK, macros and DX + +- Epic NEW `sdk: videre-sdk and the blessed venue and keeper authoring path` (SDK/DX) + - #136 consolidate nexum-sdk and macros; rename nexum-venue-sdk to videre-sdk (SDK/DX) + - sdk: rename to videre-sdk and add the keeper sweep assembler and venue client (SDK/DX) + - #322 make the IntentBody derive no_std, emitting core and alloc paths (SDK/DX) + - #264 convert the bind-macro error and level shims to From impls (SDK/DX) + - sdk: the single blessed venue authoring macro (SDK/DX) + - sdk: the videre conformance kit and wire-drift gate (SDK/DX) + - sdk: the keeper macro and typed venue client (SDK/DX) +- Epic NEW `sdk: guest seams, alloy provider and the dx polish cluster` (SDK/DX) + - sdk: guest seams and mocks for identity, messaging and remote-store (SDK/DX) + - #291 add contains, len and count metadata queries to the local store (Storage) + - sdk: an alloy provider seam over the chain host (SDK/DX) + - sdk: the alloy-grade DX polish cluster (SDK/DX) + +### M4: CoW on the generic seam (the shepherd bundle) + +- Epic #138 (reuse) `intent: CoW venue adapter and flagship module ports` (CoW) + - cow: cleave the venue into orderbook-only and a composable keeper (CoW) + - cow: settle the idempotency seam before order assembly moves into the adapter (CoW) + - #324 build the cow adapter cdylib with timeout transport middleware (CoW) + - wit: own the shepherd-cow event-ABI packages at the bundle layer (WIT/ABI) + - #323 ratify or reconcile the retry classification table against the api retry hint (CoW) + - #327 re-point the twap monitor onto the cow adapter submit and status (CoW) + - #328 re-point the ethflow watcher onto the cow adapter observe and status (CoW) + - #293 retire the legacy cow-api host shim and cow cone (CoW) + - cow: swap the composable poll wire and delete the legacy revert adapter, fork-gated (CoW) +- Epic NEW `cow: run the cow keeper on the generic seam and rewrite the docs` (CoW) + - cow: run the cow keeper on the generic venue client (CoW) + - docs: rewrite the platform docs as the shipped-venue source of truth (Docs) +- Epic NEW `cow: carry the live twap and composable keeper fixes into the port` (CoW) + - #121 treat DuplicatedOrder as already-submitted and add errorType retry classification (CoW) + - #48 stop twap-monitor orphaned gate markers leaking on the decode-failure path (CoW) + - #75 stop twap-monitor retrying unknown revert selectors every block forever (CoW) + - #320 one-block retry before dropping on an invalid signature, same-block create race (CoW) + - #54 support the ConditionalOrderRemoved event (CoW) + - #64 reconcile the grant contract-modification deliverable divergence (CoW) + +### M5: The gated three-repo split + +- Epic #274 (reuse) `packaging: carve nexum-runtime, videre and shepherd into three repos` (Tooling/Packaging) + - packaging: transitional path-dep workspace in the three groupings (Tooling/Packaging) + - packaging: flip the host WIT to crate-local deps and carve the runtime repo (Tooling/Packaging) + - packaging: cross-repo WIT consumption with git-tag sourcing (Tooling/Packaging) + - packaging: the cut go/no-go checklist, host venue-agnostic and cow on the seam (Tooling/Packaging) + - packaging: carve nexum-runtime, videre and shepherd into three repos (Tooling/Packaging) + - packaging: cut the first consumable videre release and graduate off the umbrella (Tooling/Packaging) +- Epic NEW `packaging: operator delivery, multi-chain and the swarm remote-store` (Tooling/Packaging) + - #337 fix the sccache config that hard-fails every fork PR because secrets do not flow to fork runs (Tooling/Packaging) + - #151 real Swarm remote-store backend (Storage) + - #125 fix the ghcr image name mismatch that breaks fresh-server docker compose pull (Tooling/Packaging) + - #124 multi-chain deployment patterns for Mainnet, Arbitrum, Base and Gnosis (Docs) + +### M6: Second-venue acceptance and vocabulary freeze + +- Epic #140 (reuse) `sdk: prove venue-neutrality with a second venue and freeze the vocabulary` (SDK/DX) + - wit: hold the value-flow freeze until the second venue proves the abstraction (WIT/ABI) + - #141 curated adapter registry and consent surface (SDK/DX) + - #330 freeze-gate decisions for videre value-flow, amount canonicalization and native-token settlement (WIT/ABI) + +The second-venue build is epic #140's own charter (the deduped second-venue draft), so it has no separate deliverable child; the freeze (#330) lands last, after acceptance. + +### M7: Egress guard + +- Epic #139 (reuse) `guard: simulate, analyzers, policy, identity checkpoint` (Runtime/Lifecycle) + - guard: make the policy check async for simulate and remote analyzers (Runtime/Lifecycle) + - guard: close the derive-before-check escape and single-decode the body (Runtime/Lifecycle) + - #52 real keystore-backed signing identity backend (Host Backends) + - guard: move the checkpoint to the signed-transaction identity boundary (Runtime/Lifecycle) +- Epic NEW `guard: capability and egress enforcement teeth` (Runtime/Lifecycle) + - guard: bring http egress under the compile-time world guarantee (Runtime/Lifecycle) + - host: enforce messaging query scope with the Waku backend (Host Backends) + - host: align mock capability-grant fidelity to the real host grant (Host Backends) + +### M8: Post-v1 hardening and debt + +- Epic NEW `sdk: deferred videre abstraction concepts` (SDK/DX) + - #355 add offer and provide-liquidity for maker-side two-sided venues, post 0.1 (WIT/ABI) + - wit: additive firm-quote field for a taker-side RFQ venue (WIT/ABI) + - sdk: the venue-neutral source-to-venue materialiser (SDK/DX) +- Epic NEW `chain: robustness and typed-fault debt` (Chain) + - #269 populate the rate-limited retry hint and map 429 and timeout by type (Chain) + - #288 flatten the request-batch dead outer chain-error or record the escape hatch (Chain) + - #286 add From and TryFrom between the wit-bindgen fault and the SDK fault (SDK/DX) + - #285 richer typed faults for the remote-store, identity and messaging backends (Observability) + - #289 typed-fault doc consistency pass (Docs) + - #302 wide-range bulk log backfill for large log gaps (Chain) +- Epic NEW `runtime: test-harness and performance debt` (Runtime/Lifecycle) + - #283 grow a multi-module harness variant and port the boot end-to-end tests (Runtime/Lifecycle) + - #284 give the supervisor poison window and restart backoff a clock seam (Runtime/Lifecycle) + - #280 migrate std sync locks to parking_lot where not held across await (Runtime/Lifecycle) + - #105 batch and host-side filtered operations on the state seam (Storage) +- Epic NEW `host: messaging backend, docs and soak evidence` (Host Backends) + - #152 real Waku publish backend (Host Backends) + - #212 payload encoding convention for nexum-native topics (Host Backends) + - #341 fix doc 02 boot-order description that has subscriptions before init (Docs) + - #65 evidence the seven-day unattended soak test (Tooling/Packaging) +- Epic #127 (reuse) `docs: grant delivery plan and evidence tracker` (Docs) + - Slim grant-delivery tracker: remaining PRs, evidence runs and sequencing. Kept as an epic; its former children #121 and #125 are re-parented out to M4 and M5 and referenced from the body only. No functional children. + +## 2. New issues index + +Forty-one new leaf issues are created (the eleven remaining drafts collapse onto existing tracker issues; see the dedup table). Every new issue gets exactly one kind label, one Component, its milestone, and Project #1 membership. + +| Milestone | Title | Kind | Component | +|-----------|-------|------|-----------| +| M1 | wit: spec the opaque-status destructuring contract the host event commits to | docs | WIT/ABI | +| M1 | wit: carry opaque status bytes so the host stops importing intent | breaking | WIT/ABI | +| M1 | wit: decide the install-time handshake manifest key and match semantics | docs | WIT/ABI | +| M1 | wit: rename the intent packages to videre | breaking | WIT/ABI | +| M1 | wit: pin the videre surface (types, venue, value-flow) | breaking | WIT/ABI | +| M1 | wit: normalize every package to a single 0.1.0 | debt | WIT/ABI | +| M1 | wit: add quote to the videre venue client and adapter | breaking | WIT/ABI | +| M1 | guard: ship the advisory-only checkpoint posture | security | Runtime/Lifecycle | +| M1 | guard: charge quota on a denied submit to close the busy-loop denial of service | security | Runtime/Lifecycle | +| M1 | engine: land the acyclicity and zero-leak CI check, advisory first | debt | Engine | +| M1 | packaging: fold-tail hygiene, codec discriminator, migration-cruft deletion, retry doc caveat | debt | Tooling/Packaging | +| M1 | packaging: execute the contract reshape as one oracle-validated fold | debt | Tooling/Packaging | +| M1 | packaging: finish the runtime train to a single green linear tip | debt | Tooling/Packaging | +| M2 | runtime: grow the extension seam to worker and provider roles | feature | Runtime/Lifecycle | +| M2 | runtime: extract the venue registry and delete the privileged router field | debt | Runtime/Lifecycle | +| M2 | runtime: extract a generic supervised-component primitive from the adapter actor | feature | Runtime/Lifecycle | +| M2 | runtime: fold venue adapters into the restart and poison sweeps | debt | Runtime/Lifecycle | +| M2 | engine: de-hardcode the known table and extract world synthesis into nexum-world | debt | Engine | +| M2 | runtime: extract a generic launcher and a bare engine binary | debt | Runtime/Lifecycle | +| M2 | runtime: build the videre-host crate and platform registration | feature | Runtime/Lifecycle | +| M2 | wit: install-time body-versions schema handshake | feature | WIT/ABI | +| M2 | engine: fail CI when the host regains venue, intent or cow knowledge | debt | Engine | +| M2 | runtime: prove the host is venue-agnostic by flipping the zero-leak check to blocking | debt | Engine | +| M3 | sdk: rename to videre-sdk and add the keeper sweep assembler and venue client | feature | SDK/DX | +| M3 | sdk: the single blessed venue authoring macro | dx | SDK/DX | +| M3 | sdk: the videre conformance kit and wire-drift gate | dx | SDK/DX | +| M3 | sdk: the keeper macro and typed venue client | feature | SDK/DX | +| M3 | sdk: guest seams and mocks for identity, messaging and remote-store | feature | SDK/DX | +| M3 | sdk: an alloy provider seam over the chain host | dx | SDK/DX | +| M3 | sdk: the alloy-grade DX polish cluster | dx | SDK/DX | +| M4 | cow: cleave the venue into orderbook-only and a composable keeper | feature | CoW | +| M4 | cow: settle the idempotency seam before order assembly moves into the adapter | feature | CoW | +| M4 | wit: own the shepherd-cow event-ABI packages at the bundle layer | feature | WIT/ABI | +| M4 | cow: run the cow keeper on the generic venue client | debt | CoW | +| M4 | cow: swap the composable poll wire and delete the legacy revert adapter, fork-gated | debt | CoW | +| M4 | docs: rewrite the platform docs as the shipped-venue source of truth | docs | Docs | +| M5 | packaging: transitional path-dep workspace in the three groupings | debt | Tooling/Packaging | +| M5 | packaging: flip the host WIT to crate-local deps and carve the runtime repo | debt | Tooling/Packaging | +| M5 | packaging: cross-repo WIT consumption with git-tag sourcing | debt | Tooling/Packaging | +| M5 | packaging: the cut go/no-go checklist, host venue-agnostic and cow on the seam | debt | Tooling/Packaging | +| M5 | packaging: carve nexum-runtime, videre and shepherd into three repos | breaking | Tooling/Packaging | +| M5 | packaging: cut the first consumable videre release and graduate off the umbrella | dx | Tooling/Packaging | +| M6 | wit: hold the value-flow freeze until the second venue proves the abstraction | debt | WIT/ABI | +| M7 | guard: make the policy check async for simulate and remote analyzers | debt | Runtime/Lifecycle | +| M7 | guard: close the derive-before-check escape and single-decode the body | security | Runtime/Lifecycle | +| M7 | guard: move the checkpoint to the signed-transaction identity boundary | security | Runtime/Lifecycle | +| M7 | guard: bring http egress under the compile-time world guarantee | security | Runtime/Lifecycle | +| M7 | host: enforce messaging query scope with the Waku backend | bug | Host Backends | +| M7 | host: align mock capability-grant fidelity to the real host grant | debt | Host Backends | +| M8 | wit: additive firm-quote field for a taker-side RFQ venue | feature | WIT/ABI | +| M8 | sdk: the venue-neutral source-to-venue materialiser | dx | SDK/DX | + +Ten more epics are created fresh (the fourteen new epics minus the four that reuse an existing number), each carrying the `epic` label, a Component, a milestone and Project #1 membership. The reused epics (#294, #138, #274, #140, #139, #127) are not recreated. + +## 3. Close + +Seven open issues close as already delivered or obsoleted. + +| Issue | Reason | +|-------|--------| +| #339 | Obsolete: it fixed a manifest reference inside a migration file that is already deleted as reshape cruft. | +| #287 | Targets the legacy cow-api extension that #293 deletes; the timeout and typed-fault requirement is carried by the adapter error projection and the venue-error reshape, and the host-chain equivalent lives on as #269. | +| #222 | Delivered by the runtime train: the source, retrier and retry-action primitives landed; forward keeper-sweep rework is carried by the videre-sdk work. | +| #137 | Delivered by the runtime train: the value-flow and intent packages, the venue-adapter world, the router, the venue SDK, the conformance kit and echo-venue all landed; the successor is the videre contract reshape epic. | +| #135 | Delivered by the runtime train: the keeper primitives and single-venue loop landed; deferred generalization is the videre-sdk sweep assembler and the M8 materialiser. | +| #131 | Docs-only, delivered; the design docs now exist. Go-forward doc work is the source-of-truth rewrite. | +| #7 | Stale pre-restructure roadmap epic with no milestone; its goal is largely delivered and its workstreams are decomposed across the milestones and individual issues. | + +## 4. Modify + +Six open issues are rescoped or retitled rather than recreated. + +| Issue | Change | +|-------|--------| +| #139 | Rescope to the real egress guard: fold the router, capability and lifecycle hardening (single-decode, signed-transaction boundary, async policy, http under the world guarantee, messaging query scope, adapter sweeps, mock fidelity) in as children. Stays in M7, depends on #52, advisory-only until then. | +| #330 | Retitle under the videre rename (value-flow package). Keep the two freeze-gate ontology decisions. The freeze is held until the post-cut second venue proves the abstraction: it must not be applied at the cut or earlier; it lands in M6 only after acceptance. | +| #289 | Drop the deleted migration-file bullet; keep the still-valid typed-fault and docs-hygiene items. Stays in M8, distinct from the source-of-truth rewrite. | +| #274 | Rescope from a two-repo split to the three-repo split spanning the whole reshape; the physical carve is its closest correspondent. Re-milestone to M5. | +| #273 | Rescope: the preset launch-surface ask is subsumed by the extension-seam generalization and the bare-binary launcher; the residual mock-runtime preset path rolls into the guest seams. Re-milestone to M2. | +| #136 | Rescope (D3): shepherd-sdk is not kept standalone; it is absorbed into the shepherd bundle at the carve. The residual is the SDK and macro consolidation plus the nexum-venue-sdk to videre-sdk rename. Retitle and re-milestone to M3. Strip the `epic` label and demote to a normal issue. | + +## 5. Merge + +| From | Into | Note | +|------|------|------| +| #325, #326 | #324 | Golden-vector and distribution-bundling work folds into the single cow-adapter cdylib deliverable. | +| #329 | #293 | Absorbing shepherd-sdk into the bundle is part of retiring the legacy cow cone; #329 folds into #293 and closes as merged. #136 loses it as a child. | + +## 6. Dedup + +Eleven drafted items collapse onto existing tracker issues instead of being created. Eight leaf and epic bodies dedup by number; the epic dedups become reused epics. + +| Drafted item | Existing issue | +|--------------|----------------| +| host-identity-signing-backend | #52 | +| egress-guard-hardening-epic | #139 (reused epic) | +| cow-onvidere-epic | #138 (reused epic) | +| cow-venue-cdylib | #324 | +| cow-api-retire | #293 | +| ethflow-keeper | #328 | +| composable-cow-keeper-port | #327 | +| second-protocol-venue draft | #140 (reused epic; the second-venue build is #140's own charter) | +| split (physical cut) draft | #274 (reused epic) | + +The two remaining drafted epics that name the contract reshape and the host generalization become new epics; the umbrella framing lives in the milestone charters, not a super-epic. + +## 7. Epic and Component summary + +- 20 epics total: 6 reuse an existing tracker issue (#294, #138, #274, #140, #139, #127), 14 are new. +- Every issue has exactly one epic parent (single-parent native sub-issue), exactly one of the twelve Project Components, a milestone equal to its M-label, and Project #1 membership. +- Reused epics keep their live already-parented children (#294 keeps #51/#53/#107/#244/#265/#266; #138 keeps #293/#323/#324/#327/#328). The re-parent map is honoured: #321 and #273 to M2, #322 to M3, #121 to M4, #125 to M5, #330 to M6. #329 merges into #293. +- #136 and #141 currently carry the `epic` label and are demoted to leaves (their former children are re-parented or merged away, so nothing is stranded). #127 stays an epic, slim, with no functional children. +- The Version field is intentionally left unset everywhere. + +## 8. Ordered apply sequence + +The apply script (run by the owner, not here) proceeds in this order: + +1. Rename the eight existing milestones in place by number to their new titles, and create the new milestone M2. Leave milestone #1 dissolved. Do not touch milestone #7's title. +2. Close the seven delivered or obsolete issues (#339, #287, #222, #137, #135, #131, #7). +3. Apply the two merges: retarget or note #325 and #326 into #324; fold #329 into #293 and close it as merged. +4. Apply the six modifies: rescope and retitle #139, #330, #289, #274, #273, #136; re-milestone as noted; strip the `epic` label from #136. +5. Create the 41 new leaf issues with their kind label, one Component, their milestone and Project #1 membership; leave Version unset. +6. Create the ten fresh epics with the `epic` label, a Component, a milestone and Project #1 membership; for the reused epics (#294, #138, #274, #140, #139, #127) set the Component and milestone and confirm the `epic` label. +7. Wire native sub-issue parent and child links in the child order listed in section 1b, one parent per child. Honour the re-parent map; strip the `epic` label from #141 and demote it under #140. +8. Set the Project #1 Component field for every issue and epic; add every issue to Project #1. Keep #127 slim with #121 and #125 referenced from its body only. +9. Verify: 61 open tracker issues accounted for (51 living, 6 as reused epics and 45 as children; 10 closed or merged), no double-parenting, Version unset everywhere. + +Coverage is complete: the 10 open issues not placed under an epic are exactly the 7 close targets and the 3 merge sources. diff --git a/docs/design/issue-milestone-plan.v1.json b/docs/design/issue-milestone-plan.v1.json new file mode 100644 index 00000000..bf185829 --- /dev/null +++ b/docs/design/issue-milestone-plan.v1.json @@ -0,0 +1,1633 @@ +{ + "generated_note": "Videre three-layer split issue/milestone reorganisation plan for nullislabs/shepherd, generated 2026-07-15 against develop tracker state (61 open issues across 8 live milestones). DATA ONLY — nothing was created, closed, edited, or re-milestoned on GitHub; the user applies these after review. drafted_issues carries the full drafts to create (minus the 8 that dedup onto existing #s — see reconcile.dedup). reconcile carries the milestone reshape, closes, modifies, merges and dedups. Design source: docs/design/venue-platform-architecture.md + docs/design/videre-split-plan.md.", + "drafted_issues": [ + { + "key": "host-generalize-epic", + "title": "Epic: make nexum-runtime a generic, venue-agnostic component host", + "body": "## Context\n\nThe target platform is three layers with one acyclic dependency edge each: `nexum-runtime` (L1, universal host) <- `videre` (L2, generic intent/venue) <- `CoW-on-videre` (L3, concrete CoW). See `docs/design/venue-platform-architecture.md` s2 and `docs/design/videre-split-plan.md` s1-s2.\n\nToday the host is *not* venue-agnostic: `wit/nexum-host/types.wit:8` imports `nexum:intent/types.{receipt, intent-status}`; the `PoolRouter` is a privileged `HostState.pool_router` field (`host/state.rs:54`); `ModuleKind` is a hardcoded `EventModule | VenueAdapter` enum with a `match kind` in the supervisor; and the KNOWN capability table bakes in `pool` and `cow-api` rows. Until these are removed the engine cannot compile without L2's WIT and an acyclic repo split is physically impossible.\n\nThis epic tracks the work to make `nexum-runtime` know only two generic component roles - worker and provider - plus a generic `Extension` seam (`link`, `capabilities`, `service`, `provider`), so anything venue/intent/cow-shaped is contributed from outside. See the pinned seam design in `docs/design/videre-split-plan.md` s7.2 and the pinned sequencing in s8.\n\n## Scope\n\nChild issues, in sequence: R6 host<->intent WIT decouple (P0 master gate); grow the Extension seam to carry service+provider (S1); extract PoolRouter->VenueRegistry and delete the privileged field; extract the generic supervised-component/host-actor primitive from AdapterActor (folds R8); de-hardcode the KNOWN table into nexum-world; add the zero-leak CI gate; extract the generic launcher + bare Ext=() engine bin (S1); flip nexum:host WIT to crate-local wit-deps + carve nexum-runtime as L1 repo (S2); guest SDK seams + identity signing backend (deferred).\n\n## Acceptance\n\nSee acceptance field. A second platform would be just another impl Extension.\n\nCross-ref: `docs/design/venue-platform-architecture.md` s6, s8; `docs/design/videre-split-plan.md` s7.2, s8.", + "kind": "epic", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "epic", + "component/engine-runtime", + "needs-design" + ], + "acceptance": "Zero-leak CI gate green (no intent/venue/cow symbols or crate edges in nexum-runtime); echo-venue installs and a worker submits through the generic Extension seam; PoolRouter field deleted; a second platform is a plain impl Extension.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-r6-decouple", + "title": "R6: decouple nexum:host from nexum:intent - host event carries opaque status bytes (MASTER GATE)", + "body": "See docs/design/venue-platform-architecture.md s6 R6, s8 dec-2 and docs/design/videre-split-plan.md s1, s5 (Phase 0), s8 P0.1. wit/nexum-host/types.wit:8 does `use nexum:intent/types@0.1.0.{receipt, intent-status}` and the host event variant carries an intent-status-update, so the L1 host world imports the L2 intent package; until that use is gone nexum-runtime cannot compile without L2 WIT and an acyclic three-repo split is physically impossible. This is the master gate: move #1, before any crate moves. Drop the use; redefine the host event stream to carry opaque status bytes; specify (needs-design) the versioned destructuring contract those bytes commit to; regenerate goldens; re-assert the byte-identical tip oracle; land in the Phase-0 fold.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "breaking", + "component/wit-abi", + "needs-design", + "effort/days" + ], + "acceptance": "nexum:host WIT no longer uses nexum:intent (leaf package); host event carries opaque status bytes with a documented versioned destructuring contract; cargo tree -p nexum-runtime reaches no intent crate; goldens + tip oracle re-validated.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-extension-seam-roles", + "title": "Grow the Extension seam to carry worker/provider roles (service + provider, ProviderKind, HostService)", + "body": "See docs/design/videre-split-plan.md s7.2, s7.3, s8 S1.1. The extension seam today is Extension { link, capabilities } (host/extension.rs): it can add host interfaces a worker imports but cannot register a component kind (ModuleKind is a hardcoded enum) or a host service (PoolRouter is a privileged field). The fix: the runtime knows only two generic roles - worker (host pushes events at it) and provider (host holds it behind a serialized actor). Extension grows to contribute namespace/capabilities/link/service/provider; add HostService (type-erased, on HostState.services[ns]) and ProviderKind (link + async install). MSRV 1.94 async strategy: native AFIT for hot static-dispatch guest traits; async_trait only for the one dyn cold-path ProviderKind::install; keep HostService sync so it stays dyn-compatible. This is the long pole and has no prior ADR.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "feature", + "component/engine-runtime", + "needs-design", + "effort/weeks" + ], + "acceptance": "Extension seam carries namespace/capabilities/link/service/provider; HostService + ProviderKind traits exist; HostState.services is a typed per-namespace map; compiles on MSRV 1.94 with native AFIT for hot traits and async_trait only for ProviderKind::install; existing worker boot path unchanged.", + "depends_on": [ + "host-r6-decouple" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-venue-registry-extract", + "title": "Extract PoolRouter -> VenueRegistry as an extension-owned service; delete the privileged supervisor field", + "body": "See docs/design/videre-split-plan.md s5 (Phase S1), s7.2, s8 S1.2, s6.2 D1. The router is a privileged field HostState.pool_router (host/state.rs:54), built and cloned through supervisor.rs. Once the seam carries a service, the router becomes an extension-owned HostService: videre registers it as the (renamed, un-privileged) VenueRegistry behind HostState.services[ns]. Forcing-function acceptance: deleting HostState.pool_router and still booting echo-venue proves L1 is intent-free. This issue covers the L1 half: making the core hold the router only through the generic services map and removing the named field. The VenueRegistry implementation itself is owned by the L2 extension.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "debt", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "HostState.pool_router field deleted; router carried only via HostState.services as a type-erased HostService (renamed VenueRegistry); no PoolRouter symbol in nexum-runtime/src; echo-venue still boots and routes a submit.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-generic-component-kind", + "title": "Extract a generic supervised-component/host-actor primitive from AdapterActor; collapse match kind (folds R8)", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1), s7.2, s6.2 D2 and venue-platform-architecture.md s6 R8. The supervisor hardcodes component kinds (enum ModuleKind { EventModule | VenueAdapter } with a match kind dispatch) and AdapterActor is a special-cased provider actor. Extract the generic supervised-component/host-actor primitive (fuel refuel, trap->error projection, async-mutex serialization, restart/poison-sweep membership) into nexum-runtime; collapse the match kind to a generic role loop; fold R8 so provider components join the restart/poison sweeps and expose adapters_alive/providers_alive.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "feature", + "component/lifecycle", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "Generic supervised-component/host-actor primitive extracted from AdapterActor into nexum-runtime (fuel/trap-projection/serialization/sweeps); supervisor match-kind collapsed to a generic role loop; provider components join restart/poison sweeps with a liveness query (R8 folded); no venue-named kind arm in the core.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-nexum-world-registry", + "title": "De-hardcode the KNOWN capability table (drop baked pool + cow-api rows); extract world synthesis to nexum-world lib", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1 step 3), s6.2 D3. The capability model and per-component world synthesis already shipped, but the KNOWN table still bakes a pool row (world.rs:74) and a cow-api -> shepherd:cow row (world.rs:80, an L1->L3 name leak). Delete the baked rows; source per-namespace rows from registered extensions; extract world.rs synthesis + the KNOWN table into a new plain lib nexum-world (L1); rewrite find_wit_root from workspace-ancestor-walk to crate-local wit/ + wit/deps resolution; keep #[module] in nexum-module-macros; leave venue/intent macros to videre-macros.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "debt", + "component/capabilities", + "component/sdk", + "effort/days" + ], + "acceptance": "Baked pool and cow-api/shepherd:cow rows removed from the KNOWN table; capability rows are registry-driven from registered extensions; world synthesis + table extracted to a plain nexum-world L1 lib; find_wit_root resolves crate-local wit/. No venue/cow string in nexum-world.", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-zero-leak-ci-gate", + "title": "CI gate: nexum-runtime has zero venue/intent/cow symbols", + "body": "See docs/design/videre-split-plan.md s2.2, s8 S1.3, s6.1 risk 3. Once the host is generic, the venue-agnostic invariant must be enforced permanently. Add a permanent CI check that fails if the host regains intent/venue/cow knowledge: rg 'nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' crates/nexum-runtime/src must return empty; cargo tree -p nexum-runtime must not reach any videre-*/intent/cow crate. Wire it as a required check; document the invariant in the crate charter.", + "kind": "chore", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "debt", + "component/engine-runtime", + "effort/hours" + ], + "acceptance": "CI has a required check that greps nexum-runtime/src for intent/venue/cow symbols and runs cargo tree -p nexum-runtime for forbidden crate edges, failing on any hit; green at the generalized tip; invariant documented in the crate charter.", + "depends_on": [ + "host-venue-registry-extract", + "host-nexum-world-registry", + "host-generic-component-kind" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-generic-launcher-bin", + "title": "Extract a generic launcher lib + bare Ext=() nexum engine bin (retire the backwards nexum-cli -> cow dep)", + "body": "See docs/design/videre-split-plan.md s2.2, s5 (Phase S1 step 4), s6.2 D7. The L1 charter calls for a generic launcher lib (nexum-launch) plus a bare Ext=() engine binary (nexum). Today the CLI composition root wires cow in directly (launch.rs:16,47 shepherd_cow_host::extension / with_extensions), making nexum-cli depend on shepherd-cow-host - a backwards L1->L3 crate edge. Extract nexum-launch; add a bare nexum bin with Ext=(); remove the cow wiring from the generic path; the cow composition root becomes a separate shepherd bin destined for L3.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "debt", + "component/sdk", + "effort/days" + ], + "acceptance": "Generic nexum-launch lib composes a runtime from a supplied extension list; a bare Ext=() nexum bin boots with zero cow/venue deps; no L1 crate depends on shepherd-cow-host; cow composition root moved out to a shepherd bin (L3).", + "depends_on": [ + "host-extension-seam-roles" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-wit-deps-flip-carve", + "title": "Flip nexum:host WIT to crate-local wit-deps and carve nexum-runtime as the L1 repo", + "body": "See docs/design/videre-split-plan.md s5 (Phase S2), s8 S2, s6.2 D8/D9/D10. After the host is proven venue-agnostic (zero-leak gate green), the L1 slice can be physically extracted. Introduce wit-deps (deps.toml); flip every bindgen! path list and the macro WIT-root off ../../wit/* to crate-local wit/ + wit/deps/ (S2a). History-preserving git-filter-repo --path extraction of nexum-runtime as the L1 repo, reusing the keeper-rename template (byte-identical tip oracle + jj/mergiraf). Adopt independent per-package semver for nexum:host (@0.1.x). Blocked on R6 + full S1 generalization landing first; the free-reshape window must be closed before the cut.", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M6: Second venue and vocabulary freeze", + "labels": [ + "debt", + "component/wit-abi", + "component/tools", + "effort/weeks", + "blocked" + ], + "acceptance": "nexum-runtime builds against crate-local wit/ + wit/deps with lockfiles checked in; history-preserving git-filter-repo carve yields a standalone L1 repo passing the byte-identical tip oracle; zero-leak gate green in the carved repo; nexum:host on independent semver.", + "depends_on": [ + "host-zero-leak-ci-gate", + "host-generic-launcher-bin" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-backend-guest-seams", + "title": "Add guest SDK seams + Mocks for identity/messaging/remote-store, wired to the stub backends", + "body": "See docs/design/venue-platform-architecture.md s4 gap #5, s6 R5 context, s7 Phase 4. Three of the six L1 host interfaces have no guest seam: identity, messaging, remote-store have adapter:None in the macro KNOWN table, no *Host trait, and no Mock*. ADR-0009's 'each interface becomes a trait + MockX' is unfulfilled for all three. Add IdentityHost/MessagingHost/RemoteStoreHost guest traits + Mock*; wire the bind-macro slices; widen the Host supertrait to all six (or opt-in subset supertraits). No change to backend liveness scope.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M4: SDK and DX", + "labels": [ + "dx", + "feature", + "component/sdk", + "effort/days" + ], + "acceptance": "IdentityHost/MessagingHost/RemoteStoreHost guest traits + Mock* exist and are recognised by the bind macros; wired to the stub backends; Host supertrait covers all six (or documented subset supertraits); modules host-free unit-test against all three.", + "depends_on": [], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "host-identity-signing-backend", + "title": "Wire the identity signing backend (accounts / sign / sign_typed_data) with the egress guard", + "body": "See docs/design/venue-platform-architecture.md s6 R3, s8 decision 7, s7 (Phase 3). The identity host interface is a 0.3 stub with an empty roster (accounts()->Ok(vec![])); the real value-movement signing path for requires-signing intents runs through it, currently unenforced and unimplemented. Per decision 7, identity signing lands with the egress guard (Phase 3). Implement the keystore-backed backend (accounts/sign/sign_typed_data); land it alongside the egress-guard epic so the guard checkpoint sits at the signed-tx boundary; realise or retract the chain-delegates-to-identity claim. Blocked on the egress-guard epic (M5).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "feature", + "security", + "component/identity", + "effort/days" + ], + "acceptance": "Keystore-backed identity backend implements accounts/sign/sign_typed_data with a non-empty roster; signing integrates with the egress-guard checkpoint at the requires-signing boundary; the doc-08 signing-delegation claim is realised or retracted.", + "depends_on": [ + "host-backend-guest-seams" + ], + "area": "nexum-runtime host layer (L1) - making it a generic, venue-agnostic component host" + }, + { + "key": "videre-epic", + "title": "Epic: videre - the generic intent-venue abstraction (L2)", + "kind": "epic", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "epic", + "component/wit-abi", + "component/sdk", + "feature", + "effort/weeks" + ], + "depends_on": [ + "host-r6-decouple", + "host-seam-generalize" + ], + "acceptance": "All child issues closed; the videre:* WIT DAG (value-flow <- types <- venue) builds acyclically over nexum:host; echo-venue + a keeper compile against videre-sdk alone; cargo tree -p nexum-runtime reaches no videre/intent crate (host-area gate).", + "body": "Epic tracking videre L2 (the venue-neutral intent-settlement + quoting abstraction). Today L2 lives under nexum:intent / nexum:value-flow / nexum:adapter WIT + nexum-venue-sdk / nexum-venue-test / nexum-macros. Per decision 8 the whole WIT set is pre-release cruft pinned only by echo-venue, so reshaping now costs an internal recompile + a train fold, never a wire break. Scope: rename to videre:*; pin the videre:* surface (types/venue/value-flow); add quote (client + adapter) + install-time body-versions handshake; build videre-sdk + #[videre::venue]/#[videre::keeper] + typed VenueClient; conformance kit; @0.1.0 normalization. Out of scope: maker-side offer (#355, post-0.1); 0.1 is EVM-only (decision 5). See docs/design/venue-platform-architecture.md s2, s6, s8; docs/design/videre-split-plan.md s2.3, s3, s7.4-s7.6, s8.", + "area": "VIDERE" + }, + { + "key": "videre-wit-rename", + "title": "videre: rename nexum:intent/* WIT + readability renames -> videre:*", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "component/wit-abi", + "breaking", + "debt", + "effort/days" + ], + "depends_on": [ + "host-r6-decouple" + ], + "acceptance": "No nexum:intent / nexum:value-flow / nexum:adapter package or use reference remains; the tree builds; echo-venue/echo-client pass against regenerated goldens; the byte-identical tip oracle holds across the train; nexum:host and shepherd:cow brands are untouched.", + "body": "One mechanical rename folded into the Phase-0 oracle-validated git-filter-repo/jj pass. WIT packages: nexum:intent -> videre:venue (+ videre:types); nexum:value-flow -> videre:value-flow; retire nexum:adapter (its venue-adapter world folds into videre:venue). Keep nexum:host and shepherd:cow. pool face splits into worker face videre:venue/client and provider face videre:venue/adapter. Rust symbols: PoolRouter->VenueRegistry, AdapterActor->VenueActor, GuardPolicy/AllowAllGuard->EgressGuard. Introduce VenueId newtype. Regenerate goldens; re-assert tip oracle. See videre-split-plan.md s3.1, s8 P0.2; venue-platform-architecture.md s5.2.", + "area": "VIDERE" + }, + { + "key": "videre-wit-surface", + "title": "videre: pin the videre:* WIT surface (types / venue / value-flow)", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "component/wit-abi", + "breaking", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-rename" + ], + "acceptance": "The three packages match the pinned shapes in videre-split-plan.md s7.4; the worker client face and provider adapter face mirror; venue-error carries rate-limited{retry-after-ms} + denied; value-flow uses named records (no anonymous ERC tuples); codec goldens carry a version discriminator + reject-unknown + a non-empty-vector assertion; echo-venue implements the pinned adapter face and passes.", + "body": "Pin the shape of the videre contract (the rename issue does the namespace move; both ride one Phase-0 fold). 0.1 is EVM-only (decision 5). Pin videre:types (intent-header, auth-scheme {eip1271,eip712}, settlement {chain:u64}, receipt=list, submit-outcome {accepted/requires-signing}, unsigned-tx, intent-status, venue-error with rate-limit); videre:venue (worker client + provider adapter mirror faces); videre:value-flow (asset-amount, asset {native, erc20}, named records - no anonymous tuples). Codec goldens: version discriminator + reject-unknown + non-empty assertion. Doc caveats: valid-until->valid-until-ms, denied() MUST-NOT-retry, derive-header purity, gives adapter-attested not host-verified. See videre-split-plan.md s7.4; venue-platform-architecture.md s6 R1/R3, s7 Phase 0, s8 decision 5.", + "area": "VIDERE" + }, + { + "key": "videre-wit-normalize", + "title": "videre: normalize all WIT packages to a single @0.1.0", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "component/wit-abi", + "debt", + "effort/hours" + ], + "depends_on": [ + "videre-wit-rename" + ], + "acceptance": "Every WIT package and every use ...@ reference reads @0.1.0; the docs/migration/0.1-to-0.2.md file and the 'Migration from 0.1' prose in docs/08 are deleted; the tree builds and the byte-identical tip oracle holds.", + "body": "The WIT package version strings (nexum:host@0.2.0, nexum:intent@0.1.0, shepherd:cow@0.2.0, ...) are pre-release cruft, not compatibility boundaries; no external consumer pins any (decision 8). Reset every package + use reference across nexum:host, videre:* (post-rename), shepherd:cow to @0.1.0 as one git-filter-repo/jj fold with the tip oracle. Delete migration cruft: docs/migration/0.1-to-0.2.md and the 'Migration from 0.1' prose in docs/08-platform-generalisation.md. After the cut, adopt independent per-package semver. See venue-platform-architecture.md s7 Phase 0, s8 decision 8; videre-split-plan.md s8 P0.3.", + "area": "VIDERE" + }, + { + "key": "videre-quote", + "title": "videre: add quote to videre:venue (client + adapter) + IntentClient typestate", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "component/wit-abi", + "component/sdk", + "breaking", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface" + ], + "acceptance": "videre:venue/client.quote(venue, body) and videre:venue/adapter.quote(body) exist and return a value-flow-typed quote; IntentClient.quote(&body)?.submit()? typestate compiles; echo-venue implements quote; the quote record is thin (gives/wants/fee/valid-until-ms) and EVM-only.", + "body": "The vision is settlement + quoting but the contract exposes only submit/status/cancel - quoting does not exist. Free to add now (decision 8), a wire break later, so it lands in the Phase-0 window. Add quote to both faces of videre:venue; define the quote record in videre:types thin and value-flow-typed ({gives,wants,fee,valid-until-ms}); SDK IntentClient.quote(&body)?.submit()? typestate. Firm/RFQ quotes and maker-side offers out of scope -> #355. See videre-split-plan.md s3.3, s7.4, s8 P0.4, D6; venue-platform-architecture.md s4 gap 1, s8 decision 5.", + "area": "VIDERE" + }, + { + "key": "videre-body-versions-handshake", + "title": "videre: install-time body-versions schema handshake", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "component/wit-abi", + "component/manifest", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface", + "host-seam-generalize" + ], + "acceptance": "videre:venue/adapter.body-versions() -> list exists; module and adapter manifests declare a supported body_version (or set); Supervisor::install refuses to boot a keeper/adapter pair whose versions do not intersect, failing fast with a logged error; a mismatched-pair test asserts the refusal.", + "body": "Bodies are opaque list with a guest-side borsh version tag; schema agreement is never a checked property (R7/B4). Decision 4: an install-time capability handshake, not WIT-freeze-gated. WIT: videre:venue/adapter.body-versions() -> list (reserved in the surface pin, wired here). Manifest: a body_version field in module and adapter module.toml. Enforcement: videre-host contributes an install predicate via the generalized Extension seam; Supervisor::install asserts intersection and refuses mismatched pairs. Depends on the host-area seam generalization for the install predicate. See venue-platform-architecture.md s6 R7, s8 decision 4; videre-split-plan.md s3.3.", + "area": "VIDERE" + }, + { + "key": "videre-sdk-crate", + "title": "videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M4: SDK and DX", + "labels": [ + "component/sdk", + "dx", + "feature", + "effort/days" + ], + "depends_on": [ + "videre-wit-surface", + "host-seam-generalize" + ], + "acceptance": "nexum-venue-sdk -> videre-sdk; the crate exports VenueAdapter, IntentBody, IntentClient

, VenueId, and a generic Keeper::sweep assembler over a Sweep outcome resolving the dangling ConditionalSource::Outcome; the world-neutral keeper primitives stay in nexum-sdk; a keeper compiles against videre-sdk alone with no cow/host dep.", + "body": "The venue-author SDK persona is shipped (nexum-venue-sdk/nexum-venue-test) but mis-named for the split and missing its assembler. Rename to videre-sdk; own VenueAdapter, IntentBody codec + BodyError, IntentClient

, VenueId. Add the generic Keeper::sweep assembler (WatchSet->Gates->source.poll->Retrier->Journal) + a shared Sweep outcome resolving ConditionalSource::Outcome. Keep world-neutral primitives (WatchSet/Gates/Journal/Retrier/ConditionalSource) in nexum-sdk (D4). DX polish: VenueFault with Display+IntoStaticStr; #[non_exhaustive]; seal extension traits. See videre-split-plan.md s2.3, D4, s8 S1; venue-platform-architecture.md s4 gap 6, s5.3.", + "area": "VIDERE" + }, + { + "key": "videre-venue-macro", + "title": "#[videre::venue]: single blessed authoring path emitting impl VenueAdapter", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M4: SDK and DX", + "labels": [ + "component/sdk", + "dx", + "effort/days" + ], + "depends_on": [ + "videre-sdk-crate" + ], + "acceptance": "#[videre::venue] emits impl VenueAdapter (not a raw Guest impl) + the videre:venue/adapter export + the manifest kind; export_venue_adapter! is demoted to the internal codegen the macro expands to (no public second path); import-narrowing is by construction (no dead-import elision); echo-venue uses the macro and the *_to_golden bridge boilerplate is gone.", + "body": "Two authoring paths fork the one clear arrangement (R4): #[venue] emits impl Guest over raw bindgen and bypasses the typed VenueAdapter trait, while export_venue_adapter! routes through it on a differently-named world importing chain+messaging unconditionally. Decision 6: #[videre::venue] is the single blessed path, fixed to emit impl VenueAdapter. Demote export_venue_adapter! to internal codegen; narrow imports by construction (synthesize_venue); kill the ~80-line *_to_golden bridges. Lives in videre-macros after the S1 macro split. See venue-platform-architecture.md s6 R4, s8 decision 6; videre-split-plan.md s3.2, s7.5, s8 S1.3.", + "area": "VIDERE" + }, + { + "key": "videre-keeper-macro", + "title": "#[videre::keeper] macro + typed VenueClient", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M4: SDK and DX", + "labels": [ + "component/sdk", + "dx", + "feature", + "effort/weeks" + ], + "depends_on": [ + "videre-venue-macro", + "videre-quote" + ], + "acceptance": "#[videre::keeper] emits a worker that drives a venue via a typed VenueClient (alloy-style, typed not list) wrapping videre:venue/client, wiring the event subs; a keeper written against VenueClient compiles and calls quote/submit/status/cancel with typed bodies; static-dispatch (native AFIT), zero boxing on the hot path.", + "body": "The venue author gets #[videre::venue]; the keeper author needs the mirror: a macro-driven, reth/alloy-grade way to drive a venue over videre:venue/client without hand-writing list marshalling. #[videre::keeper] - write logic against a typed VenueClient; the macro wires event subs and the videre:venue/client import. VenueClient - alloy-style typed wrapper (quote/submit/status/cancel), typed bodies via the venue's IntentBody, VenueId-keyed. MSRV 1.94: native AFIT for hot traits, zero boxing. Prove: a keeper drives echo-venue. See videre-split-plan.md s7.1, s7.3, s7.5, s8 S1b.3.", + "area": "VIDERE" + }, + { + "key": "videre-conformance-kit", + "title": "videre-test: conformance kit + cargo-test-fails-if-wire-drifts gate", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M4: SDK and DX", + "labels": [ + "component/sdk", + "dx", + "effort/days" + ], + "depends_on": [ + "videre-sdk-crate" + ], + "acceptance": "nexum-venue-test -> videre-test; the kit ships CodecVectors / HeaderGoldens / MockTransport; a venue's cargo test fails on any wire-shape drift; goldens carry the version discriminator + reject-unknown + a non-empty-vector assertion; goldens regenerate under the videre:* namespace and the tip oracle holds.", + "body": "The conformance kit (nexum-venue-test) is the cargo-test-fails-if-wire-drifts gate holding every venue to portable codec vectors + header goldens. Well-built but mis-named and its codec golden passes vacuously on an empty vector. Rename to videre-test; keep CodecVectors/HeaderGoldens/MockTransport; harden goldens (version discriminator + reject-unknown + non-empty assertion); regenerate under videre:* and re-assert tip oracle; align mock-grant fidelity to the host (the #297 divergence). See venue-platform-architecture.md s2, s4 gap 9, s6 R1; videre-split-plan.md s2.3, s8 S1.", + "area": "VIDERE" + }, + { + "key": "cow-onvidere-epic", + "title": "Epic: CoW-on-videre - the concrete reference venue", + "kind": "epic", + "phase": "S1b", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "epic", + "component/cow-integration" + ], + "depends_on": [ + "host-seam-generalize" + ], + "acceptance": "cow-venue cleaved into orderbook-only venue vs composable-cow keeper (CI-gated); a cow adapter cdylib exports videre:venue/adapter and settles real orders over wasi:http; CowApiHost/cow-api/cow-ext retired from the hot path; composable-cow and ethflow keepers run on videre:venue/client; the shepherd-cow event-ABI WITs are the sole L3-owned cow surface; no L1/L2 crate compiles any cow symbol.", + "body": "CoW is the flagship concrete venue proving videre L2 is genuinely venue-neutral. Today the CoW hot path bypasses the generic seam: shepherd-sdk/src/cow/run.rs submits via CowApiHost, never videre:venue. crates/cow-venue is a body-only [lib] mixing orderbook and composable concerns. Scope (child issues): cleave cow-venue; build the cow adapter cdylib; settle the idempotency seam; port composable-cow + ethflow keepers onto videre:venue/client; retire CowApiHost/cow-api/cow-ext; own the shepherd-cow event-ABI WITs at L3; (deferred, fork-gated) the poll wire-swap deleting composable.rs. All depends on the generalized runtime seam. See videre-split-plan.md s4, s7.6, s8 S1b; venue-platform-architecture.md s6 R2.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "cleave-cow-venue", + "title": "Cleave cow-venue: orderbook-only venue vs composable-cow keeper", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "feature", + "debt", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic" + ], + "acceptance": "crates/cow-venue contains only orderbook concerns (OrderBody, borsh codec, classification.toml/rs, orderbook client); ComposableBody/composable.rs/getTradeableOrderWithSignature/COMPOSABLE_COW/ConditionalOrderCreated/revert-selector/LegacyRevertAdapter/Verdict live in a separate composable-cow keeper crate/module; a CI gate asserts the venue crate has zero Composable*/getTradeableOrder/revert-selector symbols; a new CoW keeper producing OrderBodys can be written without importing composable machinery; workspace green; goldens regenerated.", + "body": "crates/cow-venue mixes both sides: lib.rs re-exports OrderBody (order.rs) and ComposableBody (composable.rs). The load-bearing rule (s7.6): the cow venue is only the CoW orderbook - submit/quote/status/cancel of an OrderBody on api.cow.fi, mapping orderbook errors to venue-error, never heard of ComposableCoW/getTradeableOrderWithSignature/revert selectors/TWAP/EthFlow. Split into (a) venue: orderbook + OrderBody + classification; (b) composable-cow keeper: ComposableBody, COMPOSABLE_COW addr + topic-0, getTradeableOrderWithSignature, revert-selector + LegacyRevertAdapter + Verdict (ADR-0013). Drop Composable variant from the venue body. Add CI gate. Pure-Rust re-split, zero contract dependency, lands now (s4.4). See videre-split-plan.md s7.6, s4.1-s4.3; ADR-0013.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "cow-idempotency-seam", + "title": "Settle the CoW idempotency seam before order assembly moves into the adapter", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "feature", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue" + ], + "acceptance": "the keeper can derive/obtain a deterministic intent-id pre-submit without assembling OrderCreation itself; chosen mechanism implemented (adapter.derive-header returns a deterministic intent-id the keeper journals, or SubmitOutcome carries receipt = UID); the submitted: Journal check remains effective across the keeper->adapter boundary (no double-post window); a regression test exercises resubmit-after-restart and asserts a single orderbook POST.", + "body": "shepherd-sdk/src/cow/run.rs today derives the client-side order UID and checks the submitted: Journal before the network call. Once OrderCreation/UID assembly moves into the adapter's submit, the keeper can no longer derive the UID pre-submit - a double-post risk (s4.3, s6.1 risk 6). Settle this before assembly moves. Pick one: adapter.derive-header returns a deterministic intent-id the keeper journals; or SubmitOutcome carries receipt = UID. Re-route the Journal idempotency check onto that identifier. See videre-split-plan.md s4.3, s6.1 risk 6.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "cow-venue-cdylib", + "title": "Build the cow adapter cdylib (#[videre::venue] over wasi:http)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "feature", + "component/cow-integration", + "component/wit-abi" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue", + "cow-idempotency-seam", + "host-seam-generalize" + ], + "acceptance": "crates/cow-venue builds as crate-type=[cdylib] and exports videre:venue/adapter; #[videre::venue] impl Venue for CowVenue implements derive-header/quote/submit/status/cancel + body-versions; declared capabilities are [chain, http] (transport-only), no host cow interface imported; build_order_creation/order_uid_hex/gpv2_to_order_data move into the adapter's submit; classification.toml moves into the adapter and projects orderbook errorType->venue-error; the adapter POSTs OrderCreation over wasi:http; videre-test golden vectors pass; the venue installs as a provider through the generalized seam and settles an order end-to-end.", + "body": "The cow venue becomes a real cdylib targeting videre:venue/adapter, replacing the body-only [lib]. Per s4.1, cow enters as an adapter cdylib on the venue-adapter world - reaching the orderbook as opaque bytes over wasi:http + nexum:host/chain, needing no separate composable-cow world (the R2 category error). Grow crates/cow-venue to a cdylib exporting videre:venue/adapter via #[videre::venue]; move order assembly + classification.toml into submit; capabilities [chain, http] only; wire body-versions. Depends on the idempotency seam and the generalized runtime seam. See videre-split-plan.md s4.1-s4.3, s7.5-s7.6; venue-platform-architecture.md s6 R2, s5.4.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "shepherd-cow-event-abi-wits", + "title": "Own the shepherd-cow event-ABI WITs at L3", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "feature", + "component/wit-abi", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic" + ], + "acceptance": "the shepherd-cow event-ABI WITs (ConditionalOrderCreated, EthFlow.OrderPlacement, and any other cow on-chain event surfaces the keepers decode) live under wit/shepherd-cow and are consumed only by L3 crates; no videre:* or nexum:host package uses shepherd:cow; the composable-cow and ethflow keepers resolve their event ABIs from this package; the legacy host-ext surface in shepherd:cow is clearly marked as retiring.", + "body": "All cow protocol knowledge, including on-chain event ABIs the keepers watch, belongs to the CoW-on-videre L3 repo and must never appear in L1/L2 (s2.4, s7.6). The shepherd-cow WIT package holds the event-ABI surfaces (ConditionalOrderCreated topic-0; EthFlow.OrderPlacement) plus the legacy host-ext surface that is retiring. Consolidate under wit/shepherd-cow as the sole L3-owned cow WIT surface; ensure keepers resolve ABIs from here; mark the legacy host-extension interface retiring (deleted at the fork-gated poll wire-swap). See videre-split-plan.md s2.4, s7.6, s8 (S1b).", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "composable-cow-keeper-port", + "title": "Port the composable-cow keeper onto videre:venue/client (ADR-0013 Verdict)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "feature", + "component/modules", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cleave-cow-venue", + "cow-venue-cdylib", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "videre-keeper-macro" + ], + "acceptance": "the composable-cow keeper is a strategy event-module targeting nexum:host/event-module and imports videre:venue/client (not CowApiHost); it watches ConditionalOrderCreated, polls getTradeableOrderWithSignature, maps the ADR-0013 Verdict to a Sweep outcome, emitting a plain OrderBody/CowIntentBody via client.submit(CowVenue::ID, bytes); run.rs:~138 no longer calls host.submit_order(...)/CowApiHost; authored with #[videre::keeper] against a typed VenueClient; the coarse venue-error retry hint survives classification; dev/m1 green with the keeper driving the cdylib adapter end-to-end (excluding the fork-gated poll wire-swap).", + "body": "The composable-cow keeper's job (s4.1-s4.3, s7.6): watch conditional orders, poll them, produce a plain OrderBody, submit through videre:venue/client - driving the cow venue with opaque bodies, never importing the venue's world. The concrete embodiment of gap #2: flip the legacy CowApiHost submit onto the generic seam. The Rust seam re-split has zero contract dependency and lands now (s4.4); only the poll wire-swap deleting composable.rs/LegacyRevertAdapter is fork-gated and tracked separately. Keep the poll/decide/classify logic (Verdict->Sweep); route submission through videre:venue/client; author with #[videre::keeper]. See videre-split-plan.md s4.1-s4.4, s7.6, s8 S1b.3; ADR-0013.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "ethflow-keeper", + "title": "Ethflow keeper: observe indexed orders via cow.status", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "feature", + "component/modules", + "component/cow-integration" + ], + "depends_on": [ + "cow-onvidere-epic", + "cow-venue-cdylib", + "shepherd-cow-event-abi-wits", + "composable-cow-keeper-port" + ], + "acceptance": "the ethflow keeper is a second worker on the same shared cow venue - it does not submit; it watches EthFlow.OrderPlacement (EthFlow consts live in the keeper), computes the order UID, and calls client.status(CowVenue::ID, uid) to verify the orderbook indexed the on-chain EthFlow order; no ethflow specifics leak into the cow venue; authored with #[videre::keeper] against the typed VenueClient; a test drives the observe/verify path against orderbook-mock.", + "body": "Ethflow falls out for free as a second keeper on the same shared venue (s7.6): it doesn't submit, it statuses a computed UID to verify the orderbook indexed the on-chain EthFlow order. Same venue, different verb - proves the shared-venue platform model (one connection, one quota, many keepers). A worker that watches EthFlow.OrderPlacement, holds the EthFlow contract consts, computes the UID, observes via client.status. Reuses the typed VenueClient and the shepherd-cow event-ABI WITs. See videre-split-plan.md s7.6, s7.1, s8 (S1b.3).", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "cow-api-retire", + "title": "Retire CowApiHost / cow-api / cow-ext (the biggest lever)", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "debt", + "component/cow-integration", + "breaking" + ], + "depends_on": [ + "cow-onvidere-epic", + "cow-venue-cdylib", + "composable-cow-keeper-port" + ], + "acceptance": "no keeper or module submits via CowApiHost/host.submit_order(...); the hot path runs entirely through videre:venue/client; the CowApiHost trait and the cow-api/cow-ext host-extension surfaces are removed from the default build (or reduced to a deprecated read-only shim); the KNOWN capability table no longer bakes a cow-api->shepherd:cow row (registry-driven); docs/08 no longer documents the host-extension submit model as the live path; workspace green with the extension gone from the hot path.", + "body": "Retiring CowApiHost/cow-api/cow-ext is the design doc's explicitly-named biggest lever (s7.6, s4.2). The live CoW submit path today bypasses the generic seam: module -> shepherd:cow/cow-api host extension -> cowprotocol, assembling OrderCreation on the strategy side. Once the cdylib adapter exists and the composable-cow keeper submits through videre:venue/client, the legacy extension is dead weight and must retire. Delete the CowApiHost submit trait and the cow-api/cow-ext host extension from the hot path (retain a deprecated read-only shim only if the legacy read path still needs it); remove the baked cow-api->shepherd:cow KNOWN row; update docs/08. See videre-split-plan.md s4.2, s7.6, s8 S1b.2; venue-platform-architecture.md s3.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "composable-poll-wire-swap", + "title": "Composable-cow poll wire-swap: delete composable.rs / LegacyRevertAdapter (fork-gated)", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "debt", + "component/modules", + "component/cow-integration", + "blocked", + "needs-design" + ], + "depends_on": [ + "cow-onvidere-epic", + "composable-cow-keeper-port" + ], + "acceptance": "gated: proceeds only once the fork's deployments/networks.json is non-empty on a shepherd target chain; composable.rs/LegacyRevertAdapter deleted; Verdict::Post is fully populated by the structured non-reverting poll; the Verdict::NeedsInput arm is wired and dispatch-tested; Verdict::Post.next_poll_timestamp modelled as Option/NextPoll (not the 0 sentinel that collides with the fork wire); the legacy host-ext surface in wit/shepherd-cow deleted.", + "body": "ADR-0013's structured non-reverting poll cannot be instantiated until the ComposableCoW fork is deployed: Verdict::Post is the one variant LegacyRevertAdapter never produces (composable.rs), and Verdict::NeedsInput is dead surface until IOrderModule/the fork lands. Per s4.4, this poll wire-swap is hard-blocked on the fork's deployments/networks.json being non-empty - and must NOT be coupled to the keeper port, or the whole CoW-on-videre split freezes behind a third-party deployment clock. Delete composable.rs/LegacyRevertAdapter and switch the poll onto the fork's structured non-reverting getTradeableOrderWithSignature; fully populate Verdict::Post; wire/test NeedsInput; replace the 0-sentinel next_poll_timestamp; delete the legacy host-ext surface. See videre-split-plan.md s4.4, s8 (deferred); venue-platform-architecture.md s6 R2, s7 Phase 1-Wave-1; ADR-0013.", + "area": "CoW-on-videre (the concrete reference venue)" + }, + { + "key": "split-epic-p0", + "title": "Epic (P0): free monorepo reshape - land the master gate that makes an acyclic split possible", + "kind": "epic", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "epic", + "component/wit-abi", + "effort/weeks" + ], + "depends_on": [], + "body": "P0 is the free reshape phase: while everything lives in one repo and no external consumer pins any WIT package, every contract change is a recompile, not a wire break (decision 8). The master gate for the whole split is the R6 host<->intent WIT decouple: wit/nexum-host/types.wit does use nexum:intent/types@0.1.0.{receipt, intent-status}, so the L1 host world imports L2. Until that use is gone, nexum-runtime cannot compile without videre's WIT and an acyclic split is physically impossible. This epic owns the split-enabling outcome of P0: the acyclicity invariant made visible and CI-verifiable. See videre-split-plan.md s8 (P0), s5 Phase 0; venue-platform-architecture.md s8 decisions 2 and 8.", + "acceptance": "R6 decouple landed; cargo tree -p nexum-runtime reaches no intent/cow crate; WIT DAG builds acyclically in one repo; all WIT normalized to @0.1.0.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "p0-acyclicity-scaffold", + "title": "Land the acyclicity / zero-leak CI gate for nexum-runtime (advisory in P0, blocking in S1)", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "component/tools", + "component/wit-abi", + "effort/hours" + ], + "depends_on": [], + "body": "The entire split rests on one invariant: nexum-runtime (L1) knows nothing about intents, venues, or CoW. The design makes deleting the HostState.pool_router field the forcing-function acceptance test for that invariant. We need that invariant continuously checkable long before the physical cut. Add a CI job (and a just/cargo xtask entrypoint) that asserts L1 is venue-agnostic: cargo tree -p nexum-runtime must not reach videre-*/intent/cow crates; rg symbol scan must return empty; assert the WIT DAG resolves with nexum:host as a leaf. Land it advisory (non-blocking) in P0, then promote to a blocking gate in S1. See videre-split-plan.md s1, s5 Phase S1, s2.2; venue-platform-architecture.md s6 R6.", + "acceptance": "CI + local command assert cargo tree and rg symbol checks on nexum-runtime and the leaf-ness of nexum:host; wired advisory in P0 with a tracked flip-to-blocking at S1.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "split-epic-s1", + "title": "Epic (S1): make nexum-runtime venue-agnostic - generalize the Extension seam (the long pole)", + "kind": "epic", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "epic", + "component/engine-runtime", + "component/engine-supervisor", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "split-epic-p0" + ], + "body": "S1 is the only non-free work in the plan and the true long pole: making nexum-runtime venue-agnostic is runtime surgery with no prior ADR. Today the runtime hardcodes venue knowledge in two places - ModuleKind is a fixed EventModule | VenueAdapter enum, and PoolRouter is a privileged field (HostState.pool_router). The pinned fix (s7.2): the runtime knows only two generic roles - worker and provider. Extension grows from {link, capabilities} to {link, capabilities, service, provider}, adding HostService (the ex-PoolRouter, now VenueRegistry) and ProviderKind (the ex-AdapterActor lifecycle). videre becomes one impl Extension. This epic's split-owned deliverable is the runtime-venue-agnostic gate. See videre-split-plan.md s5 Phase S1, s7.2, s6 D1/D2; venue-platform-architecture.md s6 R8.", + "acceptance": "Extension hosts worker+provider roles; pool_router field deleted; zero-leak gate blocking and green; echo-venue boots through the generalized seam with no cow code in L1.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s1-gate-runtime-venue-agnostic", + "title": "GATE (a): prove nexum-runtime is venue-agnostic - flip the zero-leak CI check to blocking", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M0: Runtime architecture and lifecycle", + "labels": [ + "component/engine-runtime", + "component/engine-supervisor", + "component/tools", + "effort/days" + ], + "depends_on": [ + "p0-acyclicity-scaffold" + ], + "body": "First of the three cut gates (s8 Phase S2 gate (a); s1 go/no-go). Cutting repos before L1 is proven venue-agnostic freezes an intent-shaped host into a repo boundary. The forcing function: deleting the HostState.pool_router field (state.rs:54) and carrying the router in a composite Ext lattice. Depends on the seam-generalization implementation landing. Promote the P0 acyclicity/zero-leak CI check from advisory to blocking; add an echo-venue boot integration test as the oracle. See videre-split-plan.md s1, s5 Phase S1, s8 Phase S2 gate (a), s2.2.", + "acceptance": "pool_router field deleted; zero-leak CI check blocking + green on nexum-runtime; echo-venue boots through the generalized seam with no intent/cow crate in the graph.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "split-epic-s1b", + "title": "Epic (S1b): CoW on the generic seam - real adapter cdylib + keeper on videre:venue/client", + "kind": "epic", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "epic", + "component/cow-integration", + "component/sdk", + "effort/weeks" + ], + "depends_on": [ + "split-epic-s1" + ], + "body": "With L1 venue-agnostic (S1), S1b proves the generic seam carries a real venue, not just the echo-venue toy. Today the live CoW path bypasses the generic seam - shepherd-sdk/src/cow/run.rs:138 submits via CowApiHost, never pool - which is R1 unretired. The cleave rule (s7.6, load-bearing): the cow venue is only the CoW orderbook. All composable-cow specifics live in the composable-cow keeper and leak nowhere else. This epic's split-owned deliverable is the cow-on-generic-seam gate. See videre-split-plan.md s4, s7.6, s8 Phase S1b; venue-platform-architecture.md s6 R1/R2.", + "acceptance": "cow-venue cleaved (venue = orderbook only, CI-gated clean); real cow adapter cdylib on videre:adapter; keeper ported onto videre:venue/client; CowApiHost retired; idempotency seam settled.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s1b-gate-cow-on-generic-seam", + "title": "GATE (b): CoW rides the generic seam - keeper on videre:venue/client, CowApiHost retired", + "kind": "task", + "phase": "S1b", + "milestone_suggestion": "M2: Concrete CoW modules", + "labels": [ + "component/cow-integration", + "component/sdk", + "effort/days" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic" + ], + "body": "Second of the three cut gates (s8 Phase S2 gate (b); s1 go/no-go). The split must not codify the generic L2 contract into a repo boundary while the flagship CoW venue still bypasses it. The concrete embodiment: flip run.rs:138 from CowApiHost::submit_order(...) to pool.submit(CowVenue::ID, cow_body_bytes), retiring the CowApiHost trait and the cow-api host extension. Note the fork-gate boundary (s4.4): the Rust seam re-split lands now; only the poll wire-swap is fork-blocked. Do NOT couple them. This gate covers the seam port, not the fork-gated poll swap. See videre-split-plan.md s4.2, s4.3, s4.4, s7.6, s8 Phase S1b/S2 gate (b).", + "acceptance": "cow adapter cdylib on videre:adapter; keeper submits through videre:venue/client not CowApiHost; venue-crate symbol gate green; idempotency seam in place; seam port not coupled to the fork-gated poll swap.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "split-epic-s2", + "title": "Epic (S2): the gated repo cut - transitional workspace, WIT plumbing, three history-preserving carves", + "kind": "epic", + "phase": "S2", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "epic", + "component/tools", + "component/wit-abi", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "split-epic-s1b" + ], + "body": "S2 is the physical split, and it is gated: no carve until all three gates hold - (a) nexum-runtime venue-agnostic, (b) CoW on the generic seam with a real adapter, (c) a genuine second-protocol venue compiles against videre-sdk alone. Cutting earlier freezes an unproven contract into a repo boundary. The end state is three repos with one acyclic edge each: nexum-runtime <- videre <- CoW-on-videre. The cut proceeds reshape-then-extract: flip WIT and Rust resolution to crate-local while still one repo (S2a), then three git-filter-repo --path extractions preserving history (S2b), held together by a transitional umbrella superproject with path-deps during stabilization. See videre-split-plan.md s2.1, s5 Phase S2, s6 D8/D9/D10, s8 Phase S2.", + "acceptance": "All three gates green pre-carve; transitional workspace + WIT plumbing landed; three history-preserving carves produce building repos with tip oracle passing and the acyclic DAG intact.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s2-transitional-workspace", + "title": "Transitional path-dep cargo workspace in the 3 groupings (+ git-tag pin path + dep-sync CI)", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "component/tools", + "effort/days" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic", + "s1b-gate-cow-on-generic-seam" + ], + "body": "The split must not lose the single hoisted dependency table, the shared Cargo.lock, or atomic folds (s6 R7/D10). The mitigation is a transitional umbrella superproject with path-deps through S1-S3, converging to git-tag pins then crates.io. Reorganize the monorepo crates into the three prospective groupings as workspace members with intra-grouping path-deps; define the post-carve cross-repo Rust dep medium (git-tag pins first, path to crates.io); add a dep-sync CI check. See videre-split-plan.md s2.1, s5 Phase S2b, s6 R7/D9/D10.", + "acceptance": "Three-grouping path-dep workspace builds with the acyclic crate DAG verified; cross-repo dep medium documented (git-tag -> crates.io); dep-sync CI check green and enforcing.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s2-wit-cross-repo-consumption", + "title": "WIT cross-repo consumption: wit-deps flip + git-tag sourcing + wkg/OCI registry convergence", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "component/wit-abi", + "component/tools", + "effort/days" + ], + "depends_on": [ + "s2-transitional-workspace" + ], + "body": "Today every bindgen! and the venue macro resolve WIT via a workspace-ancestor walk into a shared ../../wit/* tree. After the carve each repo must resolve its own WIT plus cross-repo deps, with the DAG nexum:host (leaf) <- videre:value-flow <- videre:intent/videre:venue <- videre:adapter <- shepherd:cow. S2a (one repo): introduce wit-deps (deps.toml) per prospective repo; flip every bindgen! path list and the macro WIT-root; rewrite find_wit_root (lib.rs:512). S2b (cross-repo): source cross-package WIT from git tags; check in lockfiles. Convergence: document the move to wkg/OCI + per-package semver. See videre-split-plan.md s2.1, s3.4, s5 Phase S2, s6 R4/D9.", + "acceptance": "All bindgen + macro WIT resolution is crate-local (wit/ + wit/deps/); cross-repo WIT sourced from pinned git tags with lockfiles; acyclic DAG builds in-repo and cross-repo; registry + semver policy documented.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s2-cut-gate-checklist", + "title": "Cut go/no-go gate: assert (a) runtime venue-agnostic + (b) cow on the seam + (c) second-protocol venue before any carve", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "component/tools", + "effort/hours" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic", + "s1b-gate-cow-on-generic-seam", + "s3-gate-second-protocol-venue" + ], + "body": "The physical repo cut is explicitly gated: refactor now, cut later, and cut only when all three gates hold (s1 go/no-go; s6 D8; s8 Phase S2 gate). A single tracking/checklist issue (and a short pre-carve runbook) that must be closed before the three-carves issue may start: (a) nexum-runtime venue-agnostic - S1 zero-leak gate blocking+green, pool_router deleted; (b) real cow-venue cdylib + keeper ported off CowApiHost; (c) a genuine second-protocol venue compiles against videre-sdk alone; confirm all Phase-0 WIT reshapes complete. See videre-split-plan.md s1, s6 D8, s8 Phase S2 gate.", + "acceptance": "Gates (a) runtime venue-agnostic, (b) cow on the generic seam, (c) genuine second-protocol venue all closed green; Phase-0 reshape confirmed complete; pre-carve runbook signed off.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s2-three-carves", + "title": "Three history-preserving git-filter-repo carves: nexum-runtime / videre / CoW-on-videre", + "kind": "task", + "phase": "S2", + "milestone_suggestion": "M3: Infrastructure", + "labels": [ + "component/tools", + "breaking", + "effort/weeks" + ], + "depends_on": [ + "s2-cut-gate-checklist", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption" + ], + "body": "The physical cut: three git-filter-repo --path extractions preserving history, one per repo, executed as a single coordinated operation once the cut gate is green (s5 Phase S2b; s2.1). Reuse the keeper-rename template: range-limited git-filter-repo + a byte-identical tip oracle + jj/mergiraf, per repo. nexum-runtime (L1): crates/nexum-runtime, nexum-sdk, nexum-sdk-test, nexum-world, nexum-module-macros, nexum-launch, the bare nexum bin, wit/nexum-host. videre (L2): videre-sdk, videre-test, videre-macros, videre-host, wit/videre-*, echo-venue/echo-client. CoW-on-videre (L3): cow-venue, shepherd-sdk, shepherd-cow-host, shepherd-sdk-test, shepherd-backtest, the shepherd bin, wit/shepherd-cow. Wire cross-repo Rust via git-tag pins and WIT via wit-deps git tags. See videre-split-plan.md s2.1, s5 Phase S2b, s6 D9/D10.", + "acceptance": "Three history-preserving repos carved (byte-identical tip oracle per repo, full history preserved); each builds standalone on pinned cross-repo deps; acyclic DAG holds; L1 zero-leak gate green in nexum-runtime repo.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "split-epic-s3", + "title": "Epic (S3): second-venue acceptance - prove videre is genuinely venue-neutral", + "kind": "epic", + "phase": "S3", + "milestone_suggestion": "M6: Second venue and vocabulary freeze", + "labels": [ + "epic", + "component/sdk", + "component/modules", + "effort/weeks" + ], + "depends_on": [ + "split-epic-s1" + ], + "body": "S3 is the acceptance phase for calling the split done: build the first real non-cow venue (rfq or amm-router) against videre-sdk alone (s5 Phase S3; s8 Phase S3; s6 R3/D8). echo-venue is a toy and cannot prove venue-neutrality; the live CoW path bypasses videre; and 0.1 is scoped EVM-only. The second venue is also cut gate (c). This epic groups the second-protocol venue acceptance as the split's proof of venue-neutrality and the vocabulary freeze that lands with it (milestone M6). See videre-split-plan.md s5 Phase S3, s8 Phase S3, s6 R1/R3/D8; venue-platform-architecture.md s8 decision 5.", + "acceptance": "A genuine non-cow second-protocol venue merged, built against videre-sdk/videre-test alone; videre proven venue-neutral by two dissimilar real venues; vocabulary freeze applied.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "s3-gate-second-protocol-venue", + "title": "GATE (c): build a genuine second-protocol venue (rfq or amm-router) against videre-sdk alone", + "kind": "task", + "phase": "S3", + "milestone_suggestion": "M6: Second venue and vocabulary freeze", + "labels": [ + "component/sdk", + "component/modules", + "feature", + "needs-design", + "effort/weeks" + ], + "depends_on": [ + "s1-gate-runtime-venue-agnostic" + ], + "body": "Third and hardest cut gate (s8 Phase S2 gate (c); s5 Phase S3; s6 R1). It de-risks R1: the generic videre:* contract is today exercised only by echo-venue and bypassed by the live CoW path, and 0.1 is EVM-only. Only a genuine, dissimilar second venue can prove venue-neutrality. A second cow keeper does not count - this must be a second protocol. Pick rfq (firm-quote) or amm-router; author with the blessed path only (#[videre::venue] impl Venue, #[derive(IntentBody)], caps transport-only, no host code, no runtime dep, no cow knowledge); run against the videre-test golden kit; feed shape mismatches back as videre:* corrections before the cut. See videre-split-plan.md s2.5, s5 Phase S3, s6 R1/D8, s8 Phase S3; venue-platform-architecture.md s6 R1, s8 decision 5.", + "acceptance": "A genuine second-protocol venue (rfq or amm-router) compiles + passes videre-test against videre-sdk alone with no runtime/cow deps; exercises quote and other non-cow surfaces; resulting videre:* fixes landed pre-cut; gate (c) green.", + "area": "Three-repo split + sequencing (nexum-runtime / videre / CoW-on-videre)" + }, + { + "key": "egress-guard-hardening-epic", + "title": "epic: egress-guard hardening - real non-AllowAll guard, single-decode, signing-boundary, capability/lifecycle teeth (R3/R5/R8)", + "body": "The router's entire derive -> guard -> submit shape is justified by an egress checkpoint that today does not exist. Only AllowAllGuard ships (pool_router.rs:104-110), a no-op. It inspects the adapter's own derive-header output while submit re-decodes the body independently (TOCTOU), and does not cover the requires-signing signing path at all. Per the 2026-07-14 decisions, M1 ships no guard teeth (advisory-only). This epic owns the real guard's trust model and location plus surrounding capability/lifecycle hardening (R5/R8). This is the design-doc hardening addendum to the existing M5 guard epic #139; #139 builds the guard engine, this epic captures the router/capability/lifecycle fixes the venue-platform review surfaced. Reconcile by folding these as sub-issues of #139 or keeping this as a paired sub-epic. See venue-platform-architecture.md s3, s4 gap #3, s6 R3/R5/R8, s7 Phase 2, s8 decisions 1/3/4/7; videre-split-plan.md s3, s5 Phase 2, s7.2.", + "kind": "epic", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "epic", + "security", + "component/engine-supervisor", + "needs-design", + "effort/weeks" + ], + "acceptance": "A real EgressGuard runs at the signed-tx boundary with single-decode, teeth on the requires-signing path, adapters under the supervisor sweeps, and http/messaging egress enforced like chain/local-store; children below all closed.", + "depends_on": [ + "#139", + "#52" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-advisory-m1", + "title": "guard: ship advisory-only posture for M1 (keep AllowAll, feature-gate the pool import, document the checkpoint as not-yet-enforcing)", + "body": "The egress guard is AllowAllGuard, a no-op (pool_router.rs:104-110), and the real guard is deferred wholly to the egress-guard epic. M1 must not advertise a boundary it does not enforce. Decision 3 (2026-07-14): M1 is advisory-only. Keep AllowAllGuard as the default; feature-gate the nexum:intent/pool import so the advertised derive->guard->submit checkpoint is not shipped as enforcing in the default build; document at the router seam and in venue docs that the checkpoint is advisory-only / not yet enforcing, with a forward pointer to the egress-guard epic. See venue-platform-architecture.md s6 R3, s8 decision 3; videre-split-plan.md s3 (dec-3), s5 Phase 2.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "M1: Intent core and CoW venue adapter", + "labels": [ + "security", + "docs", + "component/engine-supervisor", + "effort/hours" + ], + "acceptance": "AllowAll kept as default; pool import feature-gated; router-seam + venue docs mark the checkpoint advisory-only for M1 with a link to the guard epic.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-derive-before-guard", + "title": "guard: close the derive-header-before-guard side-effect escape and the TOCTOU double-decode (single-decode the body through the checkpoint)", + "body": "Two coupled R3 defects in pool_router.rs: (1) Side-effect escape - the router runs the adapter's derive-header before guard.check, so any side effect in derivation escapes policy; the honest fix is a guarded sub-world or moving derivation behind the checkpoint. (2) TOCTOU double-decode - the guard inspects the adapter's own derive-header output while submit re-decodes the body independently, so a buggy/hostile adapter can show a benign gives and settle something else; the derived header must be passed into submit for a single decode. (The WIT hygiene change - softening host-verified gives to adapter-attested - rides the Phase-0 fold, tracked in the WIT area.) See venue-platform-architecture.md s6 R3; videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "security", + "component/engine-supervisor", + "component/wit-abi", + "effort/days" + ], + "acceptance": "Derivation cannot side-effect before guard.check; the body is decoded once and submit consumes the guard-vetted header; a divergent-re-decode test proves no bypass.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-signing-boundary", + "title": "guard: move the checkpoint to the signed unsigned-tx / identity boundary so the requires-signing path is covered", + "body": "The guard does not cover the requires-signing class at all. For that class the real value movement is the unsigned-tx calldata returned by submit and signed on the identity path - which is a 0.3 stub today (accounts() -> Ok(vec![])). Decision 7 (2026-07-14): identity signing lands with the guard (later). Add the guard checkpoint at the signed unsigned-tx / identity boundary; wire to the real identity backend (#52); coordinate with #139's identity-boundary checkpoint child so there is one checkpoint, not two. See venue-platform-architecture.md s6 R3, s8 decision 7; videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "security", + "component/identity", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "requires-signing submits are gated at the decoded unsigned-tx boundary against a real identity backend; single checkpoint shared with #139.", + "depends_on": [ + "egress-guard-hardening-epic", + "#52", + "#139" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-deny-quota", + "title": "guard: charge quota on guard-deny to close the busy-loop DoS", + "body": "When the guard denies a submission, the router does not charge the caller's quota, so a module can retry a denied submission in a tight loop for free - a DoS against the guard/router. Latent today because only AllowAllGuard ships, but cheap to fix now and required the moment a real guard denies. On a guard-deny verdict, charge the caller's rate/quota exactly as an accepted submit would, before returning the denial; add a test that a repeated denied submit exhausts quota rather than looping for free. See venue-platform-architecture.md s7 triage (guard-deny no quota / DoS #250); videre-split-plan.md s5 Phase 1.", + "kind": "bug", + "phase": "P0", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "security", + "debt", + "component/engine-supervisor", + "effort/minutes" + ], + "acceptance": "Guard-deny charges quota; a repeated-deny loop is rate-limited; regression test present.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-policy-async", + "title": "guard: make GuardPolicy::check async (the real guard needs I/O: simulate, remote analyzers)", + "body": "GuardPolicy::check is synchronous. The real guard performs I/O - simulate over provider-pool state, fact assembly, possibly a remote analyzer/policy backend - none of which a sync trait can express without blocking the supervisor. Convert GuardPolicy::check (and the guard seam it fronts) to async; apply the s7.3 async strategy (native AFIT for static-dispatch guest traits; async_trait only for cold dyn boot paths; keep dyn-required guard/service traits object-safe); update AllowAllGuard and all call sites. See venue-platform-architecture.md s7 triage (GuardPolicy::check sync #250); videre-split-plan.md s5 Phase 2, s7.3.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "breaking", + "debt", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "GuardPolicy::check is async and awaited without blocking the loop; async-dispatch split matches s7.3; green on MSRV 1.94.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "guard-egress-cap-world-guarantee", + "title": "guard: bring venue egress capabilities (http + import-narrowing) under the compile-time world guarantee", + "body": "Two coupled R5/adapter-contract gaps let egress escape the capability model: (1) http escapes the compile-time guarantee (R5) - in the KNOWN table http has import: None (world.rs:88-92); wasi:http is linked out-of-band and gated only by the engine.toml allowlist, so the undeclared-cap-is-a-compile-error guarantee covers chain/messaging/logging but not http. (2) Blanket shims / two adapter contracts (#296) - export_venue_adapter! imports chain+messaging unconditionally and leans on wasm-tools dead-import elision, while synthesize_venue narrows by construction. Bring http under the synthesised-world guarantee (or document loudly that http egress is allowlist-gated); canonicalise one adapter import-narrowing contract; retire the blanket shim path. See venue-platform-architecture.md s6 R5, s6 R4; videre-split-plan.md s5 Phase 2, s3.4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "security", + "dx", + "component/capabilities", + "component/http", + "effort/days" + ], + "acceptance": "Undeclared http egress is a build error (or the allowlist-only story is documented at the seam); exactly one adapter contract with import narrowing by construction.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "adapter-supervision-sweeps", + "title": "supervisor: fold venue adapters into the restart/poison sweeps and expose adapters_alive (R8)", + "body": "Adapters boot once and install but are not in the restart/poison-recovery sweeps (supervisor.rs:61-66); a trapped adapter stays dead until process restart, and the router only projects the trap to internal-error. A strategy cannot distinguish unknown-venue (never installed) from venue-temporarily-dead (trapped, recoverable). Naturally addressed by the S1 seam generalization which extracts the generic supervised-component primitive from AdapterActor. Fold venue adapters (the generalized provider/component kind) into the restart and poison-recovery sweeps; expose adapters_alive (or equivalent). See venue-platform-architecture.md s6 R8; videre-split-plan.md s2.2, s5 Phase S1.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "debt", + "component/lifecycle", + "component/engine-supervisor", + "effort/days" + ], + "acceptance": "Trapped adapters are swept and restarted; a liveness signal distinguishes unknown-venue from venue-temporarily-dead; trap->recovery test present.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "messaging-query-scope", + "title": "messaging: enforce messaging.query scope (goes live with the 0.3 Waku backend)", + "body": "The messaging.query path is not scope-checked - a module/adapter can query messaging outside its declared scope. Latent today because the messaging backend is a 0.3 stub; the hole goes live the moment the 0.3 Waku backend lands. Enforce the declared messaging scope on messaging.query (reject/deny out-of-scope queries), consistent with how publish scope is enforced; land the enforcement with (or ahead of) the 0.3 Waku backend. See venue-platform-architecture.md s7 triage (messaging.query not scoped #249); videre-split-plan.md s5 Phase 2.", + "kind": "bug", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "security", + "component/messaging", + "component/capabilities", + "effort/hours" + ], + "acceptance": "Out-of-scope messaging.query is denied, in-scope succeeds; enforcement wired into the 0.3 Waku backend with tests for both cases.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "mock-grant-fidelity", + "title": "capabilities: align mock capability-grant fidelity to the real host grant", + "body": "The mock capability grant diverges from the host's real grant, so tests can pass while real enforcement differs - a fidelity gap that hides capability regressions. As the real guard replaces the shims, the mock and host grant must be canonicalised to one behaviour. Reconcile the mock capability-grant behaviour with the host CapabilityRegistry grant so a capability the host would deny is also denied under mock; ideally derive both from one source-of-truth (the KNOWN capability table). See venue-platform-architecture.md s5, s7 triage (mock grant fidelity diverges #297); videre-split-plan.md s5 Phase 2.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M5: Egress guard", + "labels": [ + "debt", + "component/capabilities", + "effort/hours" + ], + "acceptance": "Mock and host agree on grant/deny for every KNOWN capability; a skew-guard test exists; no mock-only pass that the host would reject.", + "depends_on": [ + "egress-guard-hardening-epic" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "rfq-firm-quote-additive", + "title": "videre: RFQ firm-quote - additive firm: option on the quote record (taker-side, when a real RFQ venue appears)", + "body": "videre 0.1 quoting returns a plain indicative quote record. A market-maker/RFQ venue instead returns a signed, time-limited firm price the taker accepts. This is the smaller, taker-side cousin of the maker-side offer work deferred in #355 - and per that issue it slots into the existing quote record additively as a firm: option field, rather than a new interface. Deferred until a real RFQ venue exists so the firm-quote shape is not guessed. Add firm: option to videre:types quote plus the accept/settle path on the client/adapter faces; keep it additive and EVM-only; gate on a real RFQ venue. See venue-platform-architecture.md s6 R1, s8 decision 5; videre-split-plan.md s3.3, s7.4. Related: #355 (maker-side offer, distinct).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M7: Post-v1 hardening and debt", + "labels": [ + "feature", + "needs-design", + "component/wit-abi", + "effort/days" + ], + "acceptance": "quote.firm added additively (present only for RFQ), exercised by a real RFQ venue against goldens, with no breaking change to the indicative quote path.", + "depends_on": [ + "#355" + ], + "area": "egress guard + deferred/debt" + }, + { + "key": "materialiser-source-venue", + "title": "videre: Materialiser - the venue-neutral keeper materialiser (M7)", + "body": "The generic Keeper::sweep assembler resolves the dangling ConditionalSource::Outcome and gives strategy authors an assembler over the parts. The fully venue-neutral Materialiser - a source-agnostic, venue-agnostic keeper that materialises a Source's outcomes onto any Venue - is the explicit M7 destination, past the first stable runtime. It wants a second real venue and a settled keeper->pool port before it is generalized. Generalize the Keeper::sweep assembler to a Materialiser parameterized over both source and target venue, with the shared Sweep outcome; prove venue-neutrality against at least the CoW keeper and one second venue. See venue-platform-architecture.md s5.3, s7; videre-split-plan.md s5 triage (Keeper materialiser M7).", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "M7: Post-v1 hardening and debt", + "labels": [ + "dx", + "needs-design", + "component/sdk", + "effort/weeks" + ], + "acceptance": "Materialiser drives two distinct (Source,Venue) pairs through one assembler with no venue-specific branches in the materialiser.", + "depends_on": [], + "area": "egress guard + deferred/debt" + }, + { + "key": "gap-opaque-status-contract-spec", + "title": "Spec the opaque-status destructuring contract (versioned discriminator) that host event commits to - blocks R6", + "body": "The R6 host-intent decouple (host-r6-decouple, the P0 MASTER GATE) drops wit/nexum-host/types.wit:8 use nexum:intent/types.{receipt,intent-status} and has the host event stream carry opaque status bytes. But how those bytes destructure is still an OPEN decision: venue-platform-architecture.md s8 explicitly lists the exact wording + versioning scheme of the documented opaque-status destructuring contract as unresolved, and videre-split-plan.md s6.1 ranks this as risk #2. No drafted issue owns this design decision; host-r6-decouple is the implementation, which cannot land correctly until the contract shape exists. Define the wire form (version discriminator + destructuring rule); decide schema ownership (bytes host-emitted but meaning videre-owned); land as a short design note/ADR under docs/design/.", + "kind": "docs", + "phase": "P0", + "milestone_suggestion": "videre-split: P0 (pre-fold)", + "labels": [ + "gap", + "wit", + "design", + "P0", + "blocker" + ], + "acceptance": "A committed design note/ADR pins the opaque-status byte format (version discriminator + destructuring rule + schema ownership); host-r6-decouple lists it as a dependency and cites it. s8's open item is closed.", + "depends_on": [], + "area": "gap" + }, + { + "key": "gap-p0-wit-fold-execution", + "title": "Execute Phase 0 as ONE oracle-validated git-filter-repo/jj fold across the M1 train (regenerate goldens, re-assert tip oracle)", + "body": "Every P0 content issue (host-r6-decouple, videre-wit-rename, videre-wit-normalize, videre-quote, videre-wit-surface, plus the fold-tail hygiene) must land as a single train-wide fold, not as per-car edits: a WIT type touched in an early car is imported by all downstream cars, so editing car-by-car desyncs the stack. The drafted set has the epic (split-epic-p0) and the content issues, but no issue owns the mechanical fold execution - the range-limited git-filter-repo pass replayed across the stack, jj-driven per-car rebases, mergiraf conflict resolution, videre-test golden regeneration, byte-identical tip-oracle re-assertion, and the single force-push. This is the proven keeper-rename template and it is load-bearing. Assemble all P0 content changes on refactor/intent-contract-reshape; run the fold across the M1 stack (#239->#260 + Wave-1 #334/#335); regenerate goldens; re-assert tip oracle; single force-push. See venue-platform-architecture.md s7; videre-split-plan.md s5 Phase 0.", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "videre-split: P0 (fold)", + "labels": [ + "gap", + "wit", + "train-fold", + "tooling", + "P0" + ], + "acceptance": "The full P0 reshape lands as one force-pushed fold; tip oracle byte-identical across the two rebuild paths; videre-test goldens regenerated and green; all stack branches MERGEABLE.", + "depends_on": [ + "host-r6-decouple", + "gap-opaque-status-contract-spec", + "videre-wit-rename", + "videre-wit-normalize", + "videre-quote", + "videre-wit-surface", + "gap-p0-fold-tail-hygiene" + ], + "area": "gap" + }, + { + "key": "gap-p0-fold-tail-hygiene", + "title": "P0 fold tail: codec version discriminator (#297), migration-cruft deletion, denied() MUST-NOT-retry doc", + "body": "venue-platform-architecture.md s7 Phase 0 enumerates several contract-hygiene items that must ride the P0 fold but are not covered by any drafted issue (videre-wit-surface pins the shape; the conformance-kit is S1, too late for the fold). Specifically: codec version discriminator + reject-unknown and a non-empty-vector assertion on the cross-language codec goldens (#297); delete the migration cruft (docs/migration/0.1-to-0.2.md and the Migration from 0.1 prose in docs/08-platform-generalisation.md); MUST-NOT-retry doc caveat on venue-error.denied(); verify the valid-until -> valid-until-ms rename and the named-ERC-record lift actually land in the fold. These are cheap now (echo-only pinning) and must precede the true 0.1.0 cut. Fold-tail changes bundled into gap-p0-wit-fold-execution.", + "kind": "task", + "phase": "P0", + "milestone_suggestion": "videre-split: P0 (fold)", + "labels": [ + "gap", + "wit", + "codec", + "docs", + "P0" + ], + "acceptance": "Codec goldens carry a version discriminator, reject unknown versions, and assert a non-empty vector; docs/migration/0.1-to-0.2.md + the docs/08 migration prose are deleted; denied() has a MUST-NOT-retry doc; valid-until-ms + named ERC records confirmed in-tree.", + "depends_on": [ + "videre-wit-surface" + ], + "area": "gap" + }, + { + "key": "gap-m1-green-tip-gate", + "title": "Gate: finish M1 to a single green linear dev/m1 tip before any carve", + "body": "videre-split-plan.md s5 Phase 1 is an explicit, un-owned gate: Land the doc's Phase-1 Rust amends (#249/#250/#251/#296), the Wave-1 #334 verdict-seam fixes, the R7 install-time handshake, and the approved cars - to a single green linear dev/m1 tip. Do not begin the carve until this tip exists (carving mid-train triples the fold surgery across three repos). The drafted set covers guard-deny-quota (#250) and videre-body-versions-handshake (R7), but there is no umbrella gate asserting the green linear tip, and several required M1 cars are un-referenced: #249 supervisor missing-manifest error, #251 RateLimited fold test, #296 wit-bindgen 0.59 bump, and #334 Wave-1 (model Verdict::Post.next_poll_timestamp as Option/NextPoll not a 0-sentinel; add a NeedsInput dispatch test). Track landing of #249/#251/#296 + the two #334 Wave-1 fixes + approved cars to one green linear tip (amend-in-place, no fold). See venue-platform-architecture.md s7 Phase 1 / Phase 1-Wave-1; videre-split-plan.md s5 Phase 1.", + "kind": "chore", + "phase": "P0", + "milestone_suggestion": "M1 (green tip)", + "labels": [ + "gap", + "gate", + "M1", + "release-blocker" + ], + "acceptance": "dev/m1 is a green linear tip with #249/#251/#296 + both #334 Wave-1 fixes + approved cars merged; CI green; explicitly signed off as the precondition for beginning the S2 carve.", + "depends_on": [ + "gap-p0-wit-fold-execution", + "guard-deny-quota", + "videre-body-versions-handshake" + ], + "area": "gap" + }, + { + "key": "gap-videre-host-platform-crate", + "title": "Build the videre-host crate + videre::platform() registration (VenueRegistry + provider-kind + EgressGuard seam + bindgens)", + "body": "videre-split-plan.md s2.3 names videre-host as a new crate and calls it the split's core enabling work; s7.2 and s8 S1.3 specify that videre becomes one extension - builder.with_extension(videre::platform()) - that registers the venue-adapter provider-kind, the VenueRegistry service (the un-privileged ex-PoolRouter), the EgressGuard seam, the videre:venue/client interface, and the install predicate (R7 body-versions handshake), all through the generalized runtime seam. The drafted host-side issues cover growing the seam, extracting the service and the generic component-kind, and R8 sweeps - but no issue owns the L2 videre-host crate assembly + the videre::platform() entrypoint. In particular the s7.2 guard row (GuardPolicy/AllowAll -> EgressGuard, videre-owned) and the venue-adapter/pool-host bindgens + build_adapter_linker + adapter path of synthesize_venue have no home issue. Create videre-host (host-side L2 crate depending on nexum-runtime, the legal L2->L1 edge); land the VenueRegistry service, the venue-adapter ProviderKind + install predicate, the EgressGuard seam (advisory-only for M1), the videre:venue/client interface, and the bindgens; expose videre::platform(). See videre-split-plan.md s2.3, s7.2, s8 S1.1-S1.3.", + "kind": "task", + "phase": "S1", + "milestone_suggestion": "videre-split: S1 (generalization)", + "labels": [ + "gap", + "videre", + "host", + "seam", + "S1" + ], + "acceptance": "videre::platform() registers the provider-kind + VenueRegistry service + EgressGuard seam + videre:venue/client via the seam; videre-host depends on nexum-runtime only; echo-venue installs + a worker submits through it with the HostState.pool_router field deleted.", + "depends_on": [ + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind" + ], + "area": "gap" + }, + { + "key": "gap-handshake-manifest-key-decision", + "title": "Decide the install-time handshake manifest key (body_version vs version-set) + supported-set match semantics", + "body": "venue-platform-architecture.md s8 lists as one of only two remaining open decisions: the precise manifest key name (body_version vs a version-set field) and the supported-set match semantics for the install-time handshake (decision 4). videre-body-versions-handshake owns the implementation but presumes this decision; the schema is videre's while Supervisor::install (which asserts agreement) lives in nexum-runtime and must stay venue-agnostic, so the videre-host install predicate supplies it. Pin the manifest key name and the module-version-in-adapter-supported-set match semantics (exact-set vs range); note where it slots (module + adapter manifests, asserted at install, fail-fast + logged).", + "kind": "docs", + "phase": "P0", + "milestone_suggestion": "videre-split: P0 (decisions)", + "labels": [ + "gap", + "design", + "manifest", + "handshake" + ], + "acceptance": "A one-page decision fixes the manifest key name + supported-set match semantics; videre-body-versions-handshake references it.", + "depends_on": [], + "area": "gap" + }, + { + "key": "gap-docs-source-of-truth-rewrite", + "title": "Rewrite docs/05 + docs/08 as source-of-truth: venue persona is shipped; adapters are THE extension mechanism; cow-api is legacy read-path", + "body": "venue-platform-architecture.md s4 gap #10 and s7 Phase 4 flag the source-of-truth docs as actively misleading - blocking, cheap, highest discovery-return change: docs/05 says the venue persona is not shipped (it is), and docs/08 documents only the deprecated Layer-3 host-extension model. Decision 1 (s8) further requires deleting the shepherd:cow/cow-api-as-adapter-extension ambiguity from the docs, keeping it only as the legacy event-module read path. No drafted issue owns this (the migration-cruft deletion is in gap-p0-fold-tail-hygiene; this is the affirmative rewrite). docs/05: document the venue persona as shipped - crate layout + a step-by-step author a venue on videre. docs/08: venue adapters (#[videre::venue]) are THE domain-extension mechanism; mark shepherd:cow/cow-api as the legacy read path; delete the adapter-extension ambiguity. See venue-platform-architecture.md s4 #10, s7 Phase 4, s8 decision 1.", + "kind": "docs", + "phase": "S1b", + "milestone_suggestion": "videre-split: S1b", + "labels": [ + "gap", + "docs", + "discovery" + ], + "acceptance": "docs/05 documents the shipped venue persona with an author-a-venue walkthrough; docs/08 names venue adapters as the extension mechanism and marks cow-api legacy read-path with no adapter-extension ambiguity.", + "depends_on": [ + "cow-api-retire" + ], + "area": "gap" + }, + { + "key": "gap-alloy-provider-seam", + "title": "Chain DX: alloy Provider seam over ChainHost::request (HostTransport: alloy Transport) + carry ChainMethod to the guest", + "body": "venue-platform-architecture.md s4 gap #4 and s5.1 call the missing alloy Provider seam the largest single DX gap from the alloy target and the flagship systemic move (s5 Phase 4). Today chain is raw stringly request(u64,&str,&str)->String; authors hand-build JSON-RPC params and parse strings. doc-08 promises a HostTransport: alloy Transport shim that is not in the SDK. The closed ChainMethod RPC enum already exists host-side but is not carried to the guest. No drafted issue covers this (host-backend-guest-seams is identity/messaging/remote-store only). Add HostTransport: alloy Transport over ChainHost::request; a guest host.provider(Chain) returning an alloy Provider; carry the typed ChainMethod surface to the guest; zero-cost Chain/ChainId newtypes. See venue-platform-architecture.md s5.1, s7 Phase 4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "post-M1: DX build-out (Phase 4)", + "labels": [ + "gap", + "dx", + "chain", + "alloy", + "sdk" + ], + "acceptance": "A guest strategy can do host.provider(chain).get_block_number() / .call(&tx) through an alloy Provider backed by ChainHost; typed ChainMethod reaches the guest; no hand-rolled JSON-RPC at call sites.", + "depends_on": [], + "area": "gap" + }, + { + "key": "gap-dx-polish-cluster", + "title": "reth/alloy DX polish cluster: VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const, kill *_to_golden bridges", + "body": "venue-platform-architecture.md s5 (the systemic moves worth making) and s7 Phase 4 enumerate a DX-polish cluster that no drafted issue owns: mirror venue-error -> VenueFault (Display + IntoStaticStr label + From) so operator logs stop {0:?}-formatting and rate-limited{retry-after-ms} survives the fold; Order typestate builder replacing the bare 12-field OrderBody literal + CoW SellToken/BuyToken newtypes; uniform #[non_exhaustive] across public error/label enums; seal the extension traits (Host, HostFault, RuntimeTypes, Runtime, IntentPool) with a private Sealed supertrait; derive the mirrors from one source-of-truth const (the fault vocabulary is hand-mirrored in three places and the KNOWN table is duplicated); kill the ~80-line *_to_golden bridge boilerplate each #[venue] adapter hand-copies (R4 tail). One umbrella; land as convenient in Phase 4. See venue-platform-architecture.md s5, s7 Phase 4.", + "kind": "task", + "phase": "deferred", + "milestone_suggestion": "post-M1: DX build-out (Phase 4)", + "labels": [ + "gap", + "dx", + "sdk", + "ergonomics" + ], + "acceptance": "VenueError mirrored to a Display/IntoStaticStr VenueFault preserving retry-after-ms; Order typestate builder + sell/buy newtypes; #[non_exhaustive] uniform; extension traits sealed; fault vocab + KNOWN table emitted from single-source consts; *_to_golden bridges removed.", + "depends_on": [], + "area": "gap" + } + ], + "tracker": { + "open_count": 61, + "open_unmilestoned": 5, + "milestones": [ + { + "number": 8, + "title": "M1: Intent core and CoW venue adapter", + "open_issues": 17, + "repurposed_as": "P0 — Videre contract reshape & the R6 master gate" + }, + { + "number": 7, + "title": "M0: Runtime architecture and lifecycle", + "open_issues": 7, + "repurposed_as": "S1 — Generic venue-agnostic host (nexum-runtime L1) + lifecycle" + }, + { + "number": 3, + "title": "M2: Concrete CoW modules", + "open_issues": 5, + "repurposed_as": "S1b — CoW on the generic seam" + }, + { + "number": 4, + "title": "M3: Infrastructure", + "open_issues": 3, + "repurposed_as": "S2 — The gated three-repo cut & delivery infrastructure" + }, + { + "number": 5, + "title": "M4: SDK and DX", + "open_issues": 3, + "repurposed_as": "videre SDK, macros & reth/alloy DX" + }, + { + "number": 9, + "title": "M5: Egress guard", + "open_issues": 2, + "repurposed_as": "Egress guard (real, teeth)" + }, + { + "number": 10, + "title": "M6: Second venue and vocabulary freeze", + "open_issues": 3, + "repurposed_as": "S3 — Second-venue acceptance & vocabulary freeze" + }, + { + "number": 6, + "title": "M7: Post-v1 hardening and debt", + "open_issues": 16, + "repurposed_as": "Post-v1 hardening & debt (rolling)" + }, + { + "number": 1, + "title": "(dissolved) Host backends real", + "open_issues": 0, + "repurposed_as": "closed/dissolved" + } + ] + }, + "reconcile": { + "milestone_plan": [ + { + "name": "P0 — Videre contract reshape & the R6 master gate", + "existing_number": 8, + "charter": "Repurposes old M1 (intent core is delivered: #137/#135/#131/#222 closed). Now owns the free, in-monorepo pre-release WIT fold that every later phase depends on. Land R6 host<->intent decouple (the master gate: host event carries opaque status bytes); rename nexum:intent/value-flow/adapter -> videre:*; normalize all packages to @0.1.0; add quoting (client+adapter); reshape venue-error (rate-limited{retry-after-ms}+denied) and value-flow (named records); spec the opaque-status destructuring contract and the install-time body-versions handshake key; ship the advisory-only M1 guard posture; and finish the M1 train to a single green linear tip. Executed as ONE oracle-validated git-filter-repo/jj fold. Nothing is pinned so every change is a free recompile; this phase MUST complete before any carve.", + "new_issue_keys": [ + "split-epic-p0", + "host-r6-decouple", + "gap-opaque-status-contract-spec", + "videre-epic", + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "videre-body-versions-handshake", + "gap-handshake-manifest-key-decision", + "gap-p0-wit-fold-execution", + "gap-p0-fold-tail-hygiene", + "p0-acyclicity-scaffold", + "guard-advisory-m1", + "guard-deny-quota", + "gap-m1-green-tip-gate" + ], + "moved_issues": [] + }, + { + "name": "S1 — Generic venue-agnostic host (nexum-runtime L1) + lifecycle", + "existing_number": 7, + "charter": "Repurposes M0 (library-first composable runtime) and absorbs the dissolved old-M2 execution/lifecycle hardening. Now the long-pole S1 phase: make nexum-runtime venue-agnostic by growing Extension to worker/provider roles (service+provider, HostService, ProviderKind), extracting PoolRouter->VenueRegistry as an extension-owned service and DELETING the privileged HostState.pool_router field (the forcing-function acceptance test), extracting the generic supervised-component/host-actor primitive from AdapterActor (folds R8), de-hardcoding the KNOWN table into nexum-world, the bare Ext=() launcher, and the permanent zero-leak CI gate. Plus L1 execution/resource/lifecycle hardening that survives the split unchanged (per-module quotas, fuel accounting, graceful drain, pluggable log seam, WASI allowlist, handler-DoS, router watch-set bound).", + "new_issue_keys": [ + "host-generalize-epic", + "split-epic-s1", + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind", + "host-nexum-world-registry", + "host-zero-leak-ci-gate", + "host-generic-launcher-bin", + "s1-gate-runtime-venue-agnostic", + "gap-videre-host-platform-crate", + "adapter-supervision-sweeps" + ], + "moved_issues": [ + 294, + 266, + 265, + 244, + 107, + 53, + 51, + 273, + 321 + ] + }, + { + "name": "S1b — CoW on the generic seam (concrete venue + keepers)", + "existing_number": 3, + "charter": "Repurposes M2 'Concrete CoW modules'. Prove the generic seam carries a REAL venue, not just echo. Cleave cow-venue into an orderbook-only venue vs a composable-cow keeper (CI-gated clean); build the cow adapter cdylib (#[videre::venue] over wasi:http, #324); settle the idempotency seam before assembly moves into the adapter; port the composable-cow keeper (#327) and ethflow keeper (#328) onto videre:venue/client; retire CowApiHost/cow-api/cow-ext (#293, the biggest lever); own the shepherd-cow event-ABI WITs at L3; rewrite docs/05+08 source-of-truth. The fork-gated poll wire-swap (delete composable.rs) rides here but stays deferred/decoupled. Carries the still-live cow module bugs (#320/#121/#75/#48/#54) into the ported keeper.", + "new_issue_keys": [ + "split-epic-s1b", + "s1b-gate-cow-on-generic-seam", + "cleave-cow-venue", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "composable-poll-wire-swap", + "gap-docs-source-of-truth-rewrite" + ], + "moved_issues": [ + 138, + 324, + 327, + 328, + 293, + 323, + 320, + 121, + 75, + 48, + 54, + 64 + ] + }, + { + "name": "S2 — The gated three-repo cut & delivery infrastructure", + "existing_number": 4, + "charter": "Repurposes M3 'Infrastructure'. The physical split, gated on all three cut gates (a runtime venue-agnostic, b cow on the generic seam, c genuine second-protocol venue). Transitional path-dep workspace in the three groupings; wit-deps flip + git-tag sourcing + wkg/OCI convergence; the cut go/no-go checklist; three history-preserving git-filter-repo carves (nexum-runtime / videre / CoW-on-videre) with the byte-identical tip oracle. Plus operator delivery: CI/CD hardening (incl. the sccache fork-PR fail-open #337), the multi-chain provider map + docs, ghcr packaging, and the Swarm remote-store backend.", + "new_issue_keys": [ + "split-epic-s2", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "host-wit-deps-flip-carve" + ], + "moved_issues": [ + 274, + 337, + 151, + 125, + 124 + ] + }, + { + "name": "S3 — Second-venue acceptance & vocabulary freeze", + "existing_number": 10, + "charter": "Repurposes M6 'Second venue and vocabulary freeze'. The acceptance phase that de-risks R1: build a genuine non-cow second-protocol venue (rfq or amm-router, #140) against videre-sdk alone, exercising quote and surfaces CoW does not, feeding contract fixes back pre-cut. Cut gate (c). Then the videre:value-flow 1.0 freeze decisions (#330, retitled) and the curated adapter registry + consent surface (#141).", + "new_issue_keys": [ + "split-epic-s3" + ], + "moved_issues": [ + 140, + 330, + 141 + ] + }, + { + "name": "videre SDK, macros & reth/alloy DX", + "existing_number": 5, + "charter": "Repurposes M4 'SDK and DX'. The venue/keeper author front door and DX build-out: videre-sdk (renamed from nexum-venue-sdk, + Keeper::sweep assembler + VenueClient), #[videre::venue] single blessed path, #[videre::keeper] + typed VenueClient, the videre-test conformance kit, guest SDK seams+Mocks for identity/messaging/remote-store, the alloy Provider chain seam, and the reth/alloy DX polish cluster (VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const). Plus the grant-scoped DX deliverables and residual nexum-sdk/macro consolidation.", + "new_issue_keys": [ + "videre-sdk-crate", + "videre-venue-macro", + "videre-keeper-macro", + "videre-conformance-kit", + "host-backend-guest-seams", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster" + ], + "moved_issues": [ + 291, + 264, + 127, + 136, + 322 + ] + }, + { + "name": "Egress guard (real, teeth)", + "existing_number": 9, + "charter": "Keeps M5 'Egress guard' as the home for the REAL (non-AllowAll) guard, deferred wholly per decision 3 (M1 is advisory-only). Single-decode / derive-before-guard TOCTOU fix, move the checkpoint to the signed unsigned-tx / identity boundary, GuardPolicy::check sync->async, bring http egress under the compile-time world guarantee, messaging.query scope enforcement, mock-grant fidelity. Anchored by the rescoped guard epic #139 and gated on the real keystore identity backend #52.", + "new_issue_keys": [ + "guard-derive-before-guard", + "guard-signing-boundary", + "guard-policy-async", + "guard-egress-cap-world-guarantee", + "messaging-query-scope", + "mock-grant-fidelity" + ], + "moved_issues": [ + 139, + 52 + ] + }, + { + "name": "Post-v1 hardening & debt (rolling)", + "existing_number": 6, + "charter": "Keeps M7 as the rolling, non-gating debt bucket. Deferred videre concepts (maker-side offer #355, RFQ firm-quote additive, Materialiser), doc-consistency passes, typed-fault/backend debt, test-harness/clock-seam debt, perf (parking_lot, bulk getLogs), the deferred messaging/remote-store backends and payload-codec convention, and the grant soak/reporting items.", + "new_issue_keys": [ + "rfq-firm-quote-additive", + "materialiser-source-venue" + ], + "moved_issues": [ + 355, + 341, + 302, + 289, + 288, + 286, + 285, + 284, + 283, + 280, + 269, + 212, + 152, + 105, + 65 + ] + } + ], + "new_issues": [ + "host-generalize-epic", + "host-r6-decouple", + "host-extension-seam-roles", + "host-venue-registry-extract", + "host-generic-component-kind", + "host-nexum-world-registry", + "host-zero-leak-ci-gate", + "host-generic-launcher-bin", + "host-wit-deps-flip-carve", + "host-backend-guest-seams", + "videre-epic", + "videre-wit-rename", + "videre-wit-surface", + "videre-wit-normalize", + "videre-quote", + "videre-body-versions-handshake", + "videre-sdk-crate", + "videre-venue-macro", + "videre-keeper-macro", + "videre-conformance-kit", + "cleave-cow-venue", + "cow-idempotency-seam", + "shepherd-cow-event-abi-wits", + "composable-poll-wire-swap", + "split-epic-p0", + "p0-acyclicity-scaffold", + "split-epic-s1", + "s1-gate-runtime-venue-agnostic", + "split-epic-s1b", + "s1b-gate-cow-on-generic-seam", + "split-epic-s2", + "s2-transitional-workspace", + "s2-wit-cross-repo-consumption", + "s2-cut-gate-checklist", + "s2-three-carves", + "split-epic-s3", + "guard-advisory-m1", + "guard-derive-before-guard", + "guard-signing-boundary", + "guard-deny-quota", + "guard-policy-async", + "guard-egress-cap-world-guarantee", + "adapter-supervision-sweeps", + "messaging-query-scope", + "mock-grant-fidelity", + "rfq-firm-quote-additive", + "materialiser-source-venue", + "gap-opaque-status-contract-spec", + "gap-p0-wit-fold-execution", + "gap-p0-fold-tail-hygiene", + "gap-m1-green-tip-gate", + "gap-videre-host-platform-crate", + "gap-handshake-manifest-key-decision", + "gap-docs-source-of-truth-rewrite", + "gap-alloy-provider-seam", + "gap-dx-polish-cluster" + ], + "close": [ + { + "number": 339, + "reason": "Obsolete: fixes nexum.toml->module.toml inside docs/migration/0.1-to-0.2.md, which is already deleted (HEAD 7c66b6c) as Phase-0 migration cruft. Fixing a deleted file is moot." + }, + { + "number": 287, + "reason": "Targets shepherd-cow-host ext_cow.rs - the legacy cow-api extension that cow-api-retire (#293) deletes. The timeout/429->typed-fault requirement is carried forward by the cow-venue adapter's errorType->venue-error projection + the R1 venue-error reshape; the host-chain equivalent lives on as #269." + }, + { + "number": 222, + "reason": "Delivered by #239: ConditionalSource/Retrier/RetryAction/cow::run landed in nexum-sdk/keeper.rs with the M1 train. Forward Verdict/Keeper::sweep rework is carried by videre-sdk-crate." + }, + { + "number": 137, + "reason": "Delivered by the M1 train (#226-#234): nexum:value-flow+nexum:intent WIT, venue-adapter world, PoolRouter, nexum-venue-sdk, conformance kit, echo-venue. Successor is the drafted videre-epic (rename/quote/normalize/R6), not a reopen." + }, + { + "number": 135, + "reason": "Delivered by the M1 train (#146/#222/#148/#147/#149): the keeper primitives + single-venue loop landed in nexum-sdk/keeper.rs. Deferred generalization is videre-sdk-crate (Keeper::sweep) + materialiser-source-venue (M7)." + }, + { + "number": 131, + "reason": "Docs-only, delivered by PR #132 (docs 08/09 + egress-guard ADR); the design docs now exist. Go-forward doc work is gap-docs-source-of-truth-rewrite." + }, + { + "number": 7, + "reason": "Stale pre-restructure roadmap epic, no milestone. Its goal is largely delivered (chain/local-store/logging live) and its workstreams are decomposed into M0-M7 + individual issues (#52/#152/#151/#285). Nothing tracks against it." + } + ], + "modify": [ + { + "number": 139, + "change": "Rescope to fold the venue-platform R3/R5/R8 router/capability/lifecycle hardening the guard engine epic did not enumerate (single-decode/derive-before-guard, signed-tx boundary, GuardPolicy async, http-under-world-guarantee, messaging.query scope, adapter sweeps, mock fidelity) as children. Keep M5. This IS the drafted egress-guard-hardening-epic; nothing shipped. Depends on #52 (identity) and stays advisory-only for M1." + }, + { + "number": 330, + "change": "Retitle the package under the videre rename: nexum:value-flow -> videre:value-flow. Keep the two freeze-gate ontology decisions (minimal-length canonical amount encoding; native-token representable-but-invalid) - NOT addressed by the Phase-0 named-records reshape. Stays M6/S3 as the freeze gate, distinct from videre-wit-surface (reshape, not freeze)." + }, + { + "number": 289, + "change": "Drop the docs/migration/0.1-to-0.2.md bullet (that file is deleted as Phase-0 cruft). Keep the still-valid items: ADR-0011 {errorType,description,data} restoration, docs/07 rpc data-payload callout, docs/production.md house-style pass, stale .mmd/.png regen. Stays M7. Distinct from gap-docs-source-of-truth-rewrite (docs/05+08 venue-persona)." + }, + { + "number": 274, + "change": "Rescope from a TWO-repo split (core + shepherd instantiation) to the THREE-repo umbrella (nexum-runtime <- videre <- CoW-on-videre) spanning the drafted split-epic family (split-epic-p0/s1/s1b/s2/s3; closest single correspondent split-epic-s2, the physical cut). Update the two-way partition to three-way; fold its post-split preset items into the generalization. Re-milestone M7->M3." + }, + { + "number": 273, + "change": "Rescope: the preset/Runtime-trait launch-surface ask is subsumed by the S1 seam generalization (host-extension-seam-roles + gap-videre-host-platform-crate grow Extension; host-generic-launcher-bin retires the backwards nexum-cli->cow dep with a bare Ext=() bin). Residual = the MockRuntime preset path (rolls into host-backend-guest-seams/#80). Re-milestone M7->M0." + }, + { + "number": 136, + "change": "Rescope: premise is now wrong - the videre split does NOT retire shepherd-sdk (recast as the L3 CoW keeper crate, kept). Residual: (a) nexum-venue-sdk->videre-sdk rename is videre-sdk-crate; (b) 'clean break / workspace-member removal' becomes the S2 carve (s2-three-carves). Retitle to the surviving nexum-sdk/macro consolidation; re-milestone M1->M4." + } + ], + "merge": [ + { + "from": [ + 325, + 326 + ], + "into": 324 + }, + { + "from": [ + 329 + ], + "into": 293 + } + ], + "dedup": [ + { + "drafted_key": "host-identity-signing-backend", + "existing": 52 + }, + { + "drafted_key": "egress-guard-hardening-epic", + "existing": 139 + }, + { + "drafted_key": "cow-onvidere-epic", + "existing": 138 + }, + { + "drafted_key": "cow-venue-cdylib", + "existing": 324 + }, + { + "drafted_key": "cow-api-retire", + "existing": 293 + }, + { + "drafted_key": "ethflow-keeper", + "existing": 328 + }, + { + "drafted_key": "composable-cow-keeper-port", + "existing": 327 + }, + { + "drafted_key": "s3-gate-second-protocol-venue", + "existing": 140 + } + ], + "summary": "The reorg reshapes the eight legacy milestones (M0-M7) into a set that reads top-to-bottom as the videre execution order - P0 (contract reshape/master gate) -> S1 (generic host) -> S1b (CoW on the seam) -> S2 (the gated cut) -> S3 (second venue) - followed by three cross-cutting buckets (videre SDK/DX, the real egress guard, and the rolling debt pile). The biggest structural move is repurposing old M1 (#8): the intent-core it was chartered for is delivered (#137/#135/#131/#222 all close on the M1 train), so #8 becomes the P0 free-WIT-fold milestone anchored on R6 (the acyclicity master gate) and the videre:* rename/normalize/quote work, executed as one oracle-validated git-filter-repo fold. M0 (#7) is repurposed as the S1 long pole - making nexum-runtime venue-agnostic (grow Extension, extract VenueRegistry, delete the pool_router field, zero-leak CI gate) - absorbing the dissolved old-M2 lifecycle hardening. The largest dedup/close wins: eight drafted issues collapse onto existing tracker issues rather than being created (host-identity-signing-backend=#52, egress-guard-hardening-epic=#139, cow-onvidere-epic=#138, cow-venue-cdylib=#324, cow-api-retire=#293, ethflow-keeper=#328, composable-cow-keeper-port=#327, s3-gate-second-protocol-venue=#140), and seven existing issues close as already-delivered-by-the-M1-train or obsoleted by the Phase-0 migration-cruft deletion. #325/#326 fold into #324 (one cow-adapter deliverable) and #329 folds into #293 (the cow-cone retirement). The concrete CoW cone (venue cleave, keeper ports, cow-api retirement) is re-milestoned M1->M2/S1b so it lands after the generic seam it depends on, while venue-agnostic L1 hardening (#294/#266/#53/#51/#107/#244) consolidates under S1. Net: 56 net-new issues across the reshaped set, with two internal-overlap seams flagged for implementation-time coordination - host-generic-component-kind vs adapter-supervision-sweeps (both fold R8) and host-wit-deps-flip-carve vs s2-wit-cross-repo-consumption/s2-three-carves (the L1 slice of the carve). Two existing epics (#274 split, #273 preset-trait) are rescoped rather than duplicated to sit under the drafted split/seam epics, and #136 is rescoped away from its now-wrong 'retire shepherd-sdk' premise since the split keeps shepherd-sdk as the L3 keeper crate." + } +} \ No newline at end of file diff --git a/docs/design/issue-milestone-plan.v1.md b/docs/design/issue-milestone-plan.v1.md new file mode 100644 index 00000000..3ffc2fe0 --- /dev/null +++ b/docs/design/issue-milestone-plan.v1.md @@ -0,0 +1,321 @@ +# Videre three-layer split — issue & milestone reorganisation plan + +*nullislabs/shepherd · generated 2026-07-15 · DATA ONLY — nothing was created, closed, edited, merged or re-milestoned on GitHub. Apply after review.* + +Design source of truth: `docs/design/venue-platform-architecture.md` + `docs/design/videre-split-plan.md`. + +## a. Executive summary + +The reorg reshapes the eight legacy milestones (M0-M7) into a set that reads top-to-bottom as the videre execution order - P0 (contract reshape/master gate) -> S1 (generic host) -> S1b (CoW on the seam) -> S2 (the gated cut) -> S3 (second venue) - followed by three cross-cutting buckets (videre SDK/DX, the real egress guard, and the rolling debt pile). The biggest structural move is repurposing old M1 (#8): the intent-core it was chartered for is delivered (#137/#135/#131/#222 all close on the M1 train), so #8 becomes the P0 free-WIT-fold milestone anchored on R6 (the acyclicity master gate) and the videre:* rename/normalize/quote work, executed as one oracle-validated git-filter-repo fold. M0 (#7) is repurposed as the S1 long pole - making nexum-runtime venue-agnostic (grow Extension, extract VenueRegistry, delete the pool_router field, zero-leak CI gate) - absorbing the dissolved old-M2 lifecycle hardening. The largest dedup/close wins: eight drafted issues collapse onto existing tracker issues rather than being created (host-identity-signing-backend=#52, egress-guard-hardening-epic=#139, cow-onvidere-epic=#138, cow-venue-cdylib=#324, cow-api-retire=#293, ethflow-keeper=#328, composable-cow-keeper-port=#327, s3-gate-second-protocol-venue=#140), and seven existing issues close as already-delivered-by-the-M1-train or obsoleted by the Phase-0 migration-cruft deletion. #325/#326 fold into #324 (one cow-adapter deliverable) and #329 folds into #293 (the cow-cone retirement). The concrete CoW cone (venue cleave, keeper ports, cow-api retirement) is re-milestoned M1->M2/S1b so it lands after the generic seam it depends on, while venue-agnostic L1 hardening (#294/#266/#53/#51/#107/#244) consolidates under S1. Net: 56 net-new issues across the reshaped set, with two internal-overlap seams flagged for implementation-time coordination - host-generic-component-kind vs adapter-supervision-sweeps (both fold R8) and host-wit-deps-flip-carve vs s2-wit-cross-repo-consumption/s2-three-carves (the L1 slice of the carve). Two existing epics (#274 split, #273 preset-trait) are rescoped rather than duplicated to sit under the drafted split/seam epics, and #136 is rescoped away from its now-wrong 'retire shepherd-sdk' premise since the split keeps shepherd-sdk as the L3 keeper crate. + +**By the numbers.** Tracker today: **61 open issues** across **8 live milestones** (plus 5 open issues with no milestone; milestone #1 *Host backends real* is dissolved/closed). The plan: + +- **8 milestones repurposed** (renamed + rechartered in place — no new milestones, no deletions) so they read top-to-bottom as the videre execution order **P0 → S1 → S1b → S2 → S3**, then three cross-cutting buckets (videre SDK/DX, real egress guard, rolling debt). +- **56 new issues to create** (out of 64 drafted — the other **8 dedup** onto existing tracker issues). +- **7 issues to close** (delivered by the M1 train or obsoleted by Phase-0 migration-cruft deletion). +- **6 issues to rescope/retitle/re-milestone**; **2 merge groups** (3 issues folded into 2 targets). +- The single hard ordering constraint: **R6 (host↔intent WIT decouple) is the master gate** — until `nexum:host` stops importing `nexum:intent`, an acyclic split is physically impossible. Nothing in S1+ may begin before R6 lands, and no repo carve may begin before all three cut gates (a/b/c) are green. + +## b. Reorganised milestone plan + +Each milestone below is an **existing** milestone repurposed in place. `New` = drafted issues to create under it; `Moved-in` = existing open issues to re-milestone into it. + +### P0 — Videre contract reshape & the R6 master gate + +*Repurposes milestone **#8**.* + +Repurposes old M1 (intent core is delivered: #137/#135/#131/#222 closed). Now owns the free, in-monorepo pre-release WIT fold that every later phase depends on. Land R6 host<->intent decouple (the master gate: host event carries opaque status bytes); rename nexum:intent/value-flow/adapter -> videre:*; normalize all packages to @0.1.0; add quoting (client+adapter); reshape venue-error (rate-limited{retry-after-ms}+denied) and value-flow (named records); spec the opaque-status destructuring contract and the install-time body-versions handshake key; ship the advisory-only M1 guard posture; and finish the M1 train to a single green linear tip. Executed as ONE oracle-validated git-filter-repo/jj fold. Nothing is pinned so every change is a free recompile; this phase MUST complete before any carve. + +**New issues (create here):** +- `split-epic-p0` — Epic (P0): free monorepo reshape - land the master gate that makes an acyclic split possible _(phase P0, epic)_ +- `host-r6-decouple` — R6: decouple nexum:host from nexum:intent - host event carries opaque status bytes (MASTER GATE) _(phase P0, task)_ +- `gap-opaque-status-contract-spec` — Spec the opaque-status destructuring contract (versioned discriminator) that host event commits to - blocks R6 _(phase P0, docs)_ +- `videre-epic` — Epic: videre - the generic intent-venue abstraction (L2) _(phase P0, epic)_ +- `videre-wit-rename` — videre: rename nexum:intent/* WIT + readability renames -> videre:* _(phase P0, task)_ +- `videre-wit-surface` — videre: pin the videre:* WIT surface (types / venue / value-flow) _(phase P0, task)_ +- `videre-wit-normalize` — videre: normalize all WIT packages to a single @0.1.0 _(phase P0, chore)_ +- `videre-quote` — videre: add quote to videre:venue (client + adapter) + IntentClient typestate _(phase P0, task)_ +- `videre-body-versions-handshake` — videre: install-time body-versions schema handshake _(phase S1, task)_ +- `gap-handshake-manifest-key-decision` — Decide the install-time handshake manifest key (body_version vs version-set) + supported-set match semantics _(phase P0, docs)_ +- `gap-p0-wit-fold-execution` — Execute Phase 0 as ONE oracle-validated git-filter-repo/jj fold across the M1 train (regenerate goldens, re-assert tip oracle) _(phase P0, chore)_ +- `gap-p0-fold-tail-hygiene` — P0 fold tail: codec version discriminator (#297), migration-cruft deletion, denied() MUST-NOT-retry doc _(phase P0, task)_ +- `p0-acyclicity-scaffold` — Land the acyclicity / zero-leak CI gate for nexum-runtime (advisory in P0, blocking in S1) _(phase P0, chore)_ +- `guard-advisory-m1` — guard: ship advisory-only posture for M1 (keep AllowAll, feature-gate the pool import, document the checkpoint as not-yet-enforcing) _(phase P0, task)_ +- `guard-deny-quota` — guard: charge quota on guard-deny to close the busy-loop DoS _(phase P0, bug)_ +- `gap-m1-green-tip-gate` — Gate: finish M1 to a single green linear dev/m1 tip before any carve _(phase P0, chore)_ + +### S1 — Generic venue-agnostic host (nexum-runtime L1) + lifecycle + +*Repurposes milestone **#7**.* + +Repurposes M0 (library-first composable runtime) and absorbs the dissolved old-M2 execution/lifecycle hardening. Now the long-pole S1 phase: make nexum-runtime venue-agnostic by growing Extension to worker/provider roles (service+provider, HostService, ProviderKind), extracting PoolRouter->VenueRegistry as an extension-owned service and DELETING the privileged HostState.pool_router field (the forcing-function acceptance test), extracting the generic supervised-component/host-actor primitive from AdapterActor (folds R8), de-hardcoding the KNOWN table into nexum-world, the bare Ext=() launcher, and the permanent zero-leak CI gate. Plus L1 execution/resource/lifecycle hardening that survives the split unchanged (per-module quotas, fuel accounting, graceful drain, pluggable log seam, WASI allowlist, handler-DoS, router watch-set bound). + +**New issues (create here):** +- `host-generalize-epic` — Epic: make nexum-runtime a generic, venue-agnostic component host _(phase S1, epic)_ +- `split-epic-s1` — Epic (S1): make nexum-runtime venue-agnostic - generalize the Extension seam (the long pole) _(phase S1, epic)_ +- `host-extension-seam-roles` — Grow the Extension seam to carry worker/provider roles (service + provider, ProviderKind, HostService) _(phase S1, task)_ +- `host-venue-registry-extract` — Extract PoolRouter -> VenueRegistry as an extension-owned service; delete the privileged supervisor field _(phase S1, task)_ +- `host-generic-component-kind` — Extract a generic supervised-component/host-actor primitive from AdapterActor; collapse match kind (folds R8) _(phase S1, task)_ +- `host-nexum-world-registry` — De-hardcode the KNOWN capability table (drop baked pool + cow-api rows); extract world synthesis to nexum-world lib _(phase S1, task)_ +- `host-zero-leak-ci-gate` — CI gate: nexum-runtime has zero venue/intent/cow symbols _(phase S1, chore)_ +- `host-generic-launcher-bin` — Extract a generic launcher lib + bare Ext=() nexum engine bin (retire the backwards nexum-cli -> cow dep) _(phase S1, task)_ +- `s1-gate-runtime-venue-agnostic` — GATE (a): prove nexum-runtime is venue-agnostic - flip the zero-leak CI check to blocking _(phase S1, task)_ +- `gap-videre-host-platform-crate` — Build the videre-host crate + videre::platform() registration (VenueRegistry + provider-kind + EgressGuard seam + bindgens) _(phase S1, task)_ +- `adapter-supervision-sweeps` — supervisor: fold venue adapters into the restart/poison sweeps and expose adapters_alive (R8) _(phase S1, task)_ + +**Moved-in existing issues (re-milestone here):** #294 (runtime: complete execution and lifecycle hardening), #266 (runtime: graceful-shutdown drain for durable state (flush the store / block Ctrl-C on in-flight commits)), #265 (runtime: make the log pipeline pluggable through a ComponentBuilder seam), #244 (security: handler dosing), #107 (runtime: verify fuel accounting during host function calls), #53 (runtime: enforce per-module resource limits and local-store quota), #51 (runtime: extend manifest capability allowlist to the WASI surface), #273 (runtime: Runtime preset trait capability gaps (extensions and pre-built instances)), #321 (runtime: bound the intent-router status-watch set (eviction + config)) + +### S1b — CoW on the generic seam (concrete venue + keepers) + +*Repurposes milestone **#3**.* + +Repurposes M2 'Concrete CoW modules'. Prove the generic seam carries a REAL venue, not just echo. Cleave cow-venue into an orderbook-only venue vs a composable-cow keeper (CI-gated clean); build the cow adapter cdylib (#[videre::venue] over wasi:http, #324); settle the idempotency seam before assembly moves into the adapter; port the composable-cow keeper (#327) and ethflow keeper (#328) onto videre:venue/client; retire CowApiHost/cow-api/cow-ext (#293, the biggest lever); own the shepherd-cow event-ABI WITs at L3; rewrite docs/05+08 source-of-truth. The fork-gated poll wire-swap (delete composable.rs) rides here but stays deferred/decoupled. Carries the still-live cow module bugs (#320/#121/#75/#48/#54) into the ported keeper. + +**New issues (create here):** +- `split-epic-s1b` — Epic (S1b): CoW on the generic seam - real adapter cdylib + keeper on videre:venue/client _(phase S1b, epic)_ +- `s1b-gate-cow-on-generic-seam` — GATE (b): CoW rides the generic seam - keeper on videre:venue/client, CowApiHost retired _(phase S1b, task)_ +- `cleave-cow-venue` — Cleave cow-venue: orderbook-only venue vs composable-cow keeper _(phase S1b, task)_ +- `cow-idempotency-seam` — Settle the CoW idempotency seam before order assembly moves into the adapter _(phase S1b, task)_ +- `shepherd-cow-event-abi-wits` — Own the shepherd-cow event-ABI WITs at L3 _(phase S1b, task)_ +- `composable-poll-wire-swap` — Composable-cow poll wire-swap: delete composable.rs / LegacyRevertAdapter (fork-gated) _(phase deferred, task)_ +- `gap-docs-source-of-truth-rewrite` — Rewrite docs/05 + docs/08 as source-of-truth: venue persona is shipped; adapters are THE extension mechanism; cow-api is legacy read-path _(phase S1b, docs)_ + +**Moved-in existing issues (re-milestone here):** #138 (intent: CoW venue adapter and flagship module ports), #324 (cow: build the cow adapter component (adapter slice) with timeout transport middleware), #327 (modules: re-point twap-monitor onto pool submit/status via the cow adapter), #328 (modules: re-point ethflow-watcher onto the pool observe/status path via the cow adapter), #293 (cow: retire the legacy cow-api host shim and cow cone), #323 (cow: ratify or reconcile the retry classification table vs cowprotocol::ApiError::retry_hint), #320 (twap: one-block retry before dropping on InvalidEip1271Signature (same-block wiring+create race)), #121 (sdk+twap: treat DuplicatedOrder as already-submitted; add errorType→retry classification), #75 (twap-monitor retries unknown revert selectors every block forever), #48 (twap-monitor orphaned gate markers leak on decode-failure path), #54 (Support ComposableCoW v2 ConditionalOrderRemoved event), #64 (Grant M2 ComposableCoW contract-modification deliverable divergence) + +### S2 — The gated three-repo cut & delivery infrastructure + +*Repurposes milestone **#4**.* + +Repurposes M3 'Infrastructure'. The physical split, gated on all three cut gates (a runtime venue-agnostic, b cow on the generic seam, c genuine second-protocol venue). Transitional path-dep workspace in the three groupings; wit-deps flip + git-tag sourcing + wkg/OCI convergence; the cut go/no-go checklist; three history-preserving git-filter-repo carves (nexum-runtime / videre / CoW-on-videre) with the byte-identical tip oracle. Plus operator delivery: CI/CD hardening (incl. the sccache fork-PR fail-open #337), the multi-chain provider map + docs, ghcr packaging, and the Swarm remote-store backend. + +**New issues (create here):** +- `split-epic-s2` — Epic (S2): the gated repo cut - transitional workspace, WIT plumbing, three history-preserving carves _(phase S2, epic)_ +- `s2-transitional-workspace` — Transitional path-dep cargo workspace in the 3 groupings (+ git-tag pin path + dep-sync CI) _(phase S2, task)_ +- `s2-wit-cross-repo-consumption` — WIT cross-repo consumption: wit-deps flip + git-tag sourcing + wkg/OCI registry convergence _(phase S2, task)_ +- `s2-cut-gate-checklist` — Cut go/no-go gate: assert (a) runtime venue-agnostic + (b) cow on the seam + (c) second-protocol venue before any carve _(phase S2, task)_ +- `s2-three-carves` — Three history-preserving git-filter-repo carves: nexum-runtime / videre / CoW-on-videre _(phase S2, task)_ +- `host-wit-deps-flip-carve` — Flip nexum:host WIT to crate-local wit-deps and carve nexum-runtime as the L1 repo _(phase S2, task)_ + +**Moved-in existing issues (re-milestone here):** #274 (epic: split the shepherd instantiation into its own repository), #337 (ci: sccache R2 config (#336) hard-fails every fork PR — secrets don't flow to fork pull_request runs), #151 (remote-store: real Swarm backend), #125 (packaging: ghcr image name mismatch breaks fresh-server docker compose pull), #124 (docs: multi-chain deployment patterns (Mainnet/Arbitrum/Base/Gnosis)) + +### S3 — Second-venue acceptance & vocabulary freeze + +*Repurposes milestone **#10**.* + +Repurposes M6 'Second venue and vocabulary freeze'. The acceptance phase that de-risks R1: build a genuine non-cow second-protocol venue (rfq or amm-router, #140) against videre-sdk alone, exercising quote and surfaces CoW does not, feeding contract fixes back pre-cut. Cut gate (c). Then the videre:value-flow 1.0 freeze decisions (#330, retitled) and the curated adapter registry + consent surface (#141). + +**New issues (create here):** +- `split-epic-s3` — Epic (S3): second-venue acceptance - prove videre is genuinely venue-neutral _(phase S3, epic)_ + +**Moved-in existing issues (re-milestone here):** #140 (intent: postage adapter as N=2 and the value-flow freeze), #330 (wit: freeze-gate decisions for nexum:value-flow (asset-amount canonicalization, native-token settlement)), #141 (intent: curated adapter registry and consent surface) + +### videre SDK, macros & reth/alloy DX + +*Repurposes milestone **#5**.* + +Repurposes M4 'SDK and DX'. The venue/keeper author front door and DX build-out: videre-sdk (renamed from nexum-venue-sdk, + Keeper::sweep assembler + VenueClient), #[videre::venue] single blessed path, #[videre::keeper] + typed VenueClient, the videre-test conformance kit, guest SDK seams+Mocks for identity/messaging/remote-store, the alloy Provider chain seam, and the reth/alloy DX polish cluster (VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const). Plus the grant-scoped DX deliverables and residual nexum-sdk/macro consolidation. + +**New issues (create here):** +- `videre-sdk-crate` — videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient _(phase S1, task)_ +- `videre-venue-macro` — #[videre::venue]: single blessed authoring path emitting impl VenueAdapter _(phase S1, task)_ +- `videre-keeper-macro` — #[videre::keeper] macro + typed VenueClient _(phase S1b, task)_ +- `videre-conformance-kit` — videre-test: conformance kit + cargo-test-fails-if-wire-drifts gate _(phase S1, task)_ +- `host-backend-guest-seams` — Add guest SDK seams + Mocks for identity/messaging/remote-store, wired to the stub backends _(phase deferred, task)_ +- `gap-alloy-provider-seam` — Chain DX: alloy Provider seam over ChainHost::request (HostTransport: alloy Transport) + carry ChainMethod to the guest _(phase deferred, task)_ +- `gap-dx-polish-cluster` — reth/alloy DX polish cluster: VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const, kill *_to_golden bridges _(phase deferred, task)_ + +**Moved-in existing issues (re-milestone here):** #291 (local-store: add contains, len and count metadata queries), #264 (sdk: convert the bind-macro error/level shims to From impls), #127 (epic: grant delivery plan - remaining PRs, evidence runs, and sequencing to July 31), #136 (sdk: one SDK plan - nexum-sdk, macros, clean break of shepherd-sdk), #322 (sdk: make the IntentBody derive no_std (emit ::core/::alloc paths)) + +### Egress guard (real, teeth) + +*Repurposes milestone **#9**.* + +Keeps M5 'Egress guard' as the home for the REAL (non-AllowAll) guard, deferred wholly per decision 3 (M1 is advisory-only). Single-decode / derive-before-guard TOCTOU fix, move the checkpoint to the signed unsigned-tx / identity boundary, GuardPolicy::check sync->async, bring http egress under the compile-time world guarantee, messaging.query scope enforcement, mock-grant fidelity. Anchored by the rescoped guard epic #139 and gated on the real keystore identity backend #52. + +**New issues (create here):** +- `guard-derive-before-guard` — guard: close the derive-header-before-guard side-effect escape and the TOCTOU double-decode (single-decode the body through the checkpoint) _(phase deferred, task)_ +- `guard-signing-boundary` — guard: move the checkpoint to the signed unsigned-tx / identity boundary so the requires-signing path is covered _(phase deferred, task)_ +- `guard-policy-async` — guard: make GuardPolicy::check async (the real guard needs I/O: simulate, remote analyzers) _(phase deferred, task)_ +- `guard-egress-cap-world-guarantee` — guard: bring venue egress capabilities (http + import-narrowing) under the compile-time world guarantee _(phase deferred, task)_ +- `messaging-query-scope` — messaging: enforce messaging.query scope (goes live with the 0.3 Waku backend) _(phase deferred, bug)_ +- `mock-grant-fidelity` — capabilities: align mock capability-grant fidelity to the real host grant _(phase deferred, task)_ + +**Moved-in existing issues (re-milestone here):** #139 (guard: simulate, analyzers, policy, identity checkpoint), #52 (identity: real signing backend (keystore)) + +### Post-v1 hardening & debt (rolling) + +*Repurposes milestone **#6**.* + +Keeps M7 as the rolling, non-gating debt bucket. Deferred videre concepts (maker-side offer #355, RFQ firm-quote additive, Materialiser), doc-consistency passes, typed-fault/backend debt, test-harness/clock-seam debt, perf (parking_lot, bulk getLogs), the deferred messaging/remote-store backends and payload-codec convention, and the grant soak/reporting items. + +**New issues (create here):** +- `rfq-firm-quote-additive` — videre: RFQ firm-quote - additive firm: option on the quote record (taker-side, when a real RFQ venue appears) _(phase deferred, task)_ +- `materialiser-source-venue` — videre: Materialiser - the venue-neutral keeper materialiser (M7) _(phase deferred, task)_ + +**Moved-in existing issues (re-milestone here):** #355 (videre: add 'offer' / provide-liquidity (maker-side, two-sided venues) — post-0.1), #341 (docs: doc 02 says subscriptions wire up before init - actual boot order is the opposite), #302 (runtime: wide-range eth_getLogs bulk backfill for large log gaps), #289 (docs: typed-fault doc consistency pass (ADR-0011, rpc, migration, house-style)), #288 (chain: flatten request-batch's dead outer chain-error or record the escape hatch), #286 (sdk: From/TryFrom between the wit-bindgen Fault and the SDK Fault), #285 (runtime: richer typed faults for the remote-store, identity and messaging backends), #284 (test: give the supervisor's poison window and restart backoff a clock seam), #283 (test: grow the harness a multi-module variant and port the boot_single e2e tests), #280 (perf: migrate std::sync locks to parking_lot where not held across await), #269 (host: populate Fault::RateLimited.retry_after_ms and map 429/timeout by type), #212 (messaging: payload encoding convention for nexum-native topics), #152 (messaging: real Waku publish backend), #105 (state: batch and host-side filtered operations on the state seam), #65 (7-day unattended soak test not evidenced) + +## c. New issues to create + +| key | title | milestone | phase | depends_on | +|-----|-------|-----------|-------|------------| +| `split-epic-p0` | Epic (P0): free monorepo reshape - land the master gate that makes an acyclic split possible | M1: Intent core and CoW venue adapter | P0 | — | +| `host-r6-decouple` | R6: decouple nexum:host from nexum:intent - host event carries opaque status bytes (MASTER GATE) | M1: Intent core and CoW venue adapter | P0 | — | +| `gap-opaque-status-contract-spec` | Spec the opaque-status destructuring contract (versioned discriminator) that host event commits to - blocks R6 | videre-split: P0 (pre-fold) | P0 | — | +| `videre-epic` | Epic: videre - the generic intent-venue abstraction (L2) | M1: Intent core and CoW venue adapter | P0 | host-r6-decouple, host-seam-generalize | +| `videre-wit-rename` | videre: rename nexum:intent/* WIT + readability renames -> videre:* | M1: Intent core and CoW venue adapter | P0 | host-r6-decouple | +| `videre-wit-surface` | videre: pin the videre:* WIT surface (types / venue / value-flow) | M1: Intent core and CoW venue adapter | P0 | videre-wit-rename | +| `videre-wit-normalize` | videre: normalize all WIT packages to a single @0.1.0 | M1: Intent core and CoW venue adapter | P0 | videre-wit-rename | +| `videre-quote` | videre: add quote to videre:venue (client + adapter) + IntentClient typestate | M1: Intent core and CoW venue adapter | P0 | videre-wit-surface | +| `videre-body-versions-handshake` | videre: install-time body-versions schema handshake | M1: Intent core and CoW venue adapter | S1 | videre-wit-surface, host-seam-generalize | +| `gap-handshake-manifest-key-decision` | Decide the install-time handshake manifest key (body_version vs version-set) + supported-set match semantics | videre-split: P0 (decisions) | P0 | — | +| `gap-p0-wit-fold-execution` | Execute Phase 0 as ONE oracle-validated git-filter-repo/jj fold across the M1 train (regenerate goldens, re-assert tip oracle) | videre-split: P0 (fold) | P0 | host-r6-decouple, gap-opaque-status-contract-spec, videre-wit-rename, videre-wit-normalize, videre-quote, videre-wit-surface, gap-p0-fold-tail-hygiene | +| `gap-p0-fold-tail-hygiene` | P0 fold tail: codec version discriminator (#297), migration-cruft deletion, denied() MUST-NOT-retry doc | videre-split: P0 (fold) | P0 | videre-wit-surface | +| `p0-acyclicity-scaffold` | Land the acyclicity / zero-leak CI gate for nexum-runtime (advisory in P0, blocking in S1) | M3: Infrastructure | P0 | — | +| `guard-advisory-m1` | guard: ship advisory-only posture for M1 (keep AllowAll, feature-gate the pool import, document the checkpoint as not-yet-enforcing) | M1: Intent core and CoW venue adapter | P0 | egress-guard-hardening-epic | +| `guard-deny-quota` | guard: charge quota on guard-deny to close the busy-loop DoS | M5: Egress guard | P0 | egress-guard-hardening-epic | +| `gap-m1-green-tip-gate` | Gate: finish M1 to a single green linear dev/m1 tip before any carve | M1 (green tip) | P0 | gap-p0-wit-fold-execution, guard-deny-quota, videre-body-versions-handshake | +| `host-generalize-epic` | Epic: make nexum-runtime a generic, venue-agnostic component host | M0: Runtime architecture and lifecycle | S1 | — | +| `split-epic-s1` | Epic (S1): make nexum-runtime venue-agnostic - generalize the Extension seam (the long pole) | M0: Runtime architecture and lifecycle | S1 | split-epic-p0 | +| `host-extension-seam-roles` | Grow the Extension seam to carry worker/provider roles (service + provider, ProviderKind, HostService) | M0: Runtime architecture and lifecycle | S1 | host-r6-decouple | +| `host-venue-registry-extract` | Extract PoolRouter -> VenueRegistry as an extension-owned service; delete the privileged supervisor field | M0: Runtime architecture and lifecycle | S1 | host-extension-seam-roles | +| `host-generic-component-kind` | Extract a generic supervised-component/host-actor primitive from AdapterActor; collapse match kind (folds R8) | M0: Runtime architecture and lifecycle | S1 | host-extension-seam-roles | +| `host-nexum-world-registry` | De-hardcode the KNOWN capability table (drop baked pool + cow-api rows); extract world synthesis to nexum-world lib | M0: Runtime architecture and lifecycle | S1 | host-extension-seam-roles | +| `host-zero-leak-ci-gate` | CI gate: nexum-runtime has zero venue/intent/cow symbols | M0: Runtime architecture and lifecycle | S1 | host-venue-registry-extract, host-nexum-world-registry, host-generic-component-kind | +| `host-generic-launcher-bin` | Extract a generic launcher lib + bare Ext=() nexum engine bin (retire the backwards nexum-cli -> cow dep) | M0: Runtime architecture and lifecycle | S1 | host-extension-seam-roles | +| `s1-gate-runtime-venue-agnostic` | GATE (a): prove nexum-runtime is venue-agnostic - flip the zero-leak CI check to blocking | M0: Runtime architecture and lifecycle | S1 | p0-acyclicity-scaffold | +| `gap-videre-host-platform-crate` | Build the videre-host crate + videre::platform() registration (VenueRegistry + provider-kind + EgressGuard seam + bindgens) | videre-split: S1 (generalization) | S1 | host-extension-seam-roles, host-venue-registry-extract, host-generic-component-kind | +| `adapter-supervision-sweeps` | supervisor: fold venue adapters into the restart/poison sweeps and expose adapters_alive (R8) | M5: Egress guard | S1 | egress-guard-hardening-epic | +| `split-epic-s1b` | Epic (S1b): CoW on the generic seam - real adapter cdylib + keeper on videre:venue/client | M2: Concrete CoW modules | S1b | split-epic-s1 | +| `s1b-gate-cow-on-generic-seam` | GATE (b): CoW rides the generic seam - keeper on videre:venue/client, CowApiHost retired | M2: Concrete CoW modules | S1b | s1-gate-runtime-venue-agnostic | +| `cleave-cow-venue` | Cleave cow-venue: orderbook-only venue vs composable-cow keeper | M1: Intent core and CoW venue adapter | S1b | cow-onvidere-epic | +| `cow-idempotency-seam` | Settle the CoW idempotency seam before order assembly moves into the adapter | M1: Intent core and CoW venue adapter | S1b | cow-onvidere-epic, cleave-cow-venue | +| `shepherd-cow-event-abi-wits` | Own the shepherd-cow event-ABI WITs at L3 | M2: Concrete CoW modules | S1b | cow-onvidere-epic | +| `composable-poll-wire-swap` | Composable-cow poll wire-swap: delete composable.rs / LegacyRevertAdapter (fork-gated) | M2: Concrete CoW modules | deferred | cow-onvidere-epic, composable-cow-keeper-port | +| `gap-docs-source-of-truth-rewrite` | Rewrite docs/05 + docs/08 as source-of-truth: venue persona is shipped; adapters are THE extension mechanism; cow-api is legacy read-path | videre-split: S1b | S1b | cow-api-retire | +| `split-epic-s2` | Epic (S2): the gated repo cut - transitional workspace, WIT plumbing, three history-preserving carves | M3: Infrastructure | S2 | split-epic-s1b | +| `s2-transitional-workspace` | Transitional path-dep cargo workspace in the 3 groupings (+ git-tag pin path + dep-sync CI) | M3: Infrastructure | S2 | s1-gate-runtime-venue-agnostic, s1b-gate-cow-on-generic-seam | +| `s2-wit-cross-repo-consumption` | WIT cross-repo consumption: wit-deps flip + git-tag sourcing + wkg/OCI registry convergence | M3: Infrastructure | S2 | s2-transitional-workspace | +| `s2-cut-gate-checklist` | Cut go/no-go gate: assert (a) runtime venue-agnostic + (b) cow on the seam + (c) second-protocol venue before any carve | M3: Infrastructure | S2 | s1-gate-runtime-venue-agnostic, s1b-gate-cow-on-generic-seam, s3-gate-second-protocol-venue | +| `s2-three-carves` | Three history-preserving git-filter-repo carves: nexum-runtime / videre / CoW-on-videre | M3: Infrastructure | S2 | s2-cut-gate-checklist, s2-transitional-workspace, s2-wit-cross-repo-consumption | +| `host-wit-deps-flip-carve` | Flip nexum:host WIT to crate-local wit-deps and carve nexum-runtime as the L1 repo | M6: Second venue and vocabulary freeze | S2 | host-zero-leak-ci-gate, host-generic-launcher-bin | +| `split-epic-s3` | Epic (S3): second-venue acceptance - prove videre is genuinely venue-neutral | M6: Second venue and vocabulary freeze | S3 | split-epic-s1 | +| `videre-sdk-crate` | videre-sdk: rename nexum-venue-sdk + add Keeper::sweep assembler + VenueClient | M4: SDK and DX | S1 | videre-wit-surface, host-seam-generalize | +| `videre-venue-macro` | #[videre::venue]: single blessed authoring path emitting impl VenueAdapter | M4: SDK and DX | S1 | videre-sdk-crate | +| `videre-keeper-macro` | #[videre::keeper] macro + typed VenueClient | M4: SDK and DX | S1b | videre-venue-macro, videre-quote | +| `videre-conformance-kit` | videre-test: conformance kit + cargo-test-fails-if-wire-drifts gate | M4: SDK and DX | S1 | videre-sdk-crate | +| `host-backend-guest-seams` | Add guest SDK seams + Mocks for identity/messaging/remote-store, wired to the stub backends | M4: SDK and DX | deferred | — | +| `gap-alloy-provider-seam` | Chain DX: alloy Provider seam over ChainHost::request (HostTransport: alloy Transport) + carry ChainMethod to the guest | post-M1: DX build-out (Phase 4) | deferred | — | +| `gap-dx-polish-cluster` | reth/alloy DX polish cluster: VenueFault mirror, Order builder, uniform non_exhaustive, sealed traits, single-source fault/KNOWN const, kill *_to_golden bridges | post-M1: DX build-out (Phase 4) | deferred | — | +| `guard-derive-before-guard` | guard: close the derive-header-before-guard side-effect escape and the TOCTOU double-decode (single-decode the body through the checkpoint) | M5: Egress guard | deferred | egress-guard-hardening-epic | +| `guard-signing-boundary` | guard: move the checkpoint to the signed unsigned-tx / identity boundary so the requires-signing path is covered | M5: Egress guard | deferred | egress-guard-hardening-epic, #52, #139 | +| `guard-policy-async` | guard: make GuardPolicy::check async (the real guard needs I/O: simulate, remote analyzers) | M5: Egress guard | deferred | egress-guard-hardening-epic | +| `guard-egress-cap-world-guarantee` | guard: bring venue egress capabilities (http + import-narrowing) under the compile-time world guarantee | M5: Egress guard | deferred | egress-guard-hardening-epic | +| `messaging-query-scope` | messaging: enforce messaging.query scope (goes live with the 0.3 Waku backend) | M5: Egress guard | deferred | egress-guard-hardening-epic | +| `mock-grant-fidelity` | capabilities: align mock capability-grant fidelity to the real host grant | M5: Egress guard | deferred | egress-guard-hardening-epic | +| `rfq-firm-quote-additive` | videre: RFQ firm-quote - additive firm: option on the quote record (taker-side, when a real RFQ venue appears) | M7: Post-v1 hardening and debt | deferred | #355 | +| `materialiser-source-venue` | videre: Materialiser - the venue-neutral keeper materialiser (M7) | M7: Post-v1 hardening and debt | deferred | — | + +*(56 new issues. `depends_on` entries prefixed `#` are existing tracker issues; bare keys are other new issues in this table. Entries like `host-seam-generalize` in a few drafts are alias references to the seam-generalization work now owned by `host-extension-seam-roles` + `gap-videre-host-platform-crate`.)* + +## d. Existing issues to CLOSE + +| # | title | reason | +|---|-------|--------| +| #339 | docs: migration guide still calls the manifest `nexum.toml` throughout (canonical is `module.toml`) | Obsolete: fixes nexum.toml->module.toml inside docs/migration/0.1-to-0.2.md, which is already deleted (HEAD 7c66b6c) as Phase-0 migration cruft. Fixing a deleted file is moot. | +| #287 | cow-ext: map reqwest timeout and 429 to Fault::Timeout / Fault::RateLimited | Targets shepherd-cow-host ext_cow.rs - the legacy cow-api extension that cow-api-retire (#293) deletes. The timeout/429->typed-fault requirement is carried forward by the cow-venue adapter's errorType->venue-error projection + the R1 venue-error reshape; the host-chain equivalent lives on as #269. | +| #222 | sdk: keeper ConditionalSource trait, retry dispatch (Retrier), and cow::run loop | Delivered by #239: ConditionalSource/Retrier/RetryAction/cow::run landed in nexum-sdk/keeper.rs with the M1 train. Forward Verdict/Keeper::sweep rework is carried by videre-sdk-crate. | +| #137 | intent: WIT packages, venue-adapter world, pool router | Delivered by the M1 train (#226-#234): nexum:value-flow+nexum:intent WIT, venue-adapter world, PoolRouter, nexum-venue-sdk, conformance kit, echo-venue. Successor is the drafted videre-epic (rename/quote/normalize/R6), not a reopen. | +| #135 | sdk: extract the venue-generic strategy chassis | Delivered by the M1 train (#146/#222/#148/#147/#149): the keeper primitives + single-venue loop landed in nexum-sdk/keeper.rs. Deferred generalization is videre-sdk-crate (Keeper::sweep) + materialiser-source-venue (M7). | +| #131 | docs: record the intent architecture (venue adapters, egress guard) | Docs-only, delivered by PR #132 (docs 08/09 + egress-guard ADR); the design docs now exist. Go-forward doc work is gap-docs-source-of-truth-rewrite. | +| #7 | Roadmap: v0.3 — make the runtime real | Stale pre-restructure roadmap epic, no milestone. Its goal is largely delivered (chain/local-store/logging live) and its workstreams are decomposed into M0-M7 + individual issues (#52/#152/#151/#285). Nothing tracks against it. | + +## e. MODIFY / rescope + +| # | title | change | +|---|-------|--------| +| #139 | guard: simulate, analyzers, policy, identity checkpoint | Rescope to fold the venue-platform R3/R5/R8 router/capability/lifecycle hardening the guard engine epic did not enumerate (single-decode/derive-before-guard, signed-tx boundary, GuardPolicy async, http-under-world-guarantee, messaging.query scope, adapter sweeps, mock fidelity) as children. Keep M5. This IS the drafted egress-guard-hardening-epic; nothing shipped. Depends on #52 (identity) and stays advisory-only for M1. | +| #330 | wit: freeze-gate decisions for nexum:value-flow (asset-amount canonicalization, native-token settlement) | Retitle the package under the videre rename: nexum:value-flow -> videre:value-flow. Keep the two freeze-gate ontology decisions (minimal-length canonical amount encoding; native-token representable-but-invalid) - NOT addressed by the Phase-0 named-records reshape. Stays M6/S3 as the freeze gate, distinct from videre-wit-surface (reshape, not freeze). | +| #289 | docs: typed-fault doc consistency pass (ADR-0011, rpc, migration, house-style) | Drop the docs/migration/0.1-to-0.2.md bullet (that file is deleted as Phase-0 cruft). Keep the still-valid items: ADR-0011 {errorType,description,data} restoration, docs/07 rpc data-payload callout, docs/production.md house-style pass, stale .mmd/.png regen. Stays M7. Distinct from gap-docs-source-of-truth-rewrite (docs/05+08 venue-persona). | +| #274 | epic: split the shepherd instantiation into its own repository | Rescope from a TWO-repo split (core + shepherd instantiation) to the THREE-repo umbrella (nexum-runtime <- videre <- CoW-on-videre) spanning the drafted split-epic family (split-epic-p0/s1/s1b/s2/s3; closest single correspondent split-epic-s2, the physical cut). Update the two-way partition to three-way; fold its post-split preset items into the generalization. Re-milestone M7->M3. | +| #273 | runtime: Runtime preset trait capability gaps (extensions and pre-built instances) | Rescope: the preset/Runtime-trait launch-surface ask is subsumed by the S1 seam generalization (host-extension-seam-roles + gap-videre-host-platform-crate grow Extension; host-generic-launcher-bin retires the backwards nexum-cli->cow dep with a bare Ext=() bin). Residual = the MockRuntime preset path (rolls into host-backend-guest-seams/#80). Re-milestone M7->M0. | +| #136 | sdk: one SDK plan - nexum-sdk, macros, clean break of shepherd-sdk | Rescope: premise is now wrong - the videre split does NOT retire shepherd-sdk (recast as the L3 CoW keeper crate, kept). Residual: (a) nexum-venue-sdk->videre-sdk rename is videre-sdk-crate; (b) 'clean break / workspace-member removal' becomes the S2 carve (s2-three-carves). Retitle to the surviving nexum-sdk/macro consolidation; re-milestone M1->M4. | + +## f. MERGE + +| from → into | titles | +|-------------|--------| +| #325, #326 → #324 | #325 cow: publish golden vectors and wire the conformance kit ; #326 cow: bundle the cow adapter into the distribution (config-installed, not compiled in) → **#324 cow: build the cow adapter component (adapter slice) with timeout transport middleware** | +| #329 → #293 | #329 sdk: retire the shepherd-sdk workspace member and migrate remaining dependents → **#293 cow: retire the legacy cow-api host shim and cow cone** | + +## g. DEDUP (drafted key ↔ existing #) + +These drafted issues are **not created** — the existing tracker issue already covers them (rescope the existing one per section e where noted). + +| drafted key | existing # | existing title | +|-------------|-----------|----------------| +| `host-identity-signing-backend` | #52 | identity: real signing backend (keystore) | +| `egress-guard-hardening-epic` | #139 | guard: simulate, analyzers, policy, identity checkpoint | +| `cow-onvidere-epic` | #138 | intent: CoW venue adapter and flagship module ports | +| `cow-venue-cdylib` | #324 | cow: build the cow adapter component (adapter slice) with timeout transport middleware | +| `cow-api-retire` | #293 | cow: retire the legacy cow-api host shim and cow cone | +| `ethflow-keeper` | #328 | modules: re-point ethflow-watcher onto the pool observe/status path via the cow adapter | +| `composable-cow-keeper-port` | #327 | modules: re-point twap-monitor onto pool submit/status via the cow adapter | +| `s3-gate-second-protocol-venue` | #140 | intent: postage adapter as N=2 and the value-flow freeze | + +## h. Ordered apply sequence + +Respects phase order (P0 → S1 → S1b → S2 → S3) and the R6 master gate. Steps 1–4 are pure bookkeeping and can be done immediately; issue **creation** (step 5+) follows the dependency chain. + +**Step 1 — Repurpose the 8 milestones (retitle + recharter in place).** +- Rename each existing milestone to its new charter name (section b): #8→P0, #7→S1, #3→S1b, #4→S2, #5→videre SDK/DX, #9→Egress guard, #10→S3, #6→Post-v1 debt. +- No milestones are created or deleted; milestone #1 stays dissolved/closed. + +**Step 2 — Close the 7 delivered/obsolete issues (clears the board first).** +- Close #339 — docs: migration guide still calls the manifest `nexum.toml` throughout (canonical is `module.toml`). +- Close #287 — cow-ext: map reqwest timeout and 429 to Fault::Timeout / Fault::RateLimited. +- Close #222 — sdk: keeper ConditionalSource trait, retry dispatch (Retrier), and cow::run loop. +- Close #137 — intent: WIT packages, venue-adapter world, pool router. +- Close #135 — sdk: extract the venue-generic strategy chassis. +- Close #131 — docs: record the intent architecture (venue adapters, egress guard). +- Close #7 — Roadmap: v0.3 — make the runtime real. + +**Step 3 — Resolve the 2 merge groups.** +- Fold #325 + #326 into #324 (single cow-adapter-cdylib deliverable); close #325/#326 as merged. +- Fold #329 into #293 (cow-api-cone retirement); close #329 as merged. + +**Step 4 — Rescope/retitle/re-milestone the 6 modify issues (section e).** +- #139 → the egress-guard hardening epic (M5, keep). #330 → videre:value-flow freeze (M6). #289 → drop the deleted-file bullet, keep the rest (M7). #274 → three-repo umbrella (M3). #273 → subsumed by S1 seam, keep MockRuntime residual (M0). #136 → nexum-sdk/macro consolidation (M4). + +**Step 5 — P0: create the master-gate chain FIRST (milestone #8 / P0).** +- Create `gap-opaque-status-contract-spec` (design note the gate implements against) and `gap-handshake-manifest-key-decision` — no deps. +- Create `split-epic-p0` and `videre-epic` (epics). +- Create `host-r6-decouple` (THE MASTER GATE) — it and the videre WIT-reshape issues (`videre-wit-rename` → `videre-wit-surface`/`videre-wit-normalize`/`videre-quote`) all land together in ONE fold. +- Create the fold-execution + hygiene owners: `gap-p0-fold-tail-hygiene`, `gap-p0-wit-fold-execution`. +- Create `p0-acyclicity-scaffold` (advisory CI), `guard-advisory-m1`, `guard-deny-quota`, `videre-body-versions-handshake`, and the tip gate `gap-m1-green-tip-gate`. +- Execute the Phase-0 fold; land M1 to a single green linear `dev/m1` tip (gap-m1-green-tip-gate) before ANY carve. + +**Step 6 — S1: generalize the host (milestone #7 / S1). Blocked on R6.** +- Create epics `host-generalize-epic`, `split-epic-s1`. +- Create `host-extension-seam-roles` (the long pole) → then `host-venue-registry-extract`, `host-generic-component-kind`, `host-nexum-world-registry`, `host-generic-launcher-bin`, `gap-videre-host-platform-crate`, `adapter-supervision-sweeps`. +- Create `host-zero-leak-ci-gate`; then `s1-gate-runtime-venue-agnostic` — flip the zero-leak check to BLOCKING; delete `HostState.pool_router`. This is cut gate (a). + +**Step 7 — videre SDK/DX (milestone #5) — proceeds alongside S1 once the seam exists.** +- Create `videre-sdk-crate` → `videre-venue-macro`, `videre-conformance-kit`; then `videre-keeper-macro`. Plus `host-backend-guest-seams`, `gap-alloy-provider-seam`, `gap-dx-polish-cluster`. + +**Step 8 — S1b: CoW on the generic seam (milestone #3). Blocked on gate (a).** +- Create epic `split-epic-s1b`; then `cleave-cow-venue` → `cow-idempotency-seam` → `cow-venue-cdylib` (=#324, rescope not create) → `composable-cow-keeper-port` (=#327) → `ethflow-keeper` (=#328); `shepherd-cow-event-abi-wits`; `cow-api-retire` (=#293); `gap-docs-source-of-truth-rewrite`. +- Create `s1b-gate-cow-on-generic-seam` — cut gate (b). Keep `composable-poll-wire-swap` deferred/fork-gated and DECOUPLED from the keeper port. + +**Step 9 — S3 gate (milestone #10) — needed as cut gate (c), runs in parallel after gate (a).** +- Create epic `split-epic-s3`; `s3-gate-second-protocol-venue` (=#140, rescope) — a genuine non-cow venue against videre-sdk alone. Gate (c). + +**Step 10 — S2: the gated cut (milestone #4). Blocked on gates a+b+c ALL green.** +- Create epic `split-epic-s2`; `s2-transitional-workspace` → `s2-wit-cross-repo-consumption`; `s2-cut-gate-checklist` (asserts a+b+c); `host-wit-deps-flip-carve`; finally `s2-three-carves` (the three history-preserving carves). NOTHING carves until the checklist is green. + +**Step 11 — Egress guard, real teeth (milestone #9) — deferred, gated on #52 + #139.** +- Rescoped #139 is the epic. Create children `guard-derive-before-guard`, `guard-signing-boundary`, `guard-policy-async`, `guard-egress-cap-world-guarantee`, `messaging-query-scope`, `mock-grant-fidelity`. (`host-identity-signing-backend` = #52, rescope not create.) + +**Step 12 — Rolling debt (milestone #6).** +- Create `rfq-firm-quote-additive` (dep #355) and `materialiser-source-venue` when their prerequisites (a second real venue) exist. + +> Two internal-overlap seams to coordinate at implementation time (flagged in the reconcile summary): `host-generic-component-kind` vs `adapter-supervision-sweeps` (both fold R8), and `host-wit-deps-flip-carve` vs `s2-wit-cross-repo-consumption`/`s2-three-carves` (the L1 slice of the carve). diff --git a/docs/design/linker-extension-seam.md b/docs/design/linker-extension-seam.md index 76525a92..f24d4b19 100644 --- a/docs/design/linker-extension-seam.md +++ b/docs/design/linker-extension-seam.md @@ -97,11 +97,14 @@ hands the extension its own entry to parse into a typed struct (cow-api's `CowConfig` reads `[extensions.cow]`, today one `orderbook_urls` per-chain map). -## Normative rule: elision and boot ordering - -Modules are compiled against the supertype world. The `wasm-tools` pipeline -elides any WIT import the produced component does not exercise, so a module -that never touches cow-api boots with a core-only linker. A module that DOES +## Normative rule: import narrowing and boot ordering + +Modules built through `#[nexum_sdk::module]` compile against a per-module +world derived from their manifest's `[capabilities]`, so a module that +never declares cow-api has no cow-api import and boots with a core-only +linker by construction. Hand-rolled modules compiled against the supertype +world reach the same shape a weaker way: the `wasm-tools` pipeline elides +any WIT import the produced component does not exercise. A module that DOES import an extension interface instantiates only if, before instantiation: - the extension's linker hook is registered (else an unsatisfied-import trap), AND diff --git a/docs/design/m1-review-sweep-triage.md b/docs/design/m1-review-sweep-triage.md new file mode 100644 index 00000000..b9093b39 --- /dev/null +++ b/docs/design/m1-review-sweep-triage.md @@ -0,0 +1,83 @@ + + +# M1 Train Review-Comment Sweep — Cross-Train Triage + +**Headline.** 53 unresolved threads were swept across 15 cars (#240–#297, #334). **47 are STILL_LIVE** at the end-of-train tip (`origin/refactor/rename-chassis-to-keeper`), **5 are RESOLVED_DOWNSTREAM**, and **1 is SUPERSEDED** (chassis→keeper rename dissolved its anchor). Zero RESOLVED_IN_CAR, zero MOOT. Of the 47 live, **19 are med** (no high survives — both highs were resolved downstream) and **28 are doc/DX nits**. Nothing is merge-blocking; the ADR-0013 Verdict seam and the chassis→keeper rename reworded surrounding code but left almost every flagged line substantively intact. + +## Still live — act on these + +### Med severity (correctness / contract / coverage) + +| PR | file:line | sev | issue | action | +|----|-----------|-----|-------|--------| +| 249 | host/pool_router.rs (via adapter.wit):14 | med | `derive-header` doc claims purity the WIT can't enforce; router now calls `derive_header` **before** `guard.check` — side effects escape policy | fix (doc caveat / sub-world) | +| 249 | host/impls/messaging.rs:50 | med | `messaging.query` not scope-checked while `publish` is; scope hole goes live with 0.3 Waku backend | leave-open | +| 249 | supervisor.rs:630 | med | Missing-manifest surfaces as misleading "declares module kind EventModule"; error should branch missing vs wrong-kind | fix | +| 248 | wit/nexum-intent/types.wit:48 | med | `valid-until` doc says ms but SDK `Tick.epoch_s` is seconds — silent /1000; rename `valid-until-ms` before 0.1.0 freeze | leave-open | +| 248 | wit/nexum-intent/types.wit:124 | med | `denied()` rides the same error channel as retryable variants but lacks MUST-NOT-retry guidance | leave-open | +| 247 | wit/nexum-value-flow/types.wit:72 | med | ERC arms use anonymous positional tuples; named records would be self-documenting + non-breaking-extensible pre-freeze | leave-open (design call) | +| 250 | host/pool_router.rs:341 | med | Guard-deny path doesn't charge quota → busy-loop DoS (LATENT: only AllowAllGuard ships in M1); reviewer's one-liner fix is clean | fix | +| 250 | host/pool_router.rs:77 | med | `GuardPolicy::check` sync; egress-guard epic needs async I/O — later change is breaking | leave-open (epic owner) | +| 251 | nexum-venue-sdk/src/faults.rs:130 | med | `RateLimited` arm untested — the one fold that drops structured data (`retry_after_ms`) | fix (add test) | +| 242 | shepherd-backtest/src/replay.rs:170 | med | `classify_ok` buckets any `app_data_resolved==None` as RejectedExpected, hiding unrelated failures (backtest only) | leave-open | +| 242 | modules/examples/stop-loss/src/strategy.rs:98 | med | Dedup-key asymmetry (`server_uid` write vs `uid_hex` read) → re-POST until validTo on UID drift | leave-open | +| 334 | shepherd-sdk/src/cow/composable.rs:76 | med | `Verdict::Post.next_poll_timestamp:u64` `0`-sentinel collides with fork wire semantics; model as `Option`/`NextPoll` now (no-op today) | fix | +| 334 | shepherd-sdk/src/cow/run.rs:67 | med | `NeedsInput` arm info-logs+busy-polls every tick, no dispatch test (LATENT until fork makes it reachable) | fix | +| 243 | crates/nexum-macros/src/lib.rs:68 | med | Handler match is name-only → async `on_block` compiles to `.await`-less call with opaque error | leave-open | +| 296 | crates/nexum-venue-sdk/src/lib.rs:75 | med | "undeclared capability = compile error" doesn't hold (blanket chain+messaging shims); two adapter contracts coexist, neither canonical | leave-open (spans #297) | +| 296 | modules/examples/echo-venue/Cargo.toml:16 | med | Pins wit-bindgen 0.58 vs workspace 0.59 → duplicate tree in Cargo.lock | fix (bump) | +| 297 | nexum-venue-test/src/transport.rs:9 | med | Mock grant fidelity diverges from host (exact-match vs path-prefix, gates query host doesn't, no fetch/chain scope); doc's "exactly as the host would" false | leave-open | +| 297 | nexum-venue-test/src/codec.rs:162 | med | Empty vector set passes `check` vacuously; re-encode-divergence branch untested | leave-open | +| 297 | nexum-venue-test/src/codec.rs:23 | med | Cross-language vector/golden files carry no version discriminator + `deny_unknown_fields` → additive field hard-fails old kits | leave-open | + +### Nits (doc-wording / DX polish — mostly lgahdl) + +| PR | file:line | issue | action | +|----|-----------|-------|--------| +| 251 | nexum-venue-sdk/src/adapter.rs:26 | `derive_header` purity claim WIT can't enforce | fix | +| 251 | nexum-venue-sdk/src/client.rs:90 | "carries no Display" false — wit-bindgen 0.58 generates Display for error-slot | fix | +| 248 | wit/nexum-intent/types.wit:14 | wrongly calls `none` a Python keyword (real risk: Rust bindgen title-casing) | fix | +| 248 | wit/nexum-intent/types.wit:76 | `settled(option<...>)` None case undocumented | leave-open | +| 247 | wit/nexum-value-flow/types.wit:66 | BZZ cited as native gas token (it's an ERC-20) | fix | +| 247 | wit/nexum-value-flow/types.wit:85 | `amount` field lacks its own doc (WIT renders field docs separately) | fix | +| 249 | engine_config.rs:132 | "may reach" → "may publish to" (only publish gated) | fix | +| 250 | host/pool_router.rs:286 | `quota_admits` doc says "Read-only" but it inserts/prunes | fix | +| 240 | crates/nexum-sdk/src/keeper.rs:337 | `ConditionalSource::label` doc "compositions"→"implementations", inverted subject-verb | fix | +| 240 | modules/twap-monitor/src/strategy.rs:111 | doc uses informal "row" vs SDK "watch"/WatchSet terminology | fix | +| 242 | shepherd-sdk/src/cow/run.rs:63 | permanent watch removal logged at info, not warn | leave-open | +| 242 | shepherd-sdk/src/cow/order.rs:85 | new-chain advisory buried mid-sentence; wants `# Note` | leave-open | +| 243 | nexum-macros/src/lib.rs:31 | "the Guest impl" ambiguous + missing `clippy::too_many_arguments` hint | leave-open | +| 243 | nexum-macros/src/lib.rs:133 | `__NexumModuleExport` non-hygienic name (collision precluded by 1-module-per-cdylib) | leave-open | +| 246 | nexum-macros/src/lib.rs:94 | unverified "must not shadow std prelude names" corollary (now duplicated at :303) | leave-open | +| 246 | nexum-macros/src/lib.rs:146 | `on_`-handler error only says "rename"; should suggest separate impl block | leave-open | +| 246 | docs/migration/0.1-to-0.2.md:432 | ambiguous "Earlier drafts of this section" | leave-open | +| 245 | docs/05-sdk-design.md:181 | move "Handlers are synchronous" callout before the code example | leave-open | +| 296 | supervisor/tests.rs:257 | pin-test comment "never depended on toolchain elision" contradicts sibling docs | leave-open | +| 296 | justfile:11 | aggregate `build` recipe omits `build-venue` → dev flow SKIPs pin test (CI half now covered) | leave-open | +| 297 | nexum-venue-test/src/reference.rs:240 | drift-assert instructs blind regeneration vs diagnosing as regression | leave-open | +| 297 | nexum-venue-test/src/codec.rs:34 | duplicate vector names accepted; `Expectation` lacks `deny_unknown_fields` | leave-open | +| 334 | shepherd-sdk/src/cow/composable.rs:60 | enum doc "Post is the only variant never produced" contradicts NeedsInput doc | fix | +| 258 | crates/cow-venue/src/composable.rs:26 | unbounded `static_input: Vec` OOM note (borsh incremental-alloc already mitigates; cap belongs at ingest) | leave-open | +| 241 | shepherd-sdk-test/src/lib.rs:316 | `enqueue_response` doc omits re-call-extends-sequence footgun | leave-open | +| 241 | shepherd-sdk-test/src/lib.rs:281 | `MockVenue` "observations" jargon vs idempotent-server-state framing | leave-open | +| 241 | nexum-sdk-test/src/lib.rs:205 | `MockLocalStore` doc omits root namespace key = `""` | leave-open | +| 241 | nexum-sdk-test/src/lib.rs:236 | `namespaced()` Panics doc gives wrong reason (should be `""` aliases root) | leave-open | + +## Superseded / resolved — safe to close + +| PR | count | why | +|----|-------|-----| +| 240 | 1 | **ADR-0013 Verdict seam** — `poll_one` now warns with revert selector + node message on the permanent-drop path (strictly more than asked) | +| 243 | 1 | **Per-module world synthesis** (commit `3f21565`) — hardcoded `shepherd:cow/shepherd` world gone; modules import only declared `[capabilities]`, so the spurious cow-api import is structurally eliminated | +| 245 | 3 | **Doc fixed downstream** ×2 (`CowApiHost` now names `cow_api_request`; `nexum::module` vs `nexum_sdk::module` reconciled by explicit gloss) + **1 superseded** by keeper rename (chassis anchor no longer exists) | +| 296 | 1 | **CI + e2e coverage added** — CI now builds `echo-venue`/`echo-client` (pin test executes, not SKIP) and a real `Supervisor::boot` round-trip test exercises the macro WIT end-to-end | + +## Patterns + +- **Doc/wording nits dominate** (28 of 47 live), almost all from **lgahdl**, most flagged "leave-open" as low-value end-of-train polish. A large share are byte-identical to the car head — later cars reworded neighbours but never touched the flagged line. +- **Same defect recurs across the venue-adapter surface**: the `derive_header` "pure derivation, no side effects" claim the WIT world cannot enforce appears on **both #249 (adapter.wit:14) and #251 (adapter.rs:26)** — and #249 is now *materially* live because the landed router calls `derive_header` before `guard.check`. +- **Watch-drop diagnostic visibility** (info-vs-warn, silent disappearance) recurs: #240 (resolved downstream), #242 run.rs:63, #334 run.rs:67. +- **Pre-freeze 0.1.0 WIT contract debt** clusters on #247/#248: unit-in-name ambiguity, anonymous vs named ADTs, missing field-level docs, retry semantics, factual token errors — the reviewer consistently pushed to fix these *before* the freeze. +- **"Latent until epic/fork lands"** is a repeated shape for the med items: guard-Deny unreachable (only `AllowAllGuard` ships) #250, `NeedsInput` unreachable #334, `messaging.query` scope hole until 0.3 #249, async guard trait #250 — cheap now, breaking later. +- **Conformance-kit hardening** (#297): recurring "vacuous pass / missing version discriminator / missing `deny_unknown_fields`" theme on the cross-language file formats — same class of guard-gap flagged four times. +- **wit-bindgen version/behaviour misstatements**: the "carries no Display" claim (#251) and the 0.58-vs-0.59 pin skew (#296) both trace to bindgen-version assumptions. diff --git a/docs/design/venue-platform-architecture.md b/docs/design/venue-platform-architecture.md new file mode 100644 index 00000000..6c768545 --- /dev/null +++ b/docs/design/venue-platform-architecture.md @@ -0,0 +1,748 @@ +# Venue Platform Architecture + +**Status:** Architecture report — decision-ready +**Baseline:** `origin/refactor/rename-chassis-to-keeper` (last car #260 + Wave-1 #334/#335), M1 train mid-flight +**Audience:** nullislabs/shepherd platform team +**Author:** lead architect + +> This report synthesises five grounded lens sweeps and an adversarial review against the +> actual tree. Where a lens and the code disagreed, the code wins; where the adversary +> corrected a lens, the correction is folded in. The sharpest verified risks are carried +> into the migration plan. + +--- + +## 1. Executive summary + +We have built a genuinely three-layer platform, and two of the three layers are real and +wired end to end. The **universal host layer** (`nexum:host@0.2.0`) is clean, versioned, +and typed-error-idiomatic: six interfaces exist and are all linked into the `event-module` +world, with `chain`, `local-store`, and `logging` live and `identity`, `messaging`, +`remote-store` intentionally stubbed to 0.3 — matching the target vision almost exactly. +The **generic settlement layer** (`nexum:intent@0.1.0` + `nexum:value-flow` + +`nexum:adapter/venue-adapter`) is venue-neutral by construction (opaque `list` bodies, +mirror `pool`/`adapter` faces, a real host `PoolRouter`), and `echo-venue` proves the seam +is implementable without CoW. The venue-author SDK persona (`nexum-venue-sdk`, +`#[venue]`, `nexum-venue-test`) is **de facto shipped and well-built**, contradicting the +design docs that still call it "planned." + +But the thesis is only half-proven, and the half that is frozen is shaped wrong. **Quoting +does not exist** — the vision says "settlement + quoting" but the contract exposes only +submit/status/cancel. The **intent ontology is EVM/CoW/single-tx-shaped** while the +value-flow vocabulary it depends on is broad — an internal contradiction. **No concrete CoW +venue component exists** (`cow-venue` ships bodies + client only), so venue-neutrality is +asserted by a toy echo, never a real second venue. The **egress guard that justifies the +whole router shape is an `AllowAllGuard` no-op**, runs on an adapter-attested header rather +than the settled bytes, and does not cover the signing path at all. The generic +`keeper.run` orchestrator the branch is *named for* is not yet in the new stack — strategy +authors get boxes of parts with no assembler. The single highest-leverage move is to fix +the `nexum:intent@0.1.0` WIT ontology **now**, while only `echo-venue` pins it and the cost +is near-zero — the whole interface set is pre-release cruft, so reshaping it costs an +internal recompile, never a wire break, right up until the true 0.1.0 release is cut. + +--- + +## 2. The target architecture + +Three layers, each a distinct versioning and trust boundary. + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ LAYER 3 — CONCRETE VENUES (components; one per protocol) │ +│ │ +│ cow-venue rfq-venue amm-router-venue echo-venue │ +│ ─ impl VenueAdapter over protocol codec + typed client │ +│ ─ targets ONE world: exports nexum:intent/adapter@0.1.0 │ +│ ─ imports ONLY scoped transport (chain, messaging, http+allowlist) │ +│ ─ reaches its protocol as opaque bytes over wasi:http / nexum:host/chain │ +└───────────────────────────────┬──────────────────────────────────────────┘ + │ exports/implements +┌───────────────────────────────▼──────────────────────────────────────────┐ +│ LAYER 2 — GENERIC INTENT / VENUE WORLDS (venue-agnostic contract) │ +│ │ +│ nexum:value-flow ── settlement / asset / asset-amount vocabulary │ +│ nexum:intent ── intent-header, auth-scheme, submit-outcome, status │ +│ pool.wit (strategy face: venue named per call) ◀─ modules │ +│ adapter.wit (venue face: no venue arg) ◀─ venues │ +│ quote.wit (MISSING — the other half of the thesis) │ +│ │ +│ host PoolRouter: resolve venue-id → AdapterActor → guard → submit │ +└───────────────────────────────┬──────────────────────────────────────────┘ + │ imports +┌───────────────────────────────▼──────────────────────────────────────────┐ +│ LAYER 1 — UNIVERSAL NEXUM HOST INTERFACES (venue-agnostic, HOST-provided) │ +│ │ +│ nexum:host@0.2.0 world event-module: │ +│ chain ● local-store ● logging ● (LIVE backends) │ +│ identity ○ messaging ○ remote-store ○ (linked, backend → 0.3) │ +│ + WASI: clocks, random, wasi:http (per-module [capabilities.http]) │ +└────────────────────────────────────────────────────────────────────────────┘ + +● live backend + SDK trait + Mock + linked ○ linked + host impl, backend deferred +``` + +### Layer 1 — universal host interfaces + +HOST-provided, implemented by the runtime, imported by everyone above. The contract is +per-capability WIT interfaces plus a single shared error vocabulary. The guest ergonomic +seam is one Rust trait per capability with a supertrait bundle and a `Mock*` for host-free +unit testing (ADR-0009), and one typed-error type mirroring the WIT `fault` variant +(ADR-0011). + +```rust +// nexum-sdk — the provider-pattern seam (target shape) +pub trait ChainHost { fn request(&self, chain: Chain, method: ChainMethod, params: &str) -> Result; } +pub trait LocalStoreHost { fn get(&self, k: &[u8]) -> Result>, Fault>; /* set/delete/list_keys */ } +pub trait LoggingHost { /* tracing facade */ } +pub trait IdentityHost { /* accounts / sign / sign_typed_data */ } // target: trait + MockIdentity +pub trait MessagingHost { /* publish / query */ } // target: trait + MockMessaging +pub trait RemoteStoreHost{ /* upload / download / read_feed / write_feed */ } + +pub trait Host: ChainHost + LocalStoreHost + LoggingHost + + IdentityHost + MessagingHost + RemoteStoreHost {} // target: all six +``` + +### Layer 2 — generic intent / venue worlds + +The venue-neutral settlement (and, per vision, quoting) contract. Bodies are opaque +`list` at both faces so no venue schema leaks into the interface; the typed edges are +`nexum:value-flow` headers/quotes the router and guard can read uniformly. + +```wit +// nexum:intent/adapter.wit — venue face (target, with quoting) +interface adapter { + use nexum:value-flow/types.{asset-amount}; + derive-header: func(body: list) -> result; // pure projection + quote: func(body: list) -> result; // MISSING today + submit: func(body: list) -> result; + status: func(id: list) -> result; + cancel: func(id: list) -> result<_, venue-error>; +} +``` + +### Layer 3 — concrete venues + +A component per protocol. Each `impl VenueAdapter`, targets exactly one world exporting +`nexum:intent/adapter@0.1.0`, and reaches its protocol as opaque bytes over the scoped +transport it declares. CoW is the flagship: a `composable-cow` adapter over the existing +`OrderBody`/`ComposableBody` codec, capabilities `[chain, http]`. + +### DX walkthrough A — author a new venue + +```rust +// modules/venues/my-rfq/src/lib.rs +use nexum_venue_sdk::{venue, VenueAdapter, IntentBody, VenueError, HostChain}; + +#[derive(IntentBody)] // versioned borsh codec, typed BodyError +struct RfqBody { /* … */ } + +struct MyRfq; + +#[venue] // synthesizes a per-manifest narrowed world +impl VenueAdapter for MyRfq { // TARGET: macro emits impl over the typed trait + fn derive_header(body: RfqBody) -> Result { /* pure */ } + fn quote(body: RfqBody) -> Result { /* http round-trip */ } + fn submit(body: RfqBody) -> Result { /* … */ } + fn status(id: &[u8]) -> Result { /* … */ } + fn cancel(id: &[u8]) -> Result<(), VenueError> { /* … */ } +} +``` + +```toml +# module.toml — capabilities are the world by construction (compile error if outside {chain,messaging,http}) +[capabilities] +required = ["chain", "http"] +``` + +The conformance kit (`nexum-venue-test`) holds it to portable JSON codec vectors and +header goldens; `cargo test` fails if the wire shape drifts. + +### DX walkthrough B — call the host from a strategy + +```rust +// pure strategy logic, generic over the host, host-free testable +fn on_tick(host: &H, tick: Tick) -> Result<(), Fault> { + let block = host.provider().get_block_number()?; // TARGET: alloy Provider seam + let price = Chainlink::new(host, FEED).latest_answer()?; // sol!-typed reader (exists) + let last = host.local_store().get(b"last")?; // typed error, `?` folds to Fault + // …decide, then submit via the venue-bound client… + Ok(()) +} +``` + +--- + +## 3. Where we are today + +Grounded at `origin/refactor/rename-chassis-to-keeper`, last car #260 + Wave-1 #334/#335. + +### Capability matrix + +| Interface / capability | WIT? | Host impl? | Backend live? | SDK trait? | Mock? | Wired into world? | +|---|---|---|---|---|---|---| +| **L1** chain | ✅ `nexum:host/chain` | ✅ `ProviderPool` | ✅ | ✅ `ChainHost` | ✅ `MockChain` | ✅ event-module + adapter | +| **L1** local-store | ✅ | ✅ redb | ✅ | ✅ `LocalStoreHost` | ✅ | ✅ event-module | +| **L1** logging | ✅ | ✅ `LogPipeline` | ✅ | ✅ `LoggingHost` | ✅ | ✅ event-module | +| **L1** identity | ✅ | ✅ (empty roster) | ❌ → 0.3 | ❌ | ❌ | ✅ linked, ❌ no seam | +| **L1** messaging | ✅ | ✅ (scope enforced) | ❌ → 0.3 | ❌ | ❌ | ✅ event-module + adapter | +| **L1** remote-store | ✅ | ✅ (`unsupported`) | ❌ → 0.3 | ❌ | ❌ | ✅ linked, ❌ no seam | +| **L1** query-module world | ✅ (EXPERIMENTAL) | ❌ no linker | ❌ | ❌ | ❌ | ❌ published-unhosted | +| **L2** value-flow types | ✅ `nexum:value-flow` | n/a | n/a | ✅ (venue SDK) | ✅ goldens | ✅ | +| **L2** intent settlement | ✅ `nexum:intent` pool/adapter | ✅ `PoolRouter` | ✅ | ✅ `IntentClient

` | ✅ `nexum-venue-test` | ✅ | +| **L2** intent quoting | ❌ absent | ❌ | ❌ | ❌ | ❌ | ❌ | +| **L2** egress guard | ✅ `GuardPolicy` trait | ⚠️ `AllowAllGuard` no-op | ❌ | n/a | n/a | ✅ seam only | +| **L2** adapter supervision | ✅ | ✅ boot/install | ⚠️ no restart/poison | n/a | n/a | ✅ partial | +| **L3** CoW venue component | ⚠️ codec/`CowClient`, plain `[lib]` | ❌ **no cdylib / no `VenueAdapter` impl** | ❌ | ⚠️ body/client only | — | ❌ | +| **L3** echo-venue | ✅ | ✅ | ✅ | ✅ `#[venue]` | ✅ golden | ✅ (only real adapter) | +| **L3** legacy `shepherd:cow/cow-api` | ✅ | ✅ (module cap) | ✅ | — | — | ✅ event-module only (**live CoW submit**) | + +### Per-layer narrative + +**Layer 1 (host).** Strong. All six interfaces exist, are linked into `event-module`, and +have host impls; the real/stub split is *backend liveness*, matching the vision (chain + +local-store today; messaging + remote-store later; identity a fourth deferred). The typed- +error model (ADR-0011) is fully realised: one `fault` variant (7 cases incl. +`rate-limited{retry-after-ms}`), mirrored as `Fault` (thiserror + `IntoStaticStr` snake_case ++ `#[non_exhaustive]`), `From for Fault` so aggregated calls fold through `?`. +Capability enforcement is hardened: `#[nexum_sdk::module]` synthesises a per-module world +from the manifest, so undeclared caps are a compile error — **except `http`** (see R-C2). +The gaps are the guest seam: the `Host` supertrait bundles only 3 of 6, and `chain` is raw +stringly JSON-RPC with no alloy `Provider`. + +**Layer 2 (generic).** The settlement surface is venue-neutral and works end to end: +three independent-cadence packages, opaque bodies, mirror faces, a real `PoolRouter` +(resolve → derive-header → guard → charge → submit → status-watch), per-component worlds +via `synthesize_venue`. What is missing is structural: **no quoting**, a **no-op guard**, +adapters **outside the restart/poison sweeps**, and a residual EVM/CoW shape in the intent +ontology (below). + +**Layer 3 (concrete).** The persona is shipped but **unexercised by a real venue**. +`cow-venue` is a plain `[lib]` crate — **no `crate-type = ["cdylib"]`** — carrying only its +`body` + `client` slices (grep for `export_venue_adapter!`/`#[venue]`/`VenueAdapter` is +empty); `lib.rs:12-13` names the typed-client-plus-adapter component an explicit "later +slice." The only working adapter is `echo-venue`. Crucially, the **live CoW submit path +never touches the generic seam**: it still runs module → the `shepherd:cow/cow-api@0.2.0` +host extension → the `cowprotocol` crate, assembling `OrderCreation` JSON on the strategy +side (`shepherd-sdk/src/cow`) and **bypassing `nexum:intent/pool` entirely**. `mod.rs:36` +already re-exports the cow-venue body types as a shim "while the module ports move off the +legacy surface," but that port has not happened — so `docs/08`, which documents *only* the +older host-extension model, still matches the hot path even though the code is meant to +migrate away from it. + +--- + +## 4. Gap analysis (ranked by leverage) + +Ranked across all layers; leverage = (blast radius if unfixed) × (cheapness now vs later). + +1. **[L2, contract] The `nexum:intent@0.1.0` ontology is mis-shaped and + unexercised.** Missing quoting; `submit-outcome.requires-signing` is a *single* EVM + `unsigned-tx`; `auth-scheme` is EVM-only; `venue: string` is stringly; `venue-error` + lacks `rate-limited`. Every fix costs only an internal recompile + a train fold, and only + `echo-venue` pins the world today — nothing is released. **Highest leverage bar none.** +2. **[L3, spec] No coherent composition path for the flagship CoW venue.** The vision's + "compose cow-protocol WASI + Nexum venue world" is a category error (B1): a component + targets one world, and no "cow-protocol WASI" interface exists. Must be a spec decision + *before* the cow-venue adapter slice is built. +3. **[L2, security] The egress guard is advertised but absent and mis-shaped.** Only + `AllowAllGuard` ships; it runs on the adapter-attested header, not the settled bytes + (TOCTOU), and does not cover the `requires-signing` signing path at all (B3). +4. **[L1, DX] The chain seam has no alloy `Provider`.** Raw `request(u64,&str,&str)->String`; + authors hand-build JSON and parse strings. `doc-08` promises a `HostTransport: alloy + Transport` shim that is not in the SDK. Largest single DX gap from the alloy target. +5. **[L1, DX] Three of six host interfaces have no guest seam.** `identity`, `messaging`, + `remote-store` have `adapter:None` in the macro KNOWN table, no `*Host` trait, no + `Mock*` — modules reach them only via raw wit-bindgen and cannot host-free unit-test. + ADR-0009's "each interface becomes a trait + MockX" is unfulfilled for all three. +6. **[L2, DX] No generic `Keeper` orchestrator.** The branch is named for `keeper.run`, but + only the parts ship (`WatchSet`/`Gates`/`Journal`/`Retrier`/`ConditionalSource`); there + is no `Keeper::sweep(tick)` assembling them, and `ConditionalSource::Outcome` is a + dangling associated type nothing consumes. +7. **[L3, DX] Two authoring paths fork the "one clear arrangement."** `#[venue]` emits a + `Guest` impl over raw bindgen types and *bypasses* the `VenueAdapter` trait; + `export_venue_adapter!` routes through it on a differently-named world. Each `#[venue]` + adapter also hand-copies ~80 lines of `*_to_golden` bridges. +8. **[L1↔L2, versioning] Asymmetric host→intent freeze coupling.** `nexum:host@0.2.0` + `use nexum:intent/types@0.1.0.{receipt, intent-status}` in its `event` variant, so a new + `intent-status` case breaks the host and recompiles every event-module. +9. **[L2, DX] `venue-error` is raw wire, not mirrored** (no `Display`, formatted via + `{0:?}`); the fault fold drops `retry-after-ms`. **[L3, DX] `OrderBody` is a bare + 12-field literal**; CoW body aliases `Address`/`U256` drop the newtype guarantee. +10. **[docs] `docs/05` says the venue persona is "not shipped"; `docs/08` documents only + the deprecated Layer-3 host-extension model.** The source-of-truth docs actively + mislead a new author. + +--- + +## 5. DX / reth-alloy idioms + +Concrete before/after for the roughest surfaces. + +### 5.1 Chain: stringly JSON-RPC → alloy Provider seam + +```rust +// BEFORE — hand-rolled JSON in, string out +let params = eth_call_params(to, calldata, "latest"); +let raw = host.request(1u64, "eth_call", ¶ms)?; // ChainHost::request +let out = parse_eth_call_result(&raw)?; + +// AFTER — alloy Provider over a HostTransport: alloy Transport shim +let provider = host.provider(Chain::mainnet()); // zero-cost, typed Chain +let out = provider.call(&tx).block(BlockId::latest()).await?; +let head = provider.get_block_number().await?; +``` + +`ChainMethod` (the closed `IntoStaticStr` RPC enum) already exists host-side — carry the +same typed surface to the guest instead of `method:&str`. + +### 5.2 Venue id: stringly → zero-cost newtype + +```rust +// BEFORE — three disconnected string definitions agree by convention +const VENUE: &str = "cow"; // cow-venue/src/client.rs +[[adapters]] name = "cow" // engine.toml +venue_id = adapter_namespace // supervisor.rs (manifest name) +// mismatch → runtime venue-error.unknown-venue + +// AFTER — one source, compile-time linkage +pub struct VenueId(&'static str); // zero-cost newtype +impl CowVenue { pub const ID: VenueId = VenueId("cow"); } +let client = IntentClient::builder().venue(CowVenue::ID).connect(pool); +``` + +### 5.3 Generic keeper: parts box → assembler + +```rust +// BEFORE — parts only; every author re-hand-rolls list→parse→is_ready→get→poll→match→apply +// AFTER — the keeper.run the branch is named for, venue-neutral +pub struct Keeper<'h, H, S> { host: &'h H, source: S } +impl<'h, H: Host, S: ConditionalSource> Keeper<'h, H, S> { + pub fn sweep(&self, tick: &Tick) -> Result { /* WatchSet→Gates→Retrier */ } +} +// give ConditionalSource a shared Outcome the keeper drives: +pub enum Sweep { Submit(Vec), WaitBlock(u64), WaitEpoch(u64), Drop, TryNextBlock } +let keeper = Keeper::new(host).source(src).submit_via(client); +``` + +### 5.4 Order construction: 12-field literal → typestate builder + +```rust +// BEFORE — name all 12 fields at every call site +let order = OrderBody { sell_token, buy_token, sell_amount, buy_amount, valid_to, + receiver, kind, partially_fillable, sell_token_balance, buy_token_balance, app_data, fee }; +// AFTER — alloy TransactionRequest idiom +let order = Order::sell(sell_token, sell_amount) + .buy(buy_token, buy_amount) + .valid_to(t) + .partially_fillable() + .build(); // receiver=None, balances/kind defaulted +``` + +### The systemic moves worth making + +- **Provider pattern:** the alloy `Provider` seam (5.1) is the flagship; `IntentClient` + gains a `ProviderBuilder`-style `builder().venue(id).connect(pool)` and a + `client.quote(&body)?.submit()?` typestate once quoting lands. +- **Zero-cost newtypes:** `VenueId`, a guest-side `Chain`/`ChainId`, and CoW + `SellToken(Address)`/`BuyToken(Address)` so sell/buy cannot silently swap. +- **Sealed traits:** seal the blanket-impl / extension-point traits (`Host`, `HostFault`, + `RuntimeTypes`, `Runtime`, `IntentPool`) with a private `Sealed` supertrait — the reth + idiom for "traits you implement, not us," and it lets the SDK grow their method sets. +- **`#[non_exhaustive]` uniformly** across every public error/label enum (`BodyError`, + `ClientError`, `ConfigError`, `ChainError`, `BuildError` currently lack it). +- **Derive the mirrors:** the `fault` vocabulary is hand-mirrored in three places + (`types.wit`, SDK `Fault`, macro round-trip) and the KNOWN capability table is duplicated + across `nexum-macros` and the runtime `CapabilityRegistry`. Both should emit from one + source-of-truth const so they cannot skew. +- **Mirror `venue-error`** the way `chain-error` is mirrored (a `VenueFault` with + `Display` + `IntoStaticStr` label + `From`), so operator logs stop + `{0:?}`-formatting and the `rate-limited` case survives. + +--- + +## 6. Red-team findings + +Ranked by what sinks the architecture, each with the failure it causes and where to fix it. +Verified against the tree by the adversarial pass. + +### R1 — `nexum:intent@0.1.0` is mis-shaped and unexercised *(sink #1)* +**Failure:** a real second venue (RFQ / AMM-router) immediately hits five walls at once — +no place to express a **quote**; `submit-outcome.requires-signing(unsigned-tx)` is exactly +one EVM call so approve+swap or any tx *sequence* is unrepresentable and a non-EVM venue +cannot express settlement at all (B2); `auth-scheme` is EVM-only; venue id is stringly; and +`venue-error` lacks `rate-limited`, so the `faults.rs` fold collapses +`unavailable|rate-limited|timeout → unavailable(string)` and destroys the `retry-after-ms` +a throttling-heavy RFQ/AMM API needs. **Where:** `wit/nexum-intent/{pool,adapter,types}.wit` +and `wit/nexum-value-flow/types.wit`. **When:** now — only `supervisor/tests.rs:306` +(`echo-venue`) instantiates the world, and the whole interface set is pre-release cruft, so a +reshape costs an internal recompile + a train fold, never a wire break. Do it before the true +0.1.0 release is cut. +**Decided (2026-07-14):** 0.1 is **EVM-only** as a *scoping* choice; non-EVM settlement +(de-EVM `auth-scheme`/`unsigned-tx`) lands later. Nothing is pinned, so this reshape — and +quoting — can land now or later at will; there is no freeze to preserve additive +extensibility across (see decision 8). + +### R2 — the flagship CoW venue has no coherent composition path *(sink #2)* +**Failure:** the vision's "concrete venue brings in the cow-protocol WASI + the Nexum venue +world" is a **category error** — a component targets one world, and no "cow-protocol WASI" +interface exists. The only CoW WIT (`shepherd:cow/cow-api@0.2.0`) is a *host extension linked +into event-modules only*: adapters **cannot import it** (`ADAPTER_CAPABILITIES = ["chain", +"messaging"]`, `manifest/capabilities.rs`; `build_adapter_linker`, `supervisor.rs:1316`, +comments "Extensions are not linked into adapters"). So a CoW adapter reaches the orderbook +over `wasi:http` (gated by `[[adapters]].http_allow`) + `nexum:host/chain` — and needs **no +separate "composable-cow module,"** because composable orders are already just a +`ComposableBody` payload variant of `CowIntentBody`. **Unresolved (ADR-0013):** the +`Verdict::Post` seam is **half-populated** — it is the one variant `LegacyRevertAdapter` never +produces (`composable.rs:340`), so decode/classify never yields a submittable order; and +`Verdict::NeedsInput` is **dead surface** until `IOrderModule`/the fork lands (`run.rs:143` +parks it). **Where:** spec decision + `docs/08` + the adapter linker/capability story. +**When:** before the cow-venue adapter slice. + +### R3 — the egress guard is advertised, absent, and mis-shaped *(sink #3)* +**Failure:** the router's entire `derive→guard→submit` shape is justified by a checkpoint +that (a) is `AllowAllGuard`, a no-op (`pool_router.rs:104-110`); (b) inspects the adapter's +*own* `derive-header` output while `submit` re-decodes the body independently, so a +buggy/hostile adapter shows a benign `gives` and settles something else (TOCTOU); and (c) +does not cover the `requires-signing` class at all — the real value movement is in the +`unsigned-tx` calldata *returned by submit* and signed on the `identity` path, which is a +0.3 stub (`accounts()->Ok(vec![])`). Shipping the `pool` import in the default build +advertises a boundary that does not exist. **Where:** `pool_router.rs` (move the guard to +the signed-tx boundary; pass the derived header *into* submit for single-decode); +`intent/types.wit` (soften "host-verified `gives`" to "adapter-attested"). **When:** before +any non-echo adapter is installable and before identity signing lands. +**Decided (2026-07-14):** advisory-only for M1 — keep `AllowAllGuard` + feature-gate the +`pool` import + document the boundary as **not yet enforcing**; the real guard (its shape and +where it runs) is deferred wholly to the egress-guard epic. No teeth land during M1. + +### R4 — two authoring paths fork the "one clear arrangement of traits" *(sink #4)* +**Failure:** `#[venue]` emits `impl exports::nexum::intent::adapter::Guest` over raw +bindgen types and **bypasses** the typed `VenueAdapter` trait (`macros/lib.rs:426`; +`echo-venue` impls inherent fns), while `export_venue_adapter!` routes through the trait — +the flagship typed trait is bypassed by the flagship macro. Note the **corrected** framing +(C1): the two world *names* (`nexum:venue-world` vs `nexum:adapter`) are a harmless local +alias — both export `nexum:intent/adapter@0.1.0` and are loadable by the same host. The +real divergence is **import-narrowing**: `export_venue_adapter!` imports chain+messaging +unconditionally and leans on wasm-tools dead-import elision to pass capability enforcement, +whereas `synthesize_venue` narrows by construction. **Where:** make `#[venue]` emit an +`impl VenueAdapter` shim; demote `export_venue_adapter!`; unify the import-narrowing +guarantee. **When:** before the cow-venue adapter, or the fork is enshrined in the flagship. +**Decided (2026-07-14):** `#[nexum::venue]` is the single blessed authoring path, fixed to +emit `impl VenueAdapter`; `export_venue_adapter!` is demoted to the internal codegen detail +the macro expands to (not a public second path). + +### R5 — the `http` capability escapes the compile-time guarantee *(corrected from lens 1)* +**Failure:** in the KNOWN table `http` has `import: None` (`world.rs:88-92`) — declaring or +omitting `http` changes *no* world import line; `wasi:http` is linked out-of-band and gated +only by the `engine.toml` allowlist. So the "undeclared cap is a compile error" guarantee +covers chain/messaging/logging but **not** the one venue capability most likely to carry +egress. **Where:** either bring `http` under the synthesised-world guarantee or document +loudly that http egress is allowlist-gated only. **When:** before venues start making +external calls in production. + +### R6 — asymmetric host→intent freeze coupling *(corrected from lens 2)* +**Failure:** `intent/types.wit` claims freeze independence, but `nexum-host/types.wit:8` +`use nexum:intent/types@0.1.0.{receipt, intent-status}` — the decoupling is +*one-directional*. Bumping `nexum:intent` (a new lifecycle `intent-status` case a new venue +needs) is a breaking change to `nexum:host` and recompiles every event-module. **Where:** +decide whether the host `event` stream should carry `intent-status` at all, or a host-owned +opaque status projection. **When:** now, before more `intent-status` cases are demanded. +**Decided (2026-07-14):** the host `event` stream carries **opaque status bytes**, decoupled +from `nexum:intent` (drop `use nexum:intent/types.{intent-status}` in `nexum-host/types.wit`), +with a documented, versioned contract for how those bytes destructure. This folds in with the +same pre-release reshape — the `nexum:host` package version carries no maturity or compat +weight (it is cruft, normalizing to `@0.1.0`; see decision 8), so there is no version reason +to sequence it apart. If ordering matters, it is on technical merit alone. + +### R7 — no module↔venue schema-version handshake *(blind spot B4)* +**Failure:** bodies are opaque `list` with a guest-side borsh version tag; if a strategy +module and the installed adapter disagree on body version, the only signal is `invalid-body` +at runtime. For a platform whose thesis is "opaque bodies + typed edges," schema agreement +is never a checked property. **Where:** a version/feature field on the pool face or a +capability handshake at install. **When:** can defer past M1, but decide the mechanism now. +**Decided (2026-07-14):** an **install-time capability handshake** — a `body_version` (or +version-set) field in the module and adapter manifests; `Supervisor::install` asserts the +module's version is in the adapter's supported set and refuses to boot a mismatched pair +(fail fast, logged). This is a manifest + supervisor change (**not** WIT-freeze-gated), so it +builds as a concrete Phase 1-2 step. + +### R8 — adapters are outside the supervisor lifecycle sweeps +**Failure:** adapters boot once and install but are not in the restart/poison-recovery +sweeps (`supervisor.rs:61-66`); a trapped adapter stays dead until process restart, and the +router only projects the trap to `internal-error`. **Where:** fold adapters into the sweeps; +expose `adapters_alive` so a strategy distinguishes `unknown-venue` from +`venue-temporarily-dead`. **When:** before production multi-venue. + +--- + +## 7. Migration plan from mid-train + +We are mid-M1 with the last car at #260 and Wave-1 (#334/#335) in flight. +`Materialiser` is the M7 destination. The governing constraint: +**the whole WIT interface set is pre-release**, pinned only by the `echo-venue`/`echo-client` +demo pair and the host router — no external consumer, no released version. The package +version strings on the WIT (`@0.1.0`, `@0.2.0`, …) are accumulated cruft, not compat +boundaries; they normalize to a single `@0.1.0` at the true initial release (decision 8). +Until that release is cut, a breaking WIT change costs only an internal recompile + a train +fold — never a wire break — so this clean-slate window is exactly when to reshape aggressively. +The WIT-debt cluster leads the plan for that reason, not because of any looming freeze. + +### The fold-vs-amend rule + +The **keeper-rename fold** is the proven stack-surgery template: a range-limited +`git-filter-repo` pass replayed across the 21-car stack (#239→#260) + Wave-1 (#334/#335), +validated against a **byte-identical tip oracle** (rebuild the tip two ways, diff, identical +trees prove no drift), then a single force-push of every branch; `jj` drives the per-car +rebases (immutable-heads override for pushed cars) and `mergiraf` resolves the WIT/Rust +conflicts. That template gives the organizing rule for the whole plan: + +- **Fold** — any change that must appear *identically in every car's WIT* is a train-wide + fold, never a per-car edit (a WIT type touched in #247 is imported by #248→#260; editing it + car-by-car desyncs the stack). Folds go through the oracle-validated `git-filter-repo` + + force-push machinery above, and every WIT-touching step regenerates the `nexum-venue-test` + goldens and re-asserts the tip oracle. +- **Amend-in-place** — a correctness fix that lives in *exactly one car* is the opposite: + amend that car directly and ripple-rebase the rest with `jj`. + +### Phase 0 — reshape the pre-release contract (one WIT fold; echo-venue sole pin) + +The whole WIT interface set is pre-release and echo-only-pinned, so this is a **clean-slate +window**: a breaking change costs an internal recompile + a train fold, never a wire break, +because there is no external consumer and no released version to break (decision 8). That +makes reshaping *free* — do it aggressively now, before the true 0.1.0 release is cut. Do the +entire WIT-debt cluster (#247/#248/#297) as **one fold** on `refactor/intent-contract-reshape`, +oracle-validated exactly like the keeper rename: + +- **train-wide version normalization** — reset every WIT package and every `use …@` + reference (`nexum:host@0.2.0`, `nexum:intent@0.1.0`, `nexum:value-flow`, `nexum:adapter`, + `shepherd:cow@0.2.0`, …) to a single **`@0.1.0`** as the true initial release, done as one + `git-filter-repo`/`jj` fold like the keeper rename (byte-identical tip oracle across the + stack). The old numbers are accumulated cruft, not compat boundaries; +- rename `valid-until` → `valid-until-ms` (kills the silent ms-vs-`Tick.epoch_s`-seconds + `/1000` mismatch); +- MUST-NOT-retry doc on `venue-error.denied()`; +- lift the anonymous `erc20`/`erc721`/`erc1155` positional tuples to **named records**; +- add a **version discriminator + reject-unknown** to the cross-language codec goldens, plus a + non-empty-vector assertion (today's empty-vector golden passes vacuously); +- `derive-header` purity doc-caveat / sub-world note; +- **fold in the R6 host↔intent decoupling** — drop `nexum-host/types.wit`'s + `use nexum:intent/types.{intent-status}` so the host `event` stream carries **opaque status + bytes** plus a documented, versioned destructuring contract. The host package version is + cruft too, so there is no maturity reason to sequence it apart from the intent reshape; it + rides the same fold (order it later only if a technical dependency demands it); +- **delete/rewrite the migration cruft** — `docs/migration/0.1-to-0.2.md` and the "Migration + from 0.1" prose in `docs/08-platform-generalisation.md` describe a version transition that + never happened; there is no released 0.1 to migrate *from*, so delete the file and drop the + prose; +- fold in the co-located nits (`none`/`unsigned` keyword note, BZZ-is-ERC20, missing + `amount`-field doc). + +**This is the free window, not a one-way door:** nothing is pinned, so quoting, de-EVM, and +any other breaking reshape can land now or later at will — echo-only pinning means the blast +radius today is a demo recompile. The reason to do it *now* is cleanliness before the true +0.1.0 release, not a looming freeze. + +### Phase 1 — per-car Rust correctness amends (no fold) + +None of these touch WIT, so each is an amend-in-place with a `jj` ripple-rebase, no fold: + +- **#249** — supervisor missing-manifest error (distinguish *missing* from *wrong-kind*); +- **#250** — guard-deny must charge quota (closes the busy-loop DoS; latent while only + `AllowAllGuard` ships, but cheap now); +- **#251** — add the `RateLimited` fold test (currently drops `retry_after_ms` untested); +- **#296** — bump `echo-venue` to `wit-bindgen` 0.59 (de-dupes `Cargo.lock`). +- **R7 install-time handshake** (shape decided 2026-07-14; manifest + supervisor, **no + WIT-freeze dependency** so it need not ride Phase 0) — add a `body_version` (or version-set) + field to the module and adapter manifests and have `Supervisor::install` assert the module's + version is in the adapter's supported set, refusing to boot a mismatched pair (fail fast, + logged). May land as a new car in Phase 1 or Phase 2. + +Clearing Phase 1 lets the approved cars (#252/#253/#256/#257/#259/#260) merge clean. + +### Phase 1-Wave-1 — Verdict seam hardening (#334) + +Both fixes are latent-until-fork, so free now; amend #334 directly: + +- model `Verdict::Post.next_poll_timestamp` as `Option`/`NextPoll` instead of a `0`-sentinel + (the sentinel collides with the fork wire; no-op today, breaking once the fork deploys); +- add a dispatch test for the `NeedsInput` arm (today it info-logs and busy-polls every tick). + +### Phase 2 — egress-guard epic + adapter/guard fidelity (new cars off Wave-1 tip / post-M1) + +A real (non-`AllowAll`) guard makes a latent cluster live, so these are new cars gated behind +the egress-guard epic owner: + +- the **material** facet of #249 `derive-header`-before-guard — the router runs adapter + derivation *before* `guard.check`, so side effects escape policy; the honest fix is a + guarded sub-world or moving derivation behind the checkpoint, not just the Phase-0 doc + caveat; +- **#250** `GuardPolicy::check` sync → async (breaking; lands with the egress-guard epic, + which needs async I/O); +- **#249** `messaging.query` scope-check hole (goes live with the 0.3 Waku backend); +- **#297** mock-grant fidelity divergence and **#296** blanket chain+messaging shims / two + coexisting adapter contracts — canonicalise **one** adapter contract as the real guard + replaces the shims. + +**M1 guard posture (advisory-only, decided 2026-07-14):** M1 ships no guard teeth. Keep +`AllowAllGuard`, feature-gate the `pool` import, and document the checkpoint as **not yet a +boundary**. The real guard — its shape *and* where it runs (signed-`unsigned-tx` boundary, +single-decode) — is deferred **wholly** to the egress-guard epic; nothing enforcing lands +during M1. + +Fold the remaining red-team hardening in here as the epic lands: move the guard checkpoint to +the signed-`unsigned-tx` boundary and soften the `gives`-is-host-verified claim to +"adapter-attested" (R3); fold adapters into the restart/poison sweeps and expose +`adapters_alive` (R8); bring `http` under the world guarantee or document the allowlist-only +story (R5). + +### Phase 3 — module/backtest correctness follow-ons + +Low blast radius, non-contract; land whenever the owning car reopens, none blocks freeze or +train merge: + +- **#242** — backtest `classify_ok` misclassification + stop-loss dedup-key asymmetry + (`server_uid` write vs `uid_hex` read → re-POST on UID drift); +- **#243** — macro name-only handler match (an async `on_block` compiles `.await`-less). + +### Phase 4 — SDK / DX build-out + docs (fold as convenient) + +With the reshaped contract underneath, these are the good-but-non-blocking build-outs; fold +WIT touches when convenient, oracle-re-validated: + +- **alloy `Provider` seam** — `HostTransport: alloy Transport` over `ChainHost::request` + (Gap #4, §5.1); +- **`IdentityHost`/`MessagingHost`/`RemoteStoreHost` + `Mock*` + bind-macro slices** wired to + the stub backends, widening the `Host` supertrait (or opt-in subset supertraits) so guest DX + decouples from 0.3 backend readiness (Gap #5); +- **unify the authoring path (R4, decided 2026-07-14):** make `#[nexum::venue]` emit an + `impl VenueAdapter` and demote `export_venue_adapter!` to the internal codegen the macro + expands to — `#[venue]` is the single blessed path, no public second path, ambiguity removed; +- **DX polish:** generic `Keeper::sweep` + shared `Sweep` outcome (§5.3); `Order` builder + (§5.4); mirror `VenueError` → `VenueFault` (§4.9); uniform `#[non_exhaustive]`; seal the + extension traits; derive the `fault` mirror + KNOWN table from one source-of-truth const; + kill the `*_to_golden` bridge boilerplate (§4.7); +- **docs (blocking, cheap):** rewrite `docs/05` (venue persona is **shipped** — document the + crate layout + a step-by-step "author a venue") and `docs/08` (venue adapters are **the** + domain-extension mechanism; mark `shepherd:cow/cow-api` as legacy read-path). Highest + discovery-return change, pure prose. + +### The full m1-review-sweep triage (item → phase → action → branch) + +| Sweep item | Phase | Action | Branch | +|---|---|---|---| +| version-string cruft → single `@0.1.0` (#247) | 0 | train-wide normalization fold | fold `refactor/intent-contract-reshape` | +| `valid-until` ms vs `epoch_s` (#248) | 0 | rename `valid-until-ms` | fold `refactor/intent-contract-reshape` | +| `denied()` no MUST-NOT-retry (#248) | 0 | doc caveat | fold | +| ERC anonymous tuples (#247) | 0 | → named records | fold | +| codec no version discriminator (#297) | 0 | add discriminator + reject-unknown | fold (vector car) | +| `derive-header` purity claim (#249) | 0 + 2 | doc/sub-world caveat now; reorder vs guard later | fold; then Phase-2 car | +| codec empty-vector vacuous pass (#297) | 0 | non-empty vector assertion | fold (vector car) | +| supervisor missing-manifest error (#249) | 1 | branch missing vs wrong-kind | amend car #249 | +| guard-deny no quota / DoS (#250) | 1 | charge quota on deny | amend car #250 | +| `RateLimited` fold untested (#251) | 1 | add test | amend car #251 | +| wit-bindgen 0.58/0.59 skew (#296) | 1 | bump to 0.59 | amend car #296 | +| `Verdict::Post` 0-sentinel (#334) | 1-W1 | model `Option`/`NextPoll` | amend #334 | +| `NeedsInput` busy-poll / no test (#334) | 1-W1 | add dispatch test | amend #334 | +| `GuardPolicy::check` sync (#250) | 2 | async trait | new car off Wave-1 (egress-guard epic) | +| `messaging.query` not scoped (#249) | 2 | scope-check | egress-guard epic (0.3 Waku) | +| mock grant fidelity diverges (#297) | 2 | align mock to host | egress-guard epic | +| blanket shims / 2 adapter contracts (#296) | 2 | canonicalise one contract | egress-guard epic | +| backtest `classify_ok` (#242) | 3 | tighten bucketing | car #242 | +| stop-loss dedup-key asymmetry (#242) | 3 | unify write/read key | car #242 | +| macro name-only match (#243) | 3 | match on async-ness | car #243 | + +### Triage + +**Do next (this week):** all of Phase 0 as one oracle-validated fold, then +Phase 1 + the two #334 fixes. *Rationale:* Phase 0 is the **clean-slate pre-release window** — +nothing is pinned, so every WIT-debt item (including the version-string normalization) costs +only an internal recompile + a fold, and echo-only pinning means the blast radius is a demo; +do it now for a clean true-0.1.0 release, not because of any freeze. Phase 1/#334 are one-car +amends with no fold cost, and clearing them lets the approved cars +(#252/#253/#256/#257/#259/#260) + #334/#335 merge clean. + +**Do later (post-M1, epic-gated):** Phase 2 — reachable only once a real egress guard replaces +`AllowAllGuard`, so fixing it now is untestable/speculative; ride the egress-guard epic + the +0.3 Waku backend. Also gated: the **ADR-0013 poll wire-swap**, merge-blocked until the fork's +`deployments/networks.json` is non-empty on a shepherd target chain; until then +`LegacyRevertAdapter` maps the upstream reverting selectors to a structured `Verdict`. + +**Decided, build in a concrete phase (no longer decide-later):** the **authoring-path unify** +(R4 — `#[venue]` emits `impl VenueAdapter`, `export_venue_adapter!` internal) lands in Phase 4 +DX; the **R7 module↔venue handshake** (install-time `body_version` assertion in +`Supervisor::install`) lands as a manifest+supervisor car in Phase 1-2 (no WIT-freeze gate). + +**Don't yet (defer to M7 / post-freeze / epic-gated):** the venue-neutral +`Materialiser` (explicitly M7); the concrete CoW venue component + the cow-venue +clean-break migration of `shepherd-sdk::cow`; **quoting** in the intent contract + de-EVM-ing +the CoW/single-tx ontology (0.1 is **EVM-only**, decided 2026-07-14); and the **real egress +guard** (M1 is advisory-only — the guard's teeth and location are the egress-guard epic's). +These want a named design partner and a *second* real venue so the true 0.1.0 does not enshrine +guesses. *Note:* deferring these is a *scoping* choice, not a compatibility one — nothing is +pinned, so quoting or a de-EVM reshape can land whenever a design partner arrives, at the cost +of an internal recompile + a fold. There is no freeze to preserve additive extensibility +across, so no additive down-payment is needed. + +### Grounded caveats + +- The triage file `docs/design/m1-review-sweep-triage.md` is **on disk only** — not committed + at `origin/refactor/rename-chassis-to-keeper`. +- The WIT (`wit/nexum-intent/{types,adapter,pool}.wit`, `wit/nexum-value-flow/types.wit`) is + confirmed `@0.1.0` **unfrozen**, pinned only by the echo demo pair + the host router. +- ADR-0013's **merge gate** (fork `deployments/networks.json` non-empty) is the hard blocker on + the poll wire-swap. + +--- + +## 8. Decisions (2026-07-14) + +The open questions are resolved. Each decision below states the resolution and its one-line +consequence for the architecture/plan; the R-item mapping is retained so cross-references +still resolve. + +1. **(Q1 / R2) No venue-specific host interfaces — adapters are transport-only.** Adapters + reach venues over the generic Nexum host interfaces + `wasi:http`; the host interface set + must be kept ample enough for venues. *Consequence:* the `shepherd:cow/cow-api`-as-adapter- + extension ambiguity is **deleted** from the docs (it survives only as the legacy + event-module read path); no extension-namespace machinery is added to `synthesize_venue` / + the adapter linker. +2. **(Q2 / R6) The host `event` stream carries opaque status bytes.** Drop + `nexum-host/types.wit`'s `use nexum:intent/types.{intent-status}` coupling; the host emits + opaque bytes with a documented, versioned destructuring contract. *Consequence:* the host + package version carries no maturity or compat weight (it is cruft, normalizing to `@0.1.0` + per decision 8), so this folds in with the same pre-release reshape in Phase 0 — order it + apart only if a technical dependency demands it, never for version-maturity reasons. +3. **(R3) The egress guard is advisory-only for M1.** Keep `AllowAllGuard`, feature-gate the + `pool` import, and document the boundary as **not yet enforcing**. *Consequence:* the real + guard — trust model, single-decode, and where it runs — is deferred wholly to the + egress-guard epic; no teeth land during the M1 timeline (consistent with decision 7). +4. **(R7) Install-time capability handshake for body-schema agreement.** A `body_version` (or + version-set) field in the module and adapter manifests; `Supervisor::install` asserts the + module's version is in the adapter's supported set and refuses to boot a mismatched pair + (fail fast, logged). *Consequence:* a manifest + supervisor change (**not** WIT-freeze- + gated), so it lands as a concrete Phase 1-2 step — not "decide the shape now, build later." +5. **(Q5 / R1-B2) 0.1 is EVM-only** — a *scoping* choice, not a compatibility one. Non-EVM + settlement (de-EVM `auth-scheme`/`unsigned-tx`) lands later. *Consequence:* nothing is + pinned (decision 8), so non-EVM + quoting can land whenever a design partner arrives, at the + cost of an internal recompile + a fold — no additive down-payment or wire-break avoidance is + needed; the named-records + version-discriminator work stands on its own hygiene merits. +6. **(Q6 / R4) `#[nexum::venue]` is the single blessed authoring path,** fixed to emit + `impl VenueAdapter` (not a raw `Guest` impl). *Consequence:* `export_venue_adapter!` is + demoted to the internal codegen detail the macro expands to — not a public second path; the + "which is blessed?" ambiguity is removed. +7. **(R3) Identity signing lands with the guard, later (Phase 3).** *Consequence:* the "chain + delegates to identity for signing" claim in `chain.wit`/`doc-08` stays unrealised until + then; module authors should not build against the signing seam before Phase 3. +8. **(new, 2026-07-14) WIT package versions are pre-release cruft — all normalize to a single + `@0.1.0`.** The accumulated version strings (`nexum:host@0.2.0`, `nexum:intent@0.1.0`, + `nexum:value-flow`, `nexum:adapter`, `shepherd:cow@0.2.0`, …) are not meaningful + compatibility boundaries; no cross-version compatibility exists or is preserved, and no + external consumer pins any of them. *Consequence:* every WIT package and `use …@` + reference resets to `@0.1.0` (the true initial release) via one `git-filter-repo`/`jj` fold + in Phase 0; until that release is cut, a "breaking" change is internal-only (recompile + + fold), never a wire break — which is what makes the Phase-0 reshape free. Cross-referenced + by the reframed Phase 0, R1, and decisions 2/5. + +- the exact wording + versioning scheme of the documented **opaque-status destructuring + contract** the host `event` stream commits to (decision 2); +- the precise **manifest key name** (`body_version` vs a version-set field) and the + supported-set match semantics for the install-time handshake (decision 4). diff --git a/docs/design/videre-body-version-decision.md b/docs/design/videre-body-version-decision.md new file mode 100644 index 00000000..ad583944 --- /dev/null +++ b/docs/design/videre-body-version-decision.md @@ -0,0 +1,61 @@ +# Body-version handshake — decision (#373) + +Fixes the manifest key and match semantics for the install-time body-schema +handshake. The M2 enforcement wiring references this. Companion to +`videre-wit-pinned-0.1.0.md` (which reserves `adapter.body-versions()`). + +## Problem + +A keeper serialises an order into an opaque `body: list` at one schema +version; the venue adapter decodes it. They ship as separate components an +operator upgrades independently, so a keeper built at schema v2 against an +adapter that only decodes v1 fails obscurely at runtime. The handshake refuses +that pair at boot instead. + +## Decision + +Manifest key: a videre-scoped `[venue]` section, snake_case fields. + +```toml +# keeper module.toml # adapter module.toml +[module] [module] +kind = "event-module" kind = "venue-adapter" +[venue] [venue] +body_version = 2 body_versions = [1, 2] +``` + +- The keeper declares **one** `body_version` (it encodes exactly one layout). +- The adapter declares the **set** `body_versions` it can still decode. +- Install succeeds iff `keeper.body_version` is in `adapter.body_versions`. + Otherwise `Supervisor::install` refuses the pair, fail-fast and logged. + +## Where it lives and who checks + +- `nexum-runtime` stays venue-agnostic: it parses `[venue]` opaquely and routes + it to the extension. It ascribes no meaning to `body_version`. +- `videre-host` supplies the install predicate through the generalized + `Extension` seam and enforces the membership check. +- The adapter **manifest** `[venue] body_versions` is the install-time + authority: the check is static, before instantiation, so a mismatch fails + before any boot cost. The WIT `adapter.body-versions() -> list` export + stays for runtime introspection; the venue macro emits both from the same + `IntentBody` version, so they cannot drift. + +## SDK / macros own the plumbing + +The body schema is one typed `IntentBody` (with a version constant) in a shared +per-venue crate both sides depend on. + +- `#[videre::venue]` emits the decoder, `body-versions()`, and the adapter + `[venue] body_versions` from that `IntentBody`. +- `#[videre::keeper]` + `VenueClient` take typed bodies; the macro marshals to + `list` and stamps the keeper `[venue] body_version` from the same constant. + +An author writes typed Rust, never a byte or a version number. Bumping the +shared `IntentBody` moves the encode path and the manifest version together; the +handshake only fires on an independent-upgrade mismatch. + +## Scope + +M1 decision only. The `Supervisor::install` predicate, the `[venue]` parse, and +the mismatched-pair test are M2. diff --git a/docs/design/videre-fold-runbook.md b/docs/design/videre-fold-runbook.md new file mode 100644 index 00000000..8cbab95f --- /dev/null +++ b/docs/design/videre-fold-runbook.md @@ -0,0 +1,75 @@ +# Videre reshape fold — runbook + tip oracle (#366) + +The recipe #366 executes: replay the whole reshape across the M1 train as one +`git-filter-repo` pass, rebase the stack with jj + mergiraf, and gate on a +byte-identical tip oracle. Validated by a dry-run of the version-normalize slice +(#371) on 2026-07-16. + +## Tooling + +`git-filter-repo`, `jj` 0.42, `mergiraf` 0.17, `wasm-tools` 1.252 — all run +ephemerally via `nix shell nixpkgs#`. + +## The harness + +Work in an isolated clone; never fold the live checkout. + +```sh +git clone --no-local /code/nxm/runtime clone && cd clone # no shared objects +git filter-repo --replace-text rules.txt --force # content transform +``` + +`rules.txt` is package-scoped regex, one rule per transform. Content-based, so +it hits every file type (wit, rs, toml, md, goldens) uniformly. + +## The tip oracle + +Two rebuild paths must produce a byte-identical tip tree: + +- **B (fold):** transform replayed across the train, then `git rev-parse ^{tree}`. +- **A (direct):** the same rules applied once to the original tip tree + (`git commit-tree ` into an orphan, filter-repo, read its tree). + +`A == B` proves the fold is a pure, history-independent blob transform. A +divergence means the fold did not cover every touched blob (see the finding). + +## Hazard: scope every rule to the package + +A blanket `@0.2.0 -> @0.1.0` corrupts WASI, which carries its own `@0.2.0` +(`wasi:io/streams@0.2.0`, `wasi:sockets/tcp@0.2.0`, ...). Rules MUST bind the +package prefix: + +``` +regex:(nexum:host[/a-z0-9-]*)@0\.2\.0==>\1@0.1.0 +regex:(shepherd:cow[/a-z0-9-]*)@0\.2\.0==>\1@0.1.0 +``` + +Dry-run confirmed: 5 WASI `@0.2.0` refs left intact, nexum/shepherd normalised. + +## Finding: base-owned blobs do not ride a train-range fold + +Range-limiting the fold to the train (`develop..dev/m1`) leaves 4 `@0.2.0` +survivors: `wit/nexum-host/logging.wit` and `wit/shepherd-cow/{cow-ext,shepherd}.wit` +(plus one ADR). Those files were last touched by base commits (`7ab804a`, +`70ec505`), so their tip blob arrives through the develop side of the tip merge +and is never in the train's blob stream. The oracle caught it (A != B). + +Implication for the split: + +- **Train-owned reshape folds cleanly** — the `nexum:intent`/`nexum:value-flow`/ + `nexum:adapter` -> `videre:*` rename and the surface thin touch packages + authored in the train (#247/#248), so every touched blob is in range. +- **Base-owned normalise does not** — `nexum:host` and `shepherd:cow` are L1/base + packages. Their `@0.2.0 -> @0.1.0` normalise must land as a base commit on + develop, under the train, not inside the L2 videre fold. R6 (#361) already + edits `nexum-host/types.wit`, so that file is train-touched and normalises with + the edit; the untouched host/cow WIT files do not. + +Rule: a fold rule only reaches files the fold range owns. Split base-package +normalise (develop) from the train-owned videre reshape (the fold). + +## Dry-run result (version-normalize slice) + +- Range fold: oracle red (4 base-owned survivors) — correct signal. +- Full-coverage fold: oracle green, 0 nexum/shepherd `@0.2.0` left, WASI intact. +- Harness, oracle, and scope-guard all validated. No push. diff --git a/docs/design/videre-split-plan.md b/docs/design/videre-split-plan.md new file mode 100644 index 00000000..55f5ffd0 --- /dev/null +++ b/docs/design/videre-split-plan.md @@ -0,0 +1,453 @@ +# The videre split — next-push execution plan + +**Splitting `nullislabs/shepherd` (`dev/m1` @ `ddfb2b9`, design-doc baseline `aed942c`) into three repos: `nexum-runtime` ← `videre` ← `CoW-on-videre`.** + +Status: decision-ready blueprint. Extends [`docs/design/venue-platform-architecture.md`](./venue-platform-architecture.md) (`9fca43c:docs/design/venue-platform-architecture.md`) — read §2 (three-layer target), §6 (R1–R8), §7 (migration plan), §8 (the 7+1 decisions) first. This plan does **not** restate the layer model or the red-team; it consumes them and turns them into an ordered set of moves with concrete crate/WIT/file targets. + +--- + +## 1. Executive summary + +**End state — three repos, one acyclic edge each:** + +- **`nexum-runtime`** — the universal host: runtime engine, generic supervisor, capability model, the `Extension` seam, the module SDK/macro, and the single leaf WIT package `nexum:host`. **Zero** intent/venue/cow knowledge. +- **`videre`** — the venue-neutral intent-settlement + quoting layer: the intent WIT packages, the venue-adapter world + component kind, the venue SDK + `#[venue]` + conformance kit, the pool router (as a host-side `videre-host` crate that plugs into the runtime seam), and the generic keeper *assembler*. +- **`CoW-on-videre`** — the concrete CoW venue adapter (cdylib), the CoW bodies + classification, and the composable-cow *keeper* strategy module — all riding videre's `pool` contract. + +**The single biggest sequencing decision:** land the **R6 host↔intent WIT decouple (§8 decision 2)** as move #1, before any crate moves. `wit/nexum-host/types.wit:8` does `use nexum:intent/types@0.1.0.{receipt, intent-status}` — the L1 host world literally imports the L2 intent package. Until that `use` is gone (host `event` carries opaque status bytes), `nexum-runtime` cannot compile without videre's WIT, and **an acyclic split is physically impossible.** Everything else is downstream of this one line. + +**Go / no-go:** **GO** on the enabling reshape and refactors — do them *now, in the monorepo*, while nothing is pinned (§8 decision 8 makes every WIT change a free recompile, not a wire break). **NO-GO** on the physical repo cut until three gates hold: (a) R6 landed and `nexum-runtime` proven intent-free; (b) a real `cow-venue` **cdylib** exists and the keeper is ported off the legacy `cow-api` host extension onto `pool`; (c) a genuine **second** venue compiles against `videre-sdk` alone. Cutting repos before (b)/(c) freezes a "generic" L2 contract that today only the `echo-venue` toy exercises and that the live CoW path bypasses entirely (`shepherd-sdk/src/cow/run.rs:138` submits via `CowApiHost`, never `pool`). That is R1 unretired — do not codify it into a repo boundary. + +**The long pole is not a file move.** Making `nexum-runtime` venue-agnostic requires generalizing the `Extension` seam (today `LinkerHook` + `NamespaceCaps`) to host a *second component kind* with its own supervised store/actor lifecycle, and lifting `PoolRouter` + the privileged `HostState.pool_router` field out of the core. This has **no ADR or design-doc decision behind it** — it exists only to satisfy the split's acyclicity/zero-knowledge directive. Scope it as real runtime surgery, prove it with `echo-venue` before cow, and make *deleting the `pool_router` field* the forcing-function acceptance test. + +--- + +## 2. Target: the three repos + +### 2.1 Layer / dependency diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CoW-on-videre (L3, app-level, may not publish) │ +│ crates: cow-venue (cdylib adapter) · shepherd-sdk (the keeper) │ +│ shepherd-cow-host (LEGACY read path) · shepherd-sdk-test │ +│ shepherd-backtest · shepherd (the concrete bin) │ +│ wit: shepherd:cow (legacy host-ext surface; retiring) │ +│ vendors: nexum:host + videre:* │ +└───────────────┬─────────────────────────────────────────────────────┘ + │ Rust: git-tag/published dep WIT: wit-deps(videre:*, nexum:host) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ videre (L2, published) │ +│ crates: videre-sdk · videre-test · videre-macros │ +│ videre-host (NEW: PoolRouter + adapter supervision, │ +│ registered via the runtime seam) │ +│ wit: videre:value-flow ← videre:intent ← videre:adapter │ +│ vendors: nexum:host │ +│ examples: echo-venue · echo-client │ +└───────────────┬─────────────────────────────────────────────────────┘ + │ Rust: videre-host depends on nexum-runtime (legal L2→L1 crate edge) + │ WIT: videre:adapter `use`s nexum:host/{types,chain,messaging} + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ nexum-runtime (L1, published) │ +│ crates: nexum-runtime · nexum-sdk · nexum-sdk-test │ +│ nexum-module-macros · nexum-world (plain lib) │ +│ nexum-launch (lib) + nexum (bare Ext=() engine bin) │ +│ wit: nexum:host (LEAF — after R6) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +WIT DAG (post-R6): `nexum:host` (leaf) ← `videre:value-flow` ← `videre:intent` ← `videre:adapter` ← `shepherd:cow`. Every edge points down. The only edge that is *up* in Rust — `videre-host → nexum-runtime` — is legal because it is a host-side crate depending on the engine, exactly as `shepherd-cow-host` depends on `nexum-runtime` today. + +### 2.2 `nexum-runtime` (L1) — charter, crates, surface + +**Charter:** run WASI components under a capability model with a supervised lifecycle, and expose a generic `Extension` seam so *anything* venue/intent/cow-shaped is added from outside. The engine never learns what a venue is. + +| Owns | Detail | +|---|---| +| `nexum-runtime` | engine, `RuntimeBuilder`/`bootstrap::run`/`with_extensions`, generic supervisor, capability registry, `host::extension::Extension`, per-interface host linker primitives (`nexum::host::chain/messaging/logging/identity/local-store/remote-store`), the generic **component-kind + host-actor** facility extracted from `AdapterActor` (fuel refuel, trap→error projection, async-mutex serialization, restart/poison-sweep membership — R8) | +| `nexum-sdk`, `nexum-sdk-test` | guest host SDK (`ChainHost`/`Fault`/`Fetch`), **and the world-neutral keeper primitives** `keeper.rs` (`WatchSet`/`Gates`/`Journal`/`Retrier`/`ConditionalSource`) — verified intent-agnostic, pure local-store, they stay here | +| `nexum-world` (new plain lib) | the `world.rs` synthesis + the KNOWN capability table, now **registry-driven** (no baked `pool`/`cow-api` rows) | +| `nexum-module-macros` | `#[module]` only | +| `nexum-launch` + `nexum` bin | generic launcher lib + a bare `Ext=()` engine binary composing from an empty extension list | +| `wit/nexum-host` | the sole L1 WIT package; **leaf after R6** | + +**Public surface (the seam it exposes upward):** `RuntimeTypes` + the `Ext` slot + `ExtState`; `Extension = { link, capabilities, component_kind, install_predicate }` (the last two are the generalization); per-interface `add_to_linker` primitives; `CapabilityRegistry`/`NamespaceCaps`; `RuntimeBuilder::with_extensions`. Nothing named `pool`, `venue`, `intent`, or `cow` appears anywhere. + +**Zero-leak CI gate (post-refactor, permanent):** `cargo tree -p nexum-runtime` must not reach `videre-*` or any intent/cow crate; `rg 'nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' crates/nexum-runtime/src` must return empty. + +### 2.3 `videre` (L2) — charter, crates, surface + +**Charter:** the generic, venue-neutral intent-settlement + quoting abstraction. A venue author codes against videre; videre is CoW-blind. Named *videre* ("to see") — price/liquidity discovery across venues is the quoting half; it names the intent/venue layer without colliding with the rejected watch/observer vocab. + +| Owns | Detail | +|---|---| +| `videre-sdk` (← `nexum-venue-sdk`) | `VenueAdapter` trait, `IntentBody` versioned-borsh codec, `IntentClient

` over the byte-level pool seam, typed transport wrappers, fault folds; **plus the generic `Keeper::sweep` assembler (§5.3) and the `Sweep` outcome** resolving the dangling `ConditionalSource::Outcome` | +| `videre-test` (← `nexum-venue-test`) | `CodecVectors`/`HeaderGoldens`/`MockTransport` conformance kit — the cargo-test-fails-if-wire-drifts gate | +| `videre-macros` | `#[venue]` (the single blessed `impl VenueAdapter` path, R4) + `#[derive(IntentBody)]` + `synthesize_venue`; depends on `nexum-world` | +| `videre-host` (**new**) | `PoolRouter` + `impls/pool.rs` + adapter supervision + guard seam + the `venue-adapter`/`pool-host` bindgens, registered into `nexum-runtime` via the generalized seam. **Host-side crate, depends on `nexum-runtime` — the split's core enabling work** | +| `wit/videre-{value-flow,intent,adapter}` | the intent contract (renamed from `nexum:*`, §8-dec-8 fold); `videre:adapter` `use`s `nexum:host/{types,chain,messaging}` = the dependency edge onto L1 | +| examples | `echo-venue` + `echo-client` — the canonical venue-neutral demos, so videre ships a working end-to-end example independent of CoW | + +**Public surface (the "author a venue" front door):** `#[videre::venue] impl VenueAdapter`, `#[derive(IntentBody)]`, `IntentClient::quote(&body)?.submit()?` typestate, the `videre-test` golden gate, `VenueId` zero-cost newtype + Order-style builder (§5.2/§5.4). Ships with quoting (`videre:intent/quote` + `adapter.quote`) in 0.1.0 — thin, value-flow-typed, EVM-only (§8 decision 5) — because a settlement layer without quoting is only half its thesis. + +### 2.4 `CoW-on-videre` (L3) — charter, crates, surface + +**Charter:** one concrete CoW venue on videre, plus the composable-cow keeper that drives it. This repo is where all cow protocol knowledge lives; L1 and L2 never compile it. + +| Owns | Detail | +|---|---| +| `cow-venue` | grown from today's body-only `[lib]` to a **cdylib** adapter targeting `videre:adapter/venue-adapter`; `#[videre::venue] impl VenueAdapter for CowVenue`; caps `[chain, http]`; owns `CowIntentBody`/`ComposableBody`, the borsh codec, and `classification.toml` (projects orderbook `errorType → venue-error`) | +| `shepherd-sdk` | the **keeper**: `ConditionalSource`/`Verdict`/`Retrier` + `cow::run`, consuming videre's generic `Keeper::sweep`; a strategy event-module importing `videre:intent/pool` | +| `shepherd-cow-host` | the **legacy** `cow-api` host extension = the read path; retires down to a shim as the keeper ports onto `pool` | +| `shepherd-sdk-test`, `shepherd-backtest` | tests + backtest | +| `shepherd` bin | the concrete composition root (today's `nexum-cli` cow-wiring logic) | +| `wit/shepherd-cow` | legacy host-ext surface only; deleted at wire-swap | + +**Modules:** ethflow-watcher, twap-monitor, stop-loss, orderbook-mock, and the composable-cow keeper module. + +### 2.5 DX walkthrough A — author a venue on videre + +```rust +// my-venue/Cargo.toml → [lib] crate-type = ["cdylib"]; deps: videre-sdk +use videre_sdk::{VenueAdapter, IntentBody, prelude::*}; + +#[derive(IntentBody)] // versioned borsh codec + goldens +enum MyBody { V1(MyIntentV1) } + +#[videre::venue] // the single blessed path (R4) +impl VenueAdapter for MyVenue { + fn derive_header(&self, body: &[u8]) -> Result { … } + fn quote(&self, body: &[u8]) -> Result { … } // 0.1.0 surface + fn submit(&self, body: &[u8]) -> Result { … } + fn status(&self, id: IntentId) -> Result { … } +} +``` +`module.toml`: `capabilities = ["chain", "http"]` (transport-only — §8 decision 1). Then `cargo test` runs the venue against `videre-test` golden vectors; a wire drift fails the build. No host code, no runtime dep, no cow knowledge. + +### 2.6 DX walkthrough B — run a keeper on a videre venue + +```rust +// A strategy event-module (targets nexum:host/event-module, imports videre:intent/pool) +struct CowSource { /* polls ComposableCoW.getTradeableOrderWithSignature */ } +impl ConditionalSource for CowSource { + fn poll(&self, ctx: &mut Ctx) -> Sweep { // generic outcome (videre) + match self.verdict(ctx) { // CoW Verdict → Sweep + Verdict::Post(order) => Sweep::Submit(CowIntentBody::v1(order).encode()), + Verdict::WaitBlock => Sweep::WaitBlock, + … + } + } +} +// The runtime boots this module; videre's Keeper::sweep assembles +// WatchSet → Gates → source.poll → Retrier → Journal, and routes +// Sweep::Submit(bytes) through pool.submit(CowVenue::ID, bytes) → cow-venue adapter. +``` + +--- + +## 3. The videre abstraction + +### 3.1 Naming and the `videre:*` WIT rename + +`nexum:value-flow / nexum:intent / nexum:adapter → videre:*`, folded into the §8-decision-8 `@0.1.0` normalization pass. **Not load-bearing for acyclicity — gate nothing on it.** Rationale for folding it in: these packages physically move to the videre repo, so the `nexum:` prefix would misattribute them to the host; and the normalization `git-filter-repo`/`jj` fold is *already pending* (versions are unnormalized — `nexum:host@0.2.0`, `shepherd:cow@0.2.0`), so the marginal cost is one extra `sed` inside a pass that must run anyway. Keep `nexum:host` (L1 brand) and `shepherd:cow` (L3). Caveat: `value-flow` is the hardest-freezing contract — regenerate all `videre-test` goldens under the new namespace and re-assert the byte-identical tip oracle. + +### 3.2 The venue SDK + `#[venue]` + +`#[videre::venue]` is the single blessed authoring path (§8 decision 6 / R4), emitting `impl VenueAdapter`; `export_venue_adapter!` demotes to the internal codegen it expands to. This kills the two forked Guest impls (`nexum-macros/src/lib.rs:422` raw Guest vs `:426` adapter Guest) that today fork "the one clear arrangement of traits." Pull R4 forward into the refactor phase so videre ships **one** authoring surface at carve time, not two across a repo boundary. + +### 3.3 Quoting + the install-time schema handshake + +- **Quoting (new in 0.1.0):** `videre:intent/quote.wit` + `adapter.quote` + an `IntentClient.quote(&body)?.submit()?` typestate. Keep the quote record thin and value-flow-typed until a second real venue exercises it (guards against enshrining a CoW/EVM-shaped quote, R1). EVM-only (§8 decision 5). Free now (§8 decision 8), a wire break later. +- **Install-time `body_version` handshake (§8 decision 4 / R7):** the adapter/module manifest declares a supported `body_version` set; the supervisor asserts agreement at install. The **schema is videre's** but `Supervisor::install` is in `nexum-runtime`, which must stay venue-agnostic — so `videre-host` supplies the *install predicate* through the generalized seam. No WIT gate. + +### 3.4 How videre consumes `nexum-runtime` host worlds + +Two distinct edges, both legal and both downward-in-contract: + +1. **WIT:** `videre:adapter/venue-adapter` `use`s `nexum:host/{types,chain,messaging}` and imports host `chain`+`messaging`+`wasi:http`. This is videre's WIT dependency on L1 — correct direction, acyclic *after R6*. +2. **Rust:** `videre-host` depends on `nexum-runtime` (the crate) to reach `wasmtime::Store`/`HostState`/`RuntimeTypes` and to register through `with_extensions`. This resolves the L6 objection ("the router is irreducible host code so it must stay in L1"): a host-side crate that *depends on* the engine is a legal L2→L1 crate edge, exactly like `shepherd-cow-host` today. The router's *contract* (WIT + guest SDK) is L2; its *implementation* is a host-side L2 crate; neither forces `nexum:intent` back into the L1 core. + +**Reconciliation with the 7 decisions:** dec-1 (transport-only adapters) = videre's adapter linker exposes only generic host interfaces, never a cow interface; dec-2 (opaque status) = the acyclicity gate that lets `nexum:host` version independently of videre; dec-3 (advisory guard) = videre advertises `derive→guard→submit` whose teeth are deferred, and videre docs **must** say the guard is advisory-only for M1 (`AllowAllGuard`); dec-4 = the install predicate above; dec-5 = EVM-only quoting/bodies; dec-6 = `#[videre::venue]`; dec-8 = the free rename+quote window that closes at the cut. + +--- + +## 4. CoW-on-videre + the composable-cow keeper + +### 4.1 The R2 category error, resolved explicitly + +There is no "cow-protocol WASI" world, and **a component targets exactly one world.** So "composable-cow as a keeper on videre" is **not** a separate component or world. Cow enters the system as exactly three artifacts on **two** component worlds plus one host extension — never as world-composition: + +| Piece | Kind | World it targets | How cow enters | +|---|---|---|---| +| **(a) `cow-venue` adapter** | cdylib component | `videre:adapter/venue-adapter` | exports `videre:intent/adapter`; imports `chain`+`wasi:http`; decodes `CowIntentBody`, POSTs `OrderCreation`, projects orderbook `errorType → venue-error` | +| **(b) composable-cow keeper** | strategy component | `nexum:host/event-module` | imports `videre:intent/pool`; runs the ADR-0013 poll loop; drives the adapter with **opaque** bodies | +| **(c) `shepherd-cow-host`** | host extension | (linker seam, not a world) | the **legacy** read path; retires to a shim | + +Composable orders are just a `ComposableBody` variant of `CowIntentBody` (R2) — no separate "composable" component. `Verdict::Post` is half-populated today (`LegacyRevertAdapter` never produces it, `composable.rs`) and `NeedsInput` is dead until the fork — so the keeper-on-videre path is *architecturally* coherent but *not yet instantiable*; naming a repo around it is ahead of the code, which is exactly why the physical cut waits on gate (b). + +### 4.2 How they compose via `pool` + +Build-time: the adapter depends on `videre-sdk` + `cow-venue::body`; the keeper depends on `nexum-sdk` + `cow-venue::body` (to encode `CowIntentBody`) + videre's `pool` bindings. **Neither imports the other's world.** Runtime: the supervisor boots both as guests; `videre-host`'s `PoolRouter` wires `pool.submit("cow", bytes) → resolve venue-id → derive-header → guard → cow-venue.submit`. The port that flips `run.rs:138` `host.submit_order(...)` (legacy `CowApiHost`) to `pool.submit(CowVenue::ID, cow_body_bytes)` is the concrete embodiment of gap #2 — it retires the `CowApiHost` trait and (eventually) the `shepherd-cow-host` extension. + +### 4.3 The clean re-split of responsibility + +- **Keeper** = poll ComposableCoW + decide + emit an opaque `CowIntentBody` + classify the coarse `venue-error` via `Retrier`. +- **Adapter** = `CowIntentBody → OrderCreation` JSON + `wasi:http` POST + orderbook-`errorType → venue-error`. `build_order_creation`/`order_uid_hex`/`gpv2_to_order_data` (today in `shepherd-sdk/src/cow/{run,order}.rs`) move **into** the adapter's `submit`. `classification.toml` moves into the adapter. +- **Consequence (R1 reshape, do in Phase 0):** the coarse `venue-error` must gain `rate-limited{retry-after-ms}` + `denied` so the retry hint survives the collapse (`faults.rs` already folds `unavailable|rate-limited|timeout`). +- **Idempotency seam (settle before the port):** `run.rs` today derives the client-side UID and checks the `submitted:` Journal *before* the network call. Once assembly moves to the adapter, the keeper can't derive the UID pre-submit → double-post risk. Fix: have the adapter's `derive-header` (already called pre-submit by the router) return a deterministic intent-id the keeper journals, or make `SubmitOutcome` carry `receipt = UID`. + +### 4.4 ADR-0013 gate + +The **Rust seam** re-split (keeper ↔ adapter) has zero contract dependency and lands **now**, independent of the fork. Only the **poll wire-swap** — deleting `composable.rs`/`LegacyRevertAdapter` — is hard-blocked until the fork's `deployments/networks.json` is non-empty on a shepherd target chain. **Do not couple them**, or the whole CoW-on-videre split freezes behind a third-party deployment clock. + +--- + +## 5. Sequencing — the next push + +Reshape-then-extract, decisively. Do everything free while one repo; cut repos only at the end, gated. + +### Phase 0 — the free WIT fold (one repo, one oracle-validated pass) + +The design doc's Phase-0 cluster **plus two split-enablers**, all in one `git-filter-repo`/`jj` fold with the byte-identical tip oracle and regenerated goldens: + +| Move | Target | Why here | +|---|---|---| +| **R6 host↔intent decouple** (§8 dec-2) | drop `wit/nexum-host/types.wit:8` `use nexum:intent/types.{receipt,intent-status}`; host `event` → opaque status bytes with a **versioned destructuring contract** (specify the wording — still open in §8) | **THE master gate.** Until this lands, L1 can't compile without L2's WIT. | +| version normalize | all packages → `@0.1.0` (§8 dec-8) | closes the free window; must precede the cut | +| `videre:*` rename | `nexum:{intent,value-flow,adapter}` → `videre:*` | free now, a fold later; gate nothing on it | +| `venue-error` reshape (R1) | add `rate-limited{retry-after-ms}` + `denied` | so classification survives the adapter split | +| the rest of doc Phase-0 | `valid-until-ms`, named ERC records, codec version discriminator, doc caveats, migration-cruft deletion | free contract reshape | + +**Exit test:** `cargo tree -p nexum-runtime` no longer reaches any intent crate; the WIT DAG builds acyclically in one repo. + +### Phase 1 — finish M1 cleanly (one repo, no fold) + +Land the doc's Phase-1 Rust amends (#249/#250/#251/#296), the Wave-1 #334 verdict-seam fixes, the R7 install-time `body_version` handshake (manifest + supervisor, no WIT gate), and the approved cars — to a single **green linear** `dev/m1` tip. Do not begin the carve until this tip exists (carving mid-train triples the fold surgery across three repos). + +### Phase S1 — the generalization (one repo, the long pole, NOT free) + +The only non-free work in the plan; validated by `echo-venue` boot tests as the oracle, **before** any cow involvement. + +1. **Generalize the seam.** Grow `host/extension.rs` `Extension` from imports-only to register a **component kind** (its store/actor/install lifecycle) + a **host actor** + an install predicate. Extract the generic supervised-component primitive from `AdapterActor` (fuel refuel, trap projection, async-mutex serialization, restart/poison-sweep per R8) into `nexum-runtime`. +2. **Move the router to `videre-host`.** Lift `host/pool_router.rs`, `host/impls/pool.rs`, the `venue_adapter`/`pool_host` bindgens (`bindings.rs:21-24` etc.), `build_adapter_linker`, the adapter path of `synthesize_venue`, and `INTENT_/ADAPTER_` namespaces into the new `videre-host` crate. **Forcing function: delete the `HostState.pool_router` field (`state.rs:54`) and carry the router in a composite `Ext` lattice** — if that field is gone and echo still boots, L1 is intent-free. +3. **Split the macros.** Factor `world.rs` synthesis + a **registry-driven** KNOWN table (delete the baked `pool` row at `world.rs:74` **and** the `cow-api → shepherd:cow` row at `:80` — an L1→L3 name leak) into `nexum-world` (plain lib, L1). `#[module]` → `nexum-module-macros` (L1); `#[venue]`+`IntentBody`+`synthesize_venue` → `videre-macros` (L2). Rewrite `find_wit_root` (`lib.rs:512`) from workspace-ancestor-walk to crate-local `wit/`+`wit/deps` resolution. Pull R4 forward (single blessed `#[venue]`). +4. **Split the CLI.** Generic launch + bare `nexum` bin stay in L1; the cow composition root (`launch.rs:16,47` `shepherd_cow_host::extension` / `with_extensions`) becomes the `shepherd` bin destined for L3 — fixing today's backwards `nexum-cli → shepherd-cow-host` dep. + +### Phase S2 — flip WIT resolution, then carve (still one repo → three) + +- **S2a (one repo):** introduce `wit-deps` (`deps.toml`) per prospective repo; flip every `bindgen!` path list and the macro WIT-root off `../../wit/*` to crate-local `wit/` + `wit/deps/`. Prove the acyclic graph still builds. Source cross-package WIT from git tags initially (no registry exists); check in resolved lockfiles. +- **S2b (three repos):** three `git-filter-repo --path` extractions preserving history — reuse the keeper-rename template from memory (range-limited `git-filter-repo` + byte-identical tip oracle + `jj`/`mergiraf`), per-repo. Wire cross-repo Rust via git-tag pins, cross-repo WIT via `wit-deps` git tags, held together by a **transitional umbrella superproject** with path-deps during stabilization. Converge to published crates.io (L1/L2) + `wkg`/OCI (`ghcr.io/nullislabs`) once stable; L3 stays app-level. + +### Phase S3 — de-risk R1 (videre repo) + +**Acceptance gate for calling the split done:** build the first real **non-cow** venue (rfq or amm-router) against `videre-sdk` alone. `echo-venue` is a toy and cannot prove venue-neutrality; the live CoW path bypasses videre, so without a second real venue the split ships an unproven L2. + +### Do-now / do-during-split / defer triage + +| Do NOW (Phase 0/1, monorepo, free) | Do DURING split (S1/S2) | Defer (M7 / design-partner / fork-gated) | +|---|---|---| +| R6 host↔intent decouple | seam generalization + delete `pool_router` field | `cow-venue` cdylib fully replacing the legacy path | +| `@0.1.0` normalize + `videre:*` rename | `videre-host` extraction | keeper clean-break off `CowApiHost` (needs idempotency seam) | +| `venue-error` reshape (R1) | macro split + registry-driven KNOWN table | poll wire-swap / delete `composable.rs` (ADR-0013, fork-gated) | +| quoting stub (`videre:intent/quote`) | CLI split → `shepherd` bin | de-EVM (dec-5 EVM-only for 0.1) | +| R7 install-handshake shape | `wit-deps` flip + three `git-filter-repo` carves | real egress guard w/ teeth (dec-3 advisory for M1) | +| finish M1 to a green linear tip | second-venue acceptance (S3) | `Keeper` materialiser (M7) | + +--- + +## 6. Red-team & open decisions + +### 6.1 Ranked risks + mitigations + +1. **Seam generalization is the long pole and has no design-doc mandate.** If `Extension` can't be grown to host a component kind, the router stays in L1, `nexum:intent` stays bound in the core, and the zero-knowledge invariant fails → no acyclic split. **Mitigate:** prove pool-router-as-extension with `echo-venue` only, in-monorepo; make *deleting `HostState.pool_router`* the acceptance test; do it before any cow work and before any carve. +2. **R6 not landed + its contract still open.** `types.wit:8` is live; §8 still lists "the exact wording + versioning of the opaque-status destructuring contract" as open. An under-specified status contract blocks Phase 0. **Mitigate:** specify the versioned discriminator first, land dec-2, CI-gate `nexum-runtime` intent-free. +3. **Freezing videre on a toy (R1 unretired).** Sole real consumer is `echo-venue`; `cow-venue` has no cdylib; the live keeper bypasses `pool`; dec-5 scopes it EVM-only → high odds it's CoW/EVM-mis-shaped. **Mitigate:** gate the *repo cut* (not the in-repo refactor) on a real second venue + a real cow cdylib + the keeper-on-`pool` port. +4. **Cross-repo WIT versioning has no teeth during transition.** `wit-deps` git-tag sourcing gives no semver enforcement; a mispinned tag silently drifts the contract until a bindgen error. And the split *creates* the external consumers that make a host WIT change a real break — the free-reshape window closes at the cut. **Mitigate:** complete all Phase-0 reshapes before S2b; pin exact tags; check in resolved lockfiles; adopt independent per-package semver (`nexum:host@0.1.x`, `videre:*@0.1.x`, `shepherd:cow`) with caret ranges. +5. **Forced cycles if steps are mis-ordered.** Three: WIT (`nexum:host→nexum:intent`, broken by R6), Rust crate (`nexum-cli→shepherd-cow-host`, broken by moving the bin to L3), bindgen (core binds `nexum:intent`, broken only *after* R6 by the router extraction). **Mitigate:** the phase order *is* the mitigation — R6 → seam+router → CLI move → carve. Verify each cycle broken and green in one repo before S2b. +6. **Idempotency regression on the keeper port** (double orderbook posts). **Mitigate:** settle the deterministic intent-id / `receipt=UID` seam before moving `OrderCreation`/UID assembly into the adapter. +7. **DX regression from three repos.** Loses the single hoisted dep table (built specifically to stop cowprotocol alpha-vs-alpha.3 drift), the shared `Cargo.lock`, and atomic folds. **Mitigate:** transitional umbrella superproject with path-deps through S1–S3; published-and-pinned WIT packages + a CI dep/WIT-sync check; never permanent cross-repo path-deps. +8. **Naming.** `videre:*` forks the wire brand and stacks a rename fold. **Mitigate:** fold the rename into the pass that must run anyway; gate nothing on it; keep `nexum:host` + `shepherd:cow`. + +### 6.2 Decisions the team must make before starting + +| # | Decision | Recommendation | +|---|---|---| +| D1 | Router placement | **Extract to `videre-host` (L2 host-side crate) via the generalized seam.** Not a relabel — requires seam generalization. Alternative (keep in L1) violates zero-knowledge. | +| D2 | Seam generalization vs special-case | **Generalize** `Extension` to register a component kind + host actor + install predicate. The special-case path leaves intent shape in L1. | +| D3 | Macro split shape | **`world.rs` → `nexum-world` (L1 plain lib); `nexum-module-macros` (L1) + `videre-macros` (L2)**, KNOWN table registry-driven (de-hardcode `pool` **and** `cow-api`). | +| D4 | Keeper location | **Primitives stay in `nexum-sdk`** (verified world-neutral); only the not-yet-written `Keeper::sweep` assembler → `videre-sdk`; CoW `ConditionalSource`/`Verdict` → L3. (Corrects the "whole keeper to videre" framing.) | +| D5 | `videre:*` rename timing | **Fold into the dec-8 normalization pass; gate nothing on it.** | +| D6 | Quoting in 0.1.0 | **Ship it** (thin, value-flow-typed, EVM-only) — it's half the thesis and free now. | +| D7 | `nexum-cli` placement | **Split:** generic `nexum` + launcher lib stay L1; the cow composition root → `shepherd` bin in L3. (Corrects the starting map.) | +| D8 | Cut timing | **Gate the physical repo cut on: R6 landed + real `cow-venue` cdylib + keeper-on-`pool` + a real second venue.** Refactor now, cut later. | +| D9 | Cross-repo dep medium | **Git-tag pins (Rust) + `wit-deps` git tags (WIT) first; crates.io + `wkg`/OCI once stable.** | +| D10 | Transitional workspace | **Yes** — one workspace / path-deps through S1–S2a, umbrella superproject S2b–S3; split physically only at S2b. | + +--- + +*Grounded against `dev/m1` (`ddfb2b9`) and `9fca43c:docs/design/venue-platform-architecture.md`. Every load-bearing path/line was verified in-tree.* + +--- + +## 7. Pinned design — the platform seam, videre WIT, and CoW-on-videre + +_Decided interactively 2026-07-15; this refines §5's sequencing at the end._ + +### 7.1 Composition model — PLATFORM (decided) + +Two models were on the table: a **host-routed platform** (venues install as components; the host dispatches keeper→venue) vs. **`wac` static composition** (compose keeper + adapter into one binary, no host router). **Chosen: the platform** — because a **shared orderbook connection + rate-limit** to CoW genuinely matters in production, and only a shared, installed venue gives one quota / one guard seam / one connection. The cost (a host-side venue runtime) is accepted, offset by **macro-driven, reth/alloy-grade DX** on top. + +### 7.2 The runtime seam — finish the `Extension` seam with worker/provider roles + +The runtime already has `Extension { link, capabilities }` (host/extension.rs) — it adds host interfaces a module imports (that's how `cow-api` works). The "long pole" is that it does only half the job: it can't register a **component kind** (`ModuleKind` is a hardcoded `EventModule | VenueAdapter` enum) or a **host service** (the `PoolRouter` is a privileged field on the supervisor). Both are welded into `nexum-runtime`. + +**Fix:** the runtime knows only two generic **roles** — **worker** (the host pushes events at it; modules, keepers) and **provider** (the host holds it behind a serialized actor; others call it; venue adapters). An `Extension` grows to contribute all four things: + +```rust +pub trait Extension: Send + Sync + 'static { + fn namespace(&self) -> &'static str; // "videre" + fn capabilities(&self) -> NamespaceCaps; // (kept) + fn link(&self, l: &mut Linker>) -> Result<()>; // worker-imported ifaces (kept) + fn service(&self) -> Option> { None } // NEW: the ex-PoolRouter, extension-owned + fn provider(&self) -> Option>> { None } // NEW: a provider kind this ext installs +} +pub trait HostService: Any + Send + Sync + 'static {} // type-erased onto HostState.services[ns] +#[async_trait] +pub trait ProviderKind: Send + Sync + 'static { + fn kind(&self) -> &'static str; // "venue-adapter" + fn link(&self, l: &mut Linker>) -> Result<()>; // provider-imported ifaces (chain, http) + async fn install(&self, c: &Component, s: Store>, svc: &Arc) -> Result<()>; +} +``` + +`videre` becomes **one extension** — `builder.with_extension(videre::platform())` — that registers the venue-adapter provider-kind + the `VenueRegistry` service (the renamed, un-privileged `PoolRouter`) + the `videre:venue/client` interface. The supervisor's `match kind { VenueAdapter => … }` collapses to a generic kind loop. **After this, `nexum-runtime` compiles with zero venue/intent/cow symbols**, and a second platform is just another `impl Extension`. + +| Concept | Today (welded in) | After (plugged in) | +|---|---|---| +| host interfaces | `Extension.link` | kept | +| host service | `supervisor.pool_router` field | `Extension::service` → `HostState.services[ns]` | +| component kind | `enum ModuleKind` + `match` | `Extension::provider` → `ProviderKind` | +| the router | `PoolRouter` | `VenueRegistry` (videre-owned) | +| the actor | `AdapterActor` | `VenueActor` (videre) | +| the guard | `GuardPolicy`/`AllowAll` | `EgressGuard` (videre) | + +### 7.3 MSRV 1.94 / async + +Native `async fn` in traits is stable but **not `dyn`-compatible** on 1.94. So: **native AFIT** for the hot, static-dispatch guest traits (`Venue`, `Keeper`, `VenueClient` — macros emit concrete impls, zero boxing); **`#[async_trait]`** only for the one `dyn`, cold-path boot trait `ProviderKind::install` (per-provider boxing at boot is free); **neither** for `HostService`/`EgressGuard` (kept sync, so `dyn`-compatible as-is). Drop the `async_trait` when `async_fn_in_dyn_trait` stabilizes. + +### 7.4 The pinned `videre:*` WIT surface + +> Superseded by `docs/design/videre-wit-pinned-0.1.0.md` (the byte-exact fold +> target, wasm-tools-validated). It corrects this section: amounts are +> big-endian minimal-length (not 32-byte LE), `u256` is `uint`, the quote func +> is `quote` returning a `quotation` record (a func and a used `quote` type +> collide), `erc20` drops its chain id, and `intent-header` drops `valid-until`. + +Renamed off `nexum:intent`; `quote` in; the maker-side "offer" deferred to **#355**; EVM-only in 0.1; install-time schema handshake in. + +```wit +package videre:types@0.1.0; +interface types { + use videre:value-flow/types.{asset-amount}; + record intent-header { gives: asset-amount, wants: asset-amount, settlement: settlement, authorisation: auth-scheme } + variant auth-scheme { eip1271, eip712 } // non-EVM → 0.2 + record settlement { chain: u64 } // EVM-only in 0.1 + type receipt = list; + variant submit-outcome { accepted(receipt), requires-signing(unsigned-tx) } + record unsigned-tx { chain: u64, to: list, value: list, data: list } + enum intent-status { pending, open, fulfilled, cancelled, expired } + variant venue-error { unknown-venue, invalid-body(string), unsupported, denied(string), + rate-limited(rate-limit), unavailable(string), timeout } + record rate-limit { retry-after-ms: option } + record quote { gives: asset-amount, wants: asset-amount, fee: asset-amount, valid-until-ms: u64 } // firm/RFQ → #355 +} + +package videre:venue@0.1.0; +interface client { // WORKER (keeper) face + use videre:types/types.{quote, receipt, intent-status, submit-outcome, venue-error}; + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} +interface adapter { // PROVIDER (venue) face — mirror; one adapter = one venue + use videre:types/types.{intent-header, quote, receipt, intent-status, submit-outcome, venue-error}; + body-versions: func() -> list; // install-time schema handshake (R7) + derive-header: func(body: list) -> result; + quote: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} + +package videre:value-flow@0.1.0; +interface types { + record asset-amount { asset: asset, amount: u256 } // named records (fixes the old anonymous tuples) + variant asset { native, erc20(erc20) } // erc721/1155/offchain/service → additive later + record erc20 { token: address } + type address = list; // 20 bytes + type u256 = list; // 32 bytes LE +} +``` + +### 7.5 DX — the macros (reth/alloy-grade) + +- `#[nexum::module]` — a worker that reacts to host events (exists). +- `#[videre::venue]` — a provider: write `impl Venue for CowVenue { … }`, the macro emits the `videre:venue/adapter` export + manifest `kind` (the single blessed authoring path, decision Q6). +- `#[videre::keeper]` — a worker that drives a venue: write logic against a typed `VenueClient` (wraps `videre:venue/client`, alloy-style, typed not `list`); the macro wires the event subs. +- Newtypes throughout: `VenueId` (was `venue: string`), `Receipt` — no stringly typing. + +### 7.6 CoW-on-videre end-to-end + the venue↔keeper boundary + +**THE RULE (load-bearing): the cow venue is *only* the CoW orderbook.** It `submit`/`quote`/`status`/`cancel`s an `OrderBody` on `api.cow.fi` and maps orderbook errors to `venue-error`. That is its entire charter — it has never heard of ComposableCoW, `getTradeableOrderWithSignature`, revert selectors, TWAP, or EthFlow. + +**All composable-cow specifics live in the composable-cow keeper and leak nowhere else:** `ComposableBody`/`ConditionalOrderParams` (keeper-internal, used to *poll*, never submitted), the `COMPOSABLE_COW` address + `ConditionalOrderCreated` topic-0, the `getTradeableOrderWithSignature` call/decode, and the revert-selector decoding + `LegacyRevertAdapter` + `Verdict` seam (ADR-0013). The keeper's job: *watch the conditional orders, poll them, produce a plain `OrderBody`* → `cow.submit(order)`. + +``` +composable-cow keeper cow VENUE (orderbook only) +────────────────────── ────────────────────────── +watch ConditionalOrderCreated submit(OrderBody) → /api/v1/orders +poll getTradeableOrderWithSignature quote / status / cancel +revert→Verdict (LegacyRevertAdapter, ADR-0013) classify orderbook errors → venue-error + └─► produces OrderBody ──cow.submit(order)──────► (no idea composable-cow exists) + +ethflow keeper (same shared venue) +watch EthFlow.OrderPlacement; EthFlow consts here +compute UID ──────────────cow.status(uid)─────────► observe/verify path +``` + +**ethflow falls out for free** as a second keeper on the same venue — it doesn't `submit`, it `status`es a computed UID to verify the orderbook indexed the on-chain EthFlow order. Same venue, different verb. + +**The cleave (real refactor):** today's `crates/cow-venue` mixes both — it has `OrderBody` *and* `ComposableBody`/`composable.rs`. Split it into (a) the venue (orderbook + `OrderBody` + classification; the venue body is `OrderBody`-only, drop the `Composable` variant) and (b) the composable-cow keeper. **CI gate:** the venue crate has zero `Composable*` / `getTradeableOrder` / revert-selector symbols. Consequence: anyone can write a new CoW keeper (limit-order, milkman-style) that produces `OrderBody`s without importing composable-cow's machinery. + +**CoW-on-videre repo owns:** the `cow` adapter cdylib (venue), cow bodies (`OrderBody`) + classification, the composable-cow keeper (bodies + poll + Verdict + revert), the ethflow keeper, and the `shepherd-cow` event-ABI WITs. Depends only on `videre` + `nexum-runtime` host worlds — no cycle. `CowApiHost`/`cow-api`/`cow-ext` retire (the design-doc's "biggest lever"). + +## 8. Pinned sequencing (refines §5) + +**Phase 0 — reshape in the monorepo (free; nothing is pinned, decision-8):** +- **P0.1 — R6 decouple (MASTER GATE, move #1):** `wit/nexum-host/types.wit` stops `use nexum:intent/types.{receipt,intent-status}`; host emits **opaque status bytes** + a documented destructuring contract. Until this lands, an acyclic split is physically impossible. +- **P0.2 — `videre:*` rename:** `nexum:intent`→`videre:venue` (`client`+`adapter`) + `videre:types`; `nexum:value-flow`→`videre:value-flow`; fold the readability renames (`pool`→`venue/client`, `PoolRouter`→`VenueRegistry`, `AdapterActor`→`VenueActor`, `GuardPolicy`→`EgressGuard`, `venue: string`→`VenueId`). +- **P0.3 — normalize all WIT to a single `@0.1.0`;** delete the `0.1-to-0.2` cruft; value-flow named records. +- **P0.4 — add `quote`** to `videre:venue` (client + adapter) + the `body-versions` handshake. + +**Phase S1 — the seam (monorepo; the real long pole):** +- **S1.1** grow `Extension` → `{link, capabilities, service, provider}`; add `ProviderKind` + `HostService`; make `HostState.services` a typed map. +- **S1.2** move `PoolRouter`→`VenueRegistry` as an extension-owned service; delete the privileged `supervisor.pool_router` field; collapse the `match kind` to the generic role loop. +- **S1.3** `videre::platform()` registers the provider-kind + service + `videre:venue/client`. **CI gate:** `nexum-runtime` has zero venue/intent/cow symbols. +- **S1.4** prove it with `echo-venue`: a venue installs + a worker submits through the generic seam. + +**Phase S1b — CoW on the generic seam (monorepo):** +- **S1b.1** cleave `cow-venue` → venue (orderbook + `OrderBody` + classification) vs composable-cow keeper. CI gate on the venue crate (no `Composable*`/`getTradeableOrder`/revert). +- **S1b.2** build the `cow` adapter cdylib (`#[venue] impl Venue` over `wasi:http`); retire `CowApiHost`/`cow-api`/`cow-ext` (the "biggest lever"). +- **S1b.3** add `#[videre::keeper]` + typed `VenueClient`; port composable-cow + ethflow onto `videre:venue/client`. +- **S1b.4** green on `dev/m1`. + +**Phase S2 — the repo cut (gated):** +- **Gate:** (a) `nexum-runtime` venue-agnostic (S1), (b) CoW on the generic seam with a real adapter (S1b), (c) a **genuine second-protocol venue** compiles against `videre-sdk` alone (de-risk R1 — not just a second cow keeper). +- Transitional cargo workspace with path deps in the three groupings → verify build → three history-preserving `git-filter-repo` carves (`nexum-runtime` / `videre` / `CoW-on-videre`); flip WIT path-deps → registry/wit-deps. + +**Phase S3 — second-venue acceptance:** the real second-protocol venue merged; videre proven venue-neutral. + +**Deferred:** maker-side "offer" / provide-liquidity (**#355**); RFQ firm-quote (additive on `quote`); the real egress guard (egress epic); `Materialiser` (M7). diff --git a/docs/design/videre-wit-pinned-0.1.0.md b/docs/design/videre-wit-pinned-0.1.0.md new file mode 100644 index 00000000..a1fd336c --- /dev/null +++ b/docs/design/videre-wit-pinned-0.1.0.md @@ -0,0 +1,244 @@ +# Pinned videre WIT surface — 0.1.0 (frozen fold target) + +The byte-exact target the M1 contract fold (#366) rewrites to, and the oracle +checks against. Supersedes `videre-split-plan.md` §7.4 where they differ. + +Decisions locked 2026-07-16: + +1. Thin to §7.4: single-asset `gives`/`wants`, `auth-scheme {eip1271, eip712}`, + EVM-only assets, plain-enum `intent-status`. Dropped cases return as + additive 0.2+ variants. +2. Amounts and addresses are big-endian, minimal-length, variable width (zero = + empty list). §7.4's "32-byte LE" is void. The `u256` alias is renamed `uint`. +3. `intent-status` is a plain enum; settlement proof and failure reason ride the + #360 opaque-status body, not the WIT (see §4). +4. `erc20 {token: address}` carries no chain id; assets share `settlement.chain`. + +Bundled consequence of (1), flagged: `intent-header` drops `valid-until`. Header +expiry is gone in 0.1; expiry survives on `quote.valid-until-ms`. Re-add as an +additive `option` in 0.2 if a keeper needs pre-decode expiry. + +## 1. `videre:value-flow@0.1.0` + +```wit +package videre:value-flow@0.1.0; + +/// Egress-neutral vocabulary for value in motion. Carries no dependency so it +/// outlives any contract built on it. EVM-only in 0.1. +interface types { + /// 20-byte EVM address, big-endian. + type address = list; + + /// Unsigned integer, big-endian, minimal-length: no leading zero bytes, + /// zero is the empty list. Decoders MUST compare by integer value, not by + /// byte equality. + type uint = list; + + /// An ERC-20 token on the intent's settlement chain. + record erc20 { + token: address, + } + + /// A kind of value that can move. erc721/erc1155/service/offchain are 0.2+. + variant asset { + /// The settlement chain's gas token. + native, + erc20(erc20), + } + + /// An amount of one asset. Never negative; direction lives in the field + /// that holds the pair (`gives` vs `wants`). + record asset-amount { + asset: asset, + amount: uint, + } +} +``` + +## 2. `videre:types@0.1.0` + +```wit +package videre:types@0.1.0; + +/// The venue-neutral intent ontology. Depends only on value-flow; never on +/// nexum:host, so the venue-error transport cases are its own. +interface types { + use videre:value-flow/types.{asset-amount}; + + /// How an intent is authorised at its venue. Non-EVM schemes are 0.2+. + variant auth-scheme { + eip1271, + eip712, + } + + /// Where a deal settles. EVM-only in 0.1. + record settlement { + chain: u64, + } + + /// Adapter-derived description of an intent body: the ontology guard policy + /// runs on. Policy has teeth on `gives`; `wants` is display-grade. + record intent-header { + gives: asset-amount, + wants: asset-amount, + settlement: settlement, + authorisation: auth-scheme, + } + + /// Venue-scoped stable id for a submitted intent. Opaque to host and policy. + type receipt = list; + + /// An EVM call the host must sign and send. The adapter only describes it; + /// the host fills gas/fee and signs, so adapters cannot move value. Always + /// a call to existing code. + record unsigned-tx { + chain: u64, + /// 20-byte contract address. + to: list, + /// Native value, big-endian minimal; empty is zero. + value: list, + /// ABI-encoded calldata. + data: list, + } + + /// What a successful submit produced. + variant submit-outcome { + accepted(receipt), + requires-signing(unsigned-tx), + } + + /// Lifecycle state. Coarse and portable; proof and failure reason ride the + /// opaque status body (see docs/design/videre-wit-pinned-0.1.0.md §4). + enum intent-status { + pending, + open, + fulfilled, + cancelled, + expired, + } + + /// Failure of a client or adapter call. `denied` and `rate-limited` are the + /// only guard/transport shapes; `denied` MUST NOT be retried. + variant venue-error { + unknown-venue, + invalid-body(string), + unsupported, + denied(string), + rate-limited(rate-limit), + unavailable(string), + timeout, + } + + record rate-limit { + retry-after-ms: option, + } + + /// An indicative quotation for a body. Firm/RFQ maker-side offers are #355. + record quotation { + gives: asset-amount, + wants: asset-amount, + fee: asset-amount, + valid-until-ms: u64, + } +} +``` + +## 3. `videre:venue@0.1.0` + +Two mirrored faces: the worker `client` face the keeper imports, the provider +`adapter` face one venue exports. One adapter is one venue. + +```wit +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper names a +/// venue by string. +interface client { + use videre:types/types.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} + +/// Provider (venue) face. Mirrors `client` without the venue selector. +interface adapter { + use videre:types/types.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + + /// Supported body schema versions, for the install-time handshake (#373). + 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; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} +``` + +## 4. `#360` opaque status body (host decouple) + +R6 (#361) stops `nexum:host` importing the intent contract. The host event +carries status as opaque `list`; the keeper decodes it. This body is not +WIT; it is a versioned codec the adapter emits and the keeper reads. + +`v1` layout, borsh, leading `u8` version tag = 1: + +``` +status-body-v1 := 0x01 + ++ status: intent-status // enum discriminant + ++ proof: option> // settlement proof, venue bytes + ++ reason: option // set only on a terminal failure + +fail-reason := { code: string, detail: string } +``` + +Contract: + +- The version tag leads. An unknown tag is a decode error, fail-closed + (reject-unknown). +- The body is never empty: at minimum tag + status. +- `proof` is display-grade venue bytes (for an EVM venue, typically the settle + tx hash). The host never inspects it. +- `reason` is present iff `status` decodes to a terminal-failure lifecycle the + venue reports; the enum itself has no `failed` case, so a keeper reads a + non-`fulfilled` terminal state plus a `reason`. +- `code` is a venue-scoped machine string a keeper may match on; `detail` is for + logs and the consent surface. + +Codec goldens carry the version discriminator, a reject-unknown case, and a +non-empty-vector assertion. + +### host `types.wit` after R6 + +```wit +// was: use nexum:intent/types@0.1.0.{receipt, intent-status}; // removed + +record intent-status-update { + venue: string, + /// Venue receipt, opaque to the host. + receipt: list, + /// Opaque status body; see §4. + status: list, +} +``` + +## 5. Rename + normalize map (the fold) + +| From (current) | To | +|---|---| +| `nexum:intent` (types, pool, adapter) | `videre:types` + `videre:venue` (client + adapter faces) | +| `nexum:value-flow` | `videre:value-flow` | +| `nexum:adapter` (venue-adapter world) | folded into `videre:venue` | +| `nexum:host@0.2.0`, `shepherd:cow@0.2.0`, all `videre:*` | `@0.1.0` (single baseline) | +| `PoolRouter` | `VenueRegistry` | +| `AdapterActor` | `VenueActor` | +| `GuardPolicy` / `AllowAllGuard` | `EgressGuard` | +| (new) | `VenueId` newtype | + +Dropped vs current tree (0.2+ additive re-adds): multi-asset `gives`/`wants` +lists; `auth-scheme` presign/offchain-sig/unsigned; `asset` +erc721/erc1155/service/offchain and `service-desc`/`offchain-desc`; off-chain +`settlement`; `intent-header.valid-until`; `venue-error` invalid-receipt / +rejected / internal-error. diff --git a/docs/diagrams/engine-boot.mmd b/docs/diagrams/engine-boot.mmd index ef7ea35b..5c08e634 100644 --- a/docs/diagrams/engine-boot.mmd +++ b/docs/diagrams/engine-boot.mmd @@ -30,7 +30,7 @@ sequenceDiagram PP-->>Bin: pool ready Bin->>LS: LocalStore::open(state_dir) - Note over LS: Creates redb file if missing,
materialises shared table. + Note over LS: Creates redb file if missing,
initialises shared table. LS-->>Bin: store ready Bin->>OBP: OrderBookPool::default() diff --git a/docs/diagrams/sequence-twap.mmd b/docs/diagrams/sequence-twap.mmd index ebb7766a..2a06efc9 100644 --- a/docs/diagrams/sequence-twap.mmd +++ b/docs/diagrams/sequence-twap.mmd @@ -31,7 +31,7 @@ sequenceDiagram RPC-->>Host: return value or revert Host-->>Mod: result (JSON encoded) Mod->>SolDec: decode return or interpret revert reason - SolDec-->>Mod: PollOutcome (module-defined enum) + SolDec-->>Mod: Verdict (module-defined enum) alt Ready (order, signature) Mod->>Cow: build OrderCreation diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md deleted file mode 100644 index 0bbcb844..00000000 --- a/docs/migration/0.1-to-0.2.md +++ /dev/null @@ -1,524 +0,0 @@ -# Migrating from Nexum 0.1 to 0.2 - -Nexum 0.2 is a single coordinated breaking-change release. It does the renames, the error-model unification, the missing primitives, and the capability-negotiation work in one window so module authors only pay the migration tax once. There will not be another breaking release of comparable scope before 1.0. - -This guide is written for two audiences: - -- **Module authors** - you write WASM components that import the Nexum WIT. -- **Host embedders** - you build the runtime that loads modules (the server daemon, a mobile wallet, a browser host). - -Each section is tagged `[author]`, `[embedder]`, or `[both]`. - ---- - -## TL;DR - what changed [both] - -| Area | 0.1 | 0.2 | -|---|---|---| -| WIT package | `web3:runtime` | `nexum:host` | -| Consensus interface | `csn` | `chain` | -| Messaging interface | `msg` | `messaging` | -| Default world | `headless-module` | `event-module` | -| CoW world | `shepherd:cow/shepherd-module` | `shepherd:cow/shepherd` | -| CoW interfaces | `cow` + `order` | `cow-api` (merged) | -| Feed methods | `feed-get` / `feed-set` | `read-feed` / `write-feed` | -| Event variants | `block-data` / `log-entry` / `message-data` / `timer(u64)` | `block` / `log` / `message` / `tick { fired-at }` | -| Errors | 5 different shapes + bare `string` | per-interface typed errors over a shared `fault` vocabulary | -| Capabilities | All six imports mandatory | Manifest-negotiated, optional imports trap on call | -| Engine crate | `nxm-engine` | `nexum-engine` | -| Manifest file | `nexum.toml` (some docs said `shepherd.toml`) | `nexum.toml` (canonical) | -| Manifest field | `wasm = "sha256:..."` | `component = "sha256:..."` | -| Manifest section | `[[subscribe]]` | `[[subscription]]` | -| Config type | `list>` (stringified) | unchanged in 0.2; typed variant on the 0.3 roadmap | -| New capabilities | - | `http` (wasi:http, allowlisted); time and randomness are ambient via wasi:clocks / wasi:random | -| New RPC method | - | `chain::request-batch` (additive) | -| New world | - | `query-module` (experimental, no host impl shipped) | - -If you only do four things: update your `nexum.toml`, run the sed cheat-sheet at the bottom, replace your error handling with the new `fault` vocabulary, and declare your capabilities explicitly. Everything else is mechanical. - ---- - -## 1. WIT renames [author] - -### Package rename - -```diff -- use web3:runtime/types.{config, event}; -- use web3:runtime/chain.{chain-id}; -+ use nexum:host/types.{config, event}; -+ use nexum:host/chain.{chain-id}; -``` - -Why: `web3:` precommitted the engine to crypto-only branding. The package is now named after the engine; web3-specific capabilities live inside it as interfaces. - -### Interface renames - -| 0.1 | 0.2 | Rationale | -|---|---|---| -| `csn` | `chain` | `csn` was unreadable; `chain.request(chainId, method, params)` reads itself. | -| `msg` | `messaging` | `msg` collided with its own `message` record; ambiguous in non-Rust bindings. | -| `cow` + `order` | `cow-api` (one interface) | `cow::cow::request` triple-stutter eliminated; `order::submit` merged as `cow-api::submit-order`. | - -### World renames - -```diff -- world headless-module { -+ world event-module { - import chain; - import identity; // NOTE: was missing from 0.1 WIT; now present - import local-store; - import remote-store; - import messaging; - import logging; - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; - } -``` - -```diff -- world shepherd-module { -- include headless-module; -- import cow; -- import order; -+ world shepherd { -+ include event-module; -+ import cow-api; - } -``` - -### Function renames (verb-first, fully spelled) - -```diff - interface remote-store { -- feed-get: func(owner: list, topic: list) -> result>, store-error>; -- feed-set: func(topic: list, data: list) -> result, store-error>; -+ read-feed: func(owner: list, topic: list) -> result>, fault>; -+ write-feed: func(topic: list, data: list) -> result, fault>; - } -``` - -### Type and field renames - -```diff - interface types { -- record block-data { ... } -- record log-entry { ..., tx-hash: list, ... } -- record message-data { ... } -- variant event { -- block(block-data), -- logs(list), -- timer(u64), -- message(message-data), -- } -+ record block { ... } -+ record log { ..., transaction-hash: list, ... } -+ record message { ... } -+ record tick { fired-at: u64 } // milliseconds since Unix epoch, UTC -+ variant event { -+ block(block), -+ logs(list), -+ tick(tick), -+ message(message), -+ } - } -``` - -Two semantic notes: - -- All `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds. Audit any timestamp arithmetic you do. -- `tick` (formerly `timer`) is now a record, not a bare `u64`. In bindings it reads `event.tick.firedAt` instead of `event.timer === 1700000000`. - -> **Breaking: the chain-event log is now `chain-log`.** The `log` record and the `logs(list)` event arm shown above have been renamed to `chain-log` and `chain-logs(chain-logs)` so the on-chain event vocabulary no longer collides with the diagnostics logging pipeline (`nexum:host/logging`, `[limits.logs]`), which is untouched. Three consequences: a manifest with `kind = "log"` fails load with an unknown-kind error naming the valid set (`block`, `chain-log`, `cron`); a component built against the old world's `log` record or `logs` arm no longer links against the current world (rebuild against the renamed WIT); and guest strategy handlers rename `on_logs` to `on_chain_logs`. The `block`, `tick`, and `message` arms are unchanged. - -> **Breaking: the chain-log record now carries the full RPC log shape.** The record was reshaped to mirror `alloy_rpc_types_eth::Log` field for field so a guest reconstructs the native alloy log without loss: `block-hash`, `block-timestamp`, `transaction-index`, and `removed` are added; `log-index` and `transaction-index` widen to `u64`; and the block-scoped fields become `option<>` (absent on a pending log). The chain id moves off the per-log record onto a new `chain-logs { chain-id, logs }` batch that the `chain-logs` event arm now wraps, since a delivery always shares one chain and the alloy log type carries no chain id of its own. Guests receive `nexum_sdk::events::Log` (alloy's RPC log) directly and decode `sol!` events against `log.inner`; the SDK bind macro emits the WIT-record-to-alloy conversion, so module glue maps a batch straight to `Vec`. - ---- - -## 2. Error model unification [both] - -The five 0.1 error shapes (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) plus bare `string` errors give way to the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases (see [ADR-0011](../adr/0011-per-interface-typed-errors.md)). - -```wit -interface types { - // The shared cross-domain vocabulary. Each payload-bearing case - // carries a human-readable detail; rate-limited carries backoff. - variant fault { - unsupported(string), // capability declared but not provisioned - unavailable(string), // capability exists, backend is down/offline - denied(string), // user or policy rejected - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), // host bug - } - - record rate-limit { - retry-after-ms: option, - } -} -``` - -Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports). A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. - -```wit -interface chain { - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } -} -``` - -### Author migration - -The stringly `domain`/`code` cross-check is gone; dispatch on the typed variant instead. - -```diff -- match chain::request(1, "eth_call", params) { -- Ok(s) => parse(s), -- Err(JsonRpcError { code, message, .. }) if code == -32000 => retry(), -- Err(e) => bail!("rpc failed: {}", e.message), -- } -+ use nexum_sdk::host::{ChainError, Fault}; -+ match host.request(1, "eth_call", params) { -+ Ok(s) => parse(s), -+ Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), -+ Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), -+ Err(ChainError::Fault(Fault::Denied(_))) => abort("denied"), -+ Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // node code + revert bytes -+ Err(e) => bail!("{e}"), -+ } -``` - -`local-store` errors are no longer bare `string`s: they are a plain `fault`. The interface is the failure domain, so the fault omits any subsystem tag; the case tells you whether you hit a quota (`invalid-input`), the backend is down (`unavailable`), etc. - -Module export signatures also change: - -```diff -- export init: func(config: config) -> result<_, string>; -- export on-event: func(event: event) -> result<_, string>; -+ export init: func(config: config) -> result<_, fault>; -+ export on-event: func(event: event) -> result<_, fault>; -``` - -Module identity is the supervisor's business, so module errors are plain `fault` cases; you no longer restate your module name or prefix your messages. A strategy that aggregates store and chain calls into one `fault` relies on the SDK's `From for Fault` fold for `?`. - -### Embedder migration - -Hosts implementing capability traits now return the interface's typed error. Chain calls return `chain-error` (use the `rpc` case for a structured JSON-RPC error; otherwise a `fault`); the rest return `fault` directly. Map each backend failure to the right case: - -| Backend signal | `fault` case | -|---|---| -| Connection refused / DNS fail / offline | `unavailable` | -| Provider HTTP 4xx (other than 401/403/429) | `invalid-input` | -| Provider HTTP 401/403 | `denied` | -| Provider HTTP 429 | `rate-limited` | -| Provider HTTP 5xx / timeout | `unavailable` or `timeout` (prefer the more specific) | -| Structured JSON-RPC error (node `code`, revert `data`) | `chain-error.rpc` (not a fault) | -| User rejected signing in wallet UI | `denied` | -| Module asked for a capability the host doesn't provide | `unsupported` | -| Bug / panic / internal invariant violated | `internal` | - ---- - -## 3. Manifest changes [both] - -### File rename - -If any code, docs, or scripts reference `shepherd.toml`, change to `nexum.toml`. This was a doc/code inconsistency in 0.1; canonical is `nexum.toml`. - -### Field and section renames - -```diff - [module] - name = "twap-monitor" - version = "0.3.0" -- wasm = "sha256:9f86d081..." -+ component = "sha256:9f86d081..." - - [module.resources] - max_memory_bytes = 10_485_760 - max_fuel_per_event = 100_000 - max_state_bytes = 52_428_800 - - [chains] - required = [42161] - -- [[subscribe]] -- type = "block" -- chain_id = 42161 -+ [[subscription]] -+ kind = "block" -+ chain_id = 42161 -``` - -`type` → `kind` because `type` is reserved in several binding languages. - -### Capability declaration (new, required) - -In 0.1 the world declared which interfaces a module imported, and instantiation failed if any were unsatisfied. In 0.2, imports declared `optional` in the manifest install a trap stub on the host side - calling them returns `fault.unsupported` rather than failing instantiation. - -```toml -[capabilities] -required = ["chain", "local-store", "logging"] -optional = ["messaging", "remote-store"] # module continues if host doesn't provide -denied = [] # explicit "do not grant even if available" - -[capabilities.http] -allow = ["api.coingecko.com", "discord.com"] - -[capabilities.identity] -methods = ["sign-typed-data"] # subset of identity surface used -``` - -If you omit `[capabilities]` entirely, 0.2 falls back to "all imports required" - same as 0.1 behaviour - and prints a deprecation warning at load. Add the section in your next module update; the implicit-all fallback will be removed in 0.3. - -### Config: unchanged in 0.2 - -`[config]` values continue to flow through to the guest as `list>` - the host flattens TOML scalars (numbers, booleans) to their string form on the way through, same as 0.1. If you currently parse `"50"` into `u64`, that code continues to work unchanged: - -```rust -let bps: u64 = config.iter() - .find(|(k, _)| k == "slippage_bps") - .map(|(_, v)| v.parse()) - .transpose()? - .unwrap_or(50); -``` - -**Deferred to 0.3.** A typed `config-value` variant (string / integer / boolean / list) and a `#[derive(NexumConfig)]` helper are on the 0.3 roadmap, bundled with the manifest-parser work (see §3) so the typing story lands as one coherent feature. - ---- - -## 4. New capabilities (additive) [author] - -These didn't exist in 0.1 and don't break anything. Adopt them to remove workarounds. - -> **Breaking if you tracked the 0.2 drafts.** Earlier 0.2 drafts published `nexum:host/clock` and `nexum:host/http` WIT interfaces. Both are gone from the package: a component importing either no longer links against the 0.2 world, and a manifest declaring `clock` under `[capabilities]` fails load with an unknown-capability error. Time is ambient `wasi:clocks` with no declaration, and outbound HTTP is a `wasi:http` import (the SDK's `http::fetch` wraps it) plus the `http` capability declaration with a `[capabilities.http].allow` list, as described below. Components built against the final 0.1 surface are unaffected beyond the renames in this guide. - -### Time (ambient wasi:clocks) - -Wall-clock and monotonic time are WASI concerns, not `nexum:host` interfaces: the host links `wasi:clocks` into every module store, so a module reads time through any wasi:clocks binding with no capability declaration. - -Replaces the 0.1 workaround of "only know the time inside `on_block` via `block.timestamp`." - -### Randomness (ambient wasi:random) - -Secure randomness is a WASI concern, not a `nexum:host` interface: the host links `wasi:random` into every module store, so a module draws CSPRNG bytes through any wasi:random binding with no capability declaration. - -Replaces the 0.1 workaround of "you can't, period." - -### `http` (allowlisted) - -Outbound HTTP is the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The host links `wasi:http/{outgoing-handler, types}` into every module store; a module imports it with any wasi:http client binding and declares the `http` capability in its manifest. - -Requires a domain allowlist in `nexum.toml`: - -```toml -[capabilities] -optional = ["http"] - -[capabilities.http] -allow = ["api.coingecko.com", "*.discord.com"] -``` - -Hosts MUST enforce the allowlist on every outgoing request (exact host match or `*.domain` suffix, case-insensitive, ports ignored); off-list hosts fail with the wasi:http `HTTP-request-denied` error code. The host does not follow redirects, so each hop is a fresh request checked against the same list. The operator sees the union of granted domains at module load. This replaces the 0.1 anti-pattern of tunnelling alerts through Waku. - -The host also bounds every request: guest-set request-options timeouts are clamped to the engine's `[limits.http]` maxima (unset ones inherit them), the whole exchange runs under a total deadline, and a response body beyond the configured cap fails with `HTTP-response-body-size`. The knobs and defaults live in `engine.example.toml`. - -### `chain::request-batch` - -```wit -interface chain { - use types.{chain-id, fault}; - - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Hosts that cannot batch natively MUST fall back to sequential - /// `request` calls; the returned list is the same length as `requests` - /// and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} -``` - -Additive. The alloy-backed `HostTransport` now routes `RequestPacket::Batch` through `request-batch` - your existing `provider.multicall(...).await` actually batches on the wire in 0.2 (it didn't in 0.1, despite the docs). - ---- - -## 5. New world: `query-module` (experimental) [author] - -A request/response world for modules that aren't event-driven (wallet rule evaluators, signature validators, pricing oracles). - -```wit -world query-module { - import local-store; - import logging; - // chain, identity, http, etc. are optional via manifest - - export init: func(config: config) -> result<_, fault>; - export evaluate: func(input: list) -> result, fault>; -} -``` - -**Status: WIT is published, no host implementation ships in 0.2.** The 0.2 server runtime only supports `event-module` and `shepherd`. The world is published so module authors can target it experimentally and so embedders building mobile/wallet hosts have a stable contract to implement against. Production support lands in 0.3. - -If you're writing a module that fits this shape, target it now and stub the host with `MockHost` for testing. - ---- - -## 6. Engine crate rename [embedder] - -```diff - [dependencies] -- nxm-engine = "0.1" -+ nexum-engine = "0.2" -``` - -The 0.1 release renamed `nexum-host` → `nxm-engine`. 0.2 reverses that to `nexum-engine` for consistency with `shepherd-sdk` / `shepherd-sdk-test` (and the future `nexum-sdk` / `cargo-nexum` direction described in doc 05). - -```diff -- use nxm_engine::{Engine, Module}; -+ use nexum_engine::{Engine, Module}; -``` - -The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` embedder facade (for non-Rust hosts) are explicitly **deferred to a later release** pending mobile validation; do not assume they exist in 0.2. - ---- - -## 7. SDK changes [author] - -### Rust SDK - -```diff -- use nexum_sdk::{provider, Identity, MsgClient, RemoteStore}; -+ use nexum_sdk::{provider, Signer, Messaging, RemoteStore}; -``` - -| 0.1 type | 0.2 type | Notes | -|---|---|---| -| `IdentityClient` | `Signer` | Trait renamed to reflect what it does | -| `MsgClient` | `Messaging` | Drops the meaningless `Client` suffix | -| `CowClient` | `Cow` | Same | -| `HostTransport` | (internal) | Now `pub(crate)`; you access it through `provider()` | -| `block_on` (re-export) | (removed from public API) | Hidden behind the `#[nexum::module]` macro | -| `Error` (multiple variants per domain) | `Fault` + `HostFault` / `ChainError` | Per-interface typed errors over the shared vocabulary; see §2 | - -### Proc macro - -`#[nexum::module]` and `#[shepherd::module]` are unchanged in shape. They now generate against `event-module` / `shepherd` worlds. If you targeted `headless-module` explicitly anywhere, rename to `event-module`. - -### Non-Rust SDKs - -The WIT renames propagate mechanically through `wit-bindgen`. Regenerate your bindings against the 0.2 WIT and your existing call sites - adjusted for the renames in §1 - will type-check. - ---- - -## 8. Mechanical rename cheat sheet [both] - -For mechanical search/replace in your codebase. Apply in order; some replacements depend on earlier ones. - -```bash -# WIT package -rg -l 'web3:runtime' | xargs sed -i 's/web3:runtime/nexum:host/g' - -# Interface names (do these before function names - some functions reference the old interface in paths) -rg -l '\bcsn\b' | xargs sed -i 's/\bcsn\b/chain/g' -rg -l '\bmsg\b' | xargs sed -i 's/\bmsg\b/messaging/g' - -# Worlds -rg -l 'headless-module' | xargs sed -i 's/headless-module/event-module/g' -rg -l 'headless_module' | xargs sed -i 's/headless_module/event_module/g' - -# CoW interface stutter -rg -l '\bcow::cow::' | xargs sed -i 's/\bcow::cow::/cow_api::/g' -# (manual: merge `order` imports into `cow-api`; rename `order::submit` to `cow-api::submit-order`) - -# Feed methods -rg -l '\bfeed-get\b' | xargs sed -i 's/\bfeed-get\b/read-feed/g' -rg -l '\bfeed-set\b' | xargs sed -i 's/\bfeed-set\b/write-feed/g' -rg -l '\bfeed_get\b' | xargs sed -i 's/\bfeed_get\b/read_feed/g' -rg -l '\bfeed_set\b' | xargs sed -i 's/\bfeed_set\b/write_feed/g' - -# Type renames -rg -l '\bblock-data\b' | xargs sed -i 's/\bblock-data\b/block/g' -rg -l '\blog-entry\b' | xargs sed -i 's/\blog-entry\b/log/g' -rg -l '\bmessage-data\b' | xargs sed -i 's/\bmessage-data\b/message/g' -rg -l '\btx-hash\b' | xargs sed -i 's/\btx-hash\b/transaction-hash/g' -rg -l '\btx_hash\b' | xargs sed -i 's/\btx_hash\b/transaction_hash/g' - -# Crate rename (Cargo.toml + use statements) -rg -l '\bnxm-engine\b' | xargs sed -i 's/\bnxm-engine\b/nexum-engine/g' -rg -l '\bnxm_engine\b' | xargs sed -i 's/\bnxm_engine\b/nexum_engine/g' - -# Manifest section -rg -l '\[\[subscribe\]\]' | xargs sed -i 's/\[\[subscribe\]\]/[[subscription]]/g' - -# Manifest field -rg -l '^wasm = ' | xargs sed -i 's/^wasm = /component = /' -``` - -Things that **cannot** be sedded - do these by hand: - -- `timer(u64)` → `tick(tick)` with the new `tick { fired-at: u64 }` record. Call sites that pattern-match `Event::Timer(ts)` become `Event::Tick(tick) => tick.fired_at`. -- Error handling. The five old error types are gone; you can't mechanically rewrite a `match` against `JsonRpcError { code, .. }` into the new typed variants (`ChainError::Rpc`, the `fault` cases). Do these per-call-site. -- Splitting `cow` + `order` into a single `cow-api`. Rewrite the imports and adjust function paths. -- Adding `[capabilities]` to `nexum.toml`. Declare what your module actually uses; this is a meaningful audit. - ---- - -## 9. Verification checklist [both] - -After running the renames: - -- [ ] `cargo check --workspace --all-targets` is clean (Rust + bindings). -- [ ] `cargo check --target wasm32-wasip2 -p ` is clean. -- [ ] `cargo test --workspace --no-fail-fast` passes. -- [ ] Your bindgen invocations point at the package's own WIT dir (`wit/nexum-host/`) - or, when consuming both `nexum:host` and a domain-extension package, list both paths explicitly. The 0.1 vendored `deps/` pattern is no longer used in the reference repo. -- [ ] `nexum.toml` has a `[capabilities]` section listing what the module uses. -- [ ] `nexum.toml` references `component = "sha256:..."` not `wasm = ...`. -- [ ] All `[[subscribe]]` sections renamed to `[[subscription]]` with `kind` (not `type`). -- [ ] No remaining references to `web3:runtime`, `csn`, `msg`, `headless-module`, `nxm-engine`, `shepherd.toml`, `feed-get`/`feed-set`, `block-data`/`log-entry`/`message-data`, `tx-hash`. -- [ ] All `Result<_, String>` from module exports replaced with `Result<_, fault>`. -- [ ] Error matching code dispatches on the typed variant (`fault` cases, `ChainError::Rpc`), not protocol-specific error codes. -- [ ] If you used `chrono`/timestamp arithmetic, audited for the seconds-vs-ms change (0.2 is always ms UTC). -- [ ] If you used `provider.multicall(...).await`, confirmed it now actually batches on the wire (`chain::request-batch` shows in tracing). - -> **No `cargo nexum` toolchain in 0.2.** A `cargo-nexum` cargo subcommand (with `new`, `check`, `package`, `run --mock`, `migrate`) is on the 0.3 roadmap. Until then, use `cargo` directly and the `just` recipes in the reference repo. - ---- - -## 10. Deprecation policy going forward [both] - -0.2 is the breaking-change window. The contracts below are stable starting at 0.2.0: - -- WIT package name `nexum:host` and interface names within it. -- The per-interface typed errors over the shared `fault` vocabulary. -- The `nexum.toml` manifest schema. -- The `#[nexum::module]` macro surface. - -Additive changes (new interfaces, new manifest fields, new SDK helpers) may land in any 0.2.x release. Existing identifiers will not be removed or repurposed before 1.0 without a deprecation cycle of at least one minor release. - -The mobile/wallet host story (`query-module` production support, C ABI, `nexum-host` embedder crate) is on the 0.3 roadmap, conditional on a named design partner. The 0.2 `query-module` WIT is an experimental option, not a stable contract; expect changes to its error variants and request/response payload conventions before the 0.3 host ships. - ---- - -## 11. Getting help - -- Open an issue at the repo with the `migration-0.2` label. -- The full 0.2 WIT lives in `wit/nexum-host/` (formerly `wit/web3-runtime/`). -- The §8 cheat sheet has the mechanical sed commands; a `cargo nexum migrate --from 0.1` codemod that wraps them safely is planned for 0.3 alongside the rest of the `cargo-nexum` toolchain. diff --git a/docs/sdk.md b/docs/sdk.md index e752c5fa..c8ee0f65 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -2,19 +2,34 @@ `nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the -per-module adapter macro, and a `prelude` that keeps boilerplate out -of module crates. `shepherd-sdk` layers the CoW Protocol surface on -top; modules that touch the orderbook depend on both crates and -import each directly (nothing is re-exported between them). +`#[nexum_sdk::module]` attribute macro and per-module adapter macro, +and a `prelude` that keeps boilerplate out of module crates. +`shepherd-sdk` layers the CoW Protocol surface on top; modules that +touch the orderbook depend on both crates and import each directly +(nothing is re-exported between them). This page is the entry point. The full API reference is the rustdoc site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-macros --no-deps --open ``` +## Authoring a module + +Modules are authored with the `#[nexum_sdk::module]` attribute +(re-exported from `nexum-macros`). Apply it to an inherent `impl` +block whose associated functions are the named event handlers - +`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each +taking its wit-bindgen payload and returning `Result<(), Fault>`. +The macro generates the rest of the per-cdylib glue: the +`wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` +adapter, a `Guest` impl whose `on_event` dispatches to whichever +handlers are present (absent handlers no-op), and `export!`. See +[doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked +example and the `nexum-macros` rustdoc for the fine print. + ## Supported host capabilities The SDK is host-neutral - it does not call wit-bindgen-generated @@ -54,7 +69,7 @@ seam one-for-one. - `cow::order::gpv2_to_order_data` - convert the on-chain `GPv2OrderData` (12-field Solidity tuple with bytes32 markers) into the typed `OrderData` shape the orderbook signs against. - - `cow::composable::PollOutcome` + `cow::composable::decode_revert` + - `cow::composable::Verdict` + `cow::composable::LegacyRevertAdapter` - typed dispatch over the five `IConditionalOrder` custom errors (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, `PollTryAtEpoch`, `PollNever`). @@ -70,8 +85,8 @@ seam one-for-one. response into bytes. - `chain::chainlink::read_latest_answer` - Chainlink AggregatorV3 reader over the two helpers above. - (The CoW-specific `cow::decode_revert_hex(s)` - the `chain-error` - rpc revert bytes -> typed `PollOutcome` - lives in `shepherd-sdk`.) + (The CoW-specific `cow::LegacyRevertAdapter(s)` - the `chain-error` + rpc revert bytes -> typed `Verdict` - lives in `shepherd-sdk`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait seam plus the SDK's host-neutral `Fault` vocabulary (same cases diff --git a/justfile b/justfile index 6e73b344..1a7bd4ff 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,11 @@ build-engine: build-module: cargo build --target wasm32-wasip2 --release -p example +# Build the reference venue adapter (echo-venue) for wasm32-wasip2. Its +# per-component world pins the #[nexum_venue_sdk::venue] acceptance test. +build-venue: + cargo build --target wasm32-wasip2 --release -p echo-venue + # Build everything build: build-engine build-module @@ -86,6 +91,6 @@ ci: cargo doc --workspace --no-deps cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe \ - -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb + -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ + -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb cargo test --workspace --all-features --no-fail-fast diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 3286a566..0c04791b 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -37,7 +37,12 @@ use wit_bindgen as _; #[cfg(target_arch = "wasm32")] wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index c9a0ff70..19311814 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -12,4 +12,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] +nexum-sdk = { path = "../../crates/nexum-sdk" } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 6ed4deb6..5c7445b0 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,24 +1,24 @@ +//! # example (reference Shepherd module) +//! +//! The minimal reference module: one handler per event, each logging a +//! one-line summary through the raw host `logging` binding. It carries +//! no strategy layer and no `[config]` behaviour, so it doubles as the +//! smallest end-to-end demonstration of `#[nexum_sdk::module]` - the +//! attribute supplies the wit-bindgen call, the host adapter, the +//! `Guest`/`on-event` dispatch, and `export!`, leaving only the +//! handlers. + // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: "../../wit/nexum-host", - world: "nexum:host/event-module", -}); - -use nexum::host::logging; -use nexum::host::types; +use nexum::host::{logging, types}; -// This is the SDK-free reference module: it depends only on -// `wit-bindgen` and installs no tracing subscriber, so it logs through -// the raw host `logging` binding directly. That binding is the same -// sink the `tracing` facade forwards to in the SDK-based modules, so -// the records are indistinguishable to the host. struct ExampleModule; -impl Guest for ExampleModule { +#[nexum_sdk::module] +impl ExampleModule { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { let name = config .iter() @@ -32,38 +32,50 @@ impl Guest for ExampleModule { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match &event { - types::Event::Block(block) => { - logging::log( - logging::Level::Info, - &format!( - "block {} on chain {} (ts={}ms)", - block.number, block.chain_id, block.timestamp - ), - ); - } - types::Event::ChainLogs(batch) => { - logging::log( - logging::Level::Info, - &format!("received {} chain-log entries", batch.logs.len()), - ); - } - types::Event::Tick(tick) => { - logging::log( - logging::Level::Info, - &format!("tick fired at {}ms", tick.fired_at), - ); - } - types::Event::Message(msg) => { - logging::log( - logging::Level::Info, - &format!("message on topic {}", msg.content_topic), - ); - } - } + fn on_block(block: types::Block) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "block {} on chain {} (ts={}ms)", + block.number, block.chain_id, block.timestamp + ), + ); + Ok(()) + } + + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("received {} chain-log entries", batch.logs.len()), + ); + Ok(()) + } + + fn on_tick(tick: types::Tick) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("tick fired at {}ms", tick.fired_at), + ); Ok(()) } -} -export!(ExampleModule); + fn on_message(msg: types::Message) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("message on topic {}", msg.content_topic), + ); + Ok(()) + } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "intent status update from venue {} ({} receipt bytes)", + update.venue, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 55aec121..263a7bcd 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -11,8 +11,8 @@ //! - `strategy.rs` holds the pure logic and tests against //! `nexum_sdk::host::Host`. It does not know `wit-bindgen` //! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Config //! @@ -27,28 +27,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; -impl Guest for BalanceTracker { +#[nexum_sdk::module] +impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -61,15 +54,10 @@ impl Guest for BalanceTracker { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) } } - -export!(BalanceTracker); diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml new file mode 100644 index 00000000..95e02533 --- /dev/null +++ b/modules/examples/echo-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "echo-client" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through nexum:intent/pool on every block and logs the intent-status transitions the router fans back." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../crates/nexum-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml new file mode 100644 index 00000000..f10708d1 --- /dev/null +++ b/modules/examples/echo-client/module.toml @@ -0,0 +1,31 @@ +# echo-client module manifest - the strategy half of the echo pair. It +# submits through nexum:intent/pool and observes intent-status, so it +# declares the `pool` capability alongside `logging`; the per-module world +# the macro derives imports exactly nexum:intent/pool and nexum:host/logging. + +[module] +name = "echo-client" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `pool` grants the nexum:intent/pool import; `logging` the log sink. +required = ["pool", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Submit on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the router polls from the echo-venue adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-client" diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs new file mode 100644 index 00000000..08eb05ee --- /dev/null +++ b/modules/examples/echo-client/src/lib.rs @@ -0,0 +1,68 @@ +//! # echo-client (reference Shepherd intent module) +//! +//! The strategy half of the echo pair. On every chain-1 block it submits an +//! opaque body through `nexum:intent/pool` to the `echo-venue` adapter and +//! logs the receipt, and it logs each `intent-status` transition the router +//! fans back from that venue. Paired with the echo-venue adapter it is the +//! smallest end-to-end demonstration of the intent core: module -> host +//! router -> venue adapter, and the status event back. +//! +//! It declares two capabilities (`pool`, `logging`), so the built component +//! imports `nexum:intent/pool` and `nexum:host/logging` and nothing else: +//! the per-module world matches the manifest by construction. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; +use nexum::intent::pool; +use nexum::intent::types::SubmitOutcome; + +/// Venue id the paired echo-venue adapter answers for; the module submits +/// to and observes exactly this venue. +const ECHO_VENUE: &str = "echo-venue"; + +struct EchoClient; + +#[nexum_sdk::module] +impl EchoClient { + fn on_block(block: types::Block) -> Result<(), Fault> { + // The echo venue accepts any bytes and hands them back as the + // receipt, so the body content is immaterial; the block number keeps + // it non-empty and legible in the logs. + let body = block.number.to_be_bytes().to_vec(); + match pool::submit(ECHO_VENUE, &body) { + Ok(SubmitOutcome::Accepted(receipt)) => logging::log( + logging::Level::Info, + &format!( + "submitted {} bytes to {ECHO_VENUE}, receipt {} bytes", + body.len(), + receipt.len(), + ), + ), + Ok(SubmitOutcome::RequiresSigning(_)) => logging::log( + logging::Level::Warn, + &format!("{ECHO_VENUE} unexpectedly asked for a signature"), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("submit to {ECHO_VENUE} was refused"), + ), + } + Ok(()) + } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {} ({} receipt bytes)", + update.venue, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml new file mode 100644 index 00000000..f900f97b --- /dev/null +++ b/modules/examples/echo-venue/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "echo-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } + +[dev-dependencies] +# The conformance kit: holds this adapter's header derivation to the kit's +# golden mirror types, so echo-venue is both the tutorial artefact and the +# kit's worked test target. +nexum-venue-test = { path = "../../../crates/nexum-venue-test" } diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml new file mode 100644 index 00000000..f57607ca --- /dev/null +++ b/modules/examples/echo-venue/module.toml @@ -0,0 +1,24 @@ +# echo-venue adapter manifest - the reference #[nexum_venue_sdk::venue] +# component. Declares a single scoped-transport capability (chain), so the +# per-component world the macro derives imports nexum:host/chain and +# nothing else. + +[module] +name = "echo-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# A venue adapter may declare only scoped transport (chain, messaging) +# plus the HTTP allowlist. echo-venue reads chain state to derive its +# header and needs nothing else. +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "echo-venue" diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs new file mode 100644 index 00000000..ef80f731 --- /dev/null +++ b/modules/examples/echo-venue/src/lib.rs @@ -0,0 +1,209 @@ +//! # echo-venue (reference Shepherd venue adapter) +//! +//! The minimal reference venue adapter: it accepts any body, echoes it back +//! as the receipt, and settles instantly (every receipt it issued reports +//! `settled`). It carries no real venue protocol, so it doubles as the +//! smallest end-to-end demonstration of `#[nexum_venue_sdk::venue]` - the +//! attribute supplies the per-cdylib wit-bindgen call for a world derived +//! from `module.toml`, the `Guest` export glue, and `export!`, leaving only +//! the adapter face - and as the `nexum-venue-test` conformance target (see +//! the tests below). +//! +//! It declares one capability (`chain`), so the built component imports +//! `nexum:host/chain` and nothing else: the per-component world matches +//! the manifest by construction. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::chain; +use nexum::intent::types::{IntentHeader, IntentStatus, SubmitOutcome, VenueError}; +use nexum::value_flow::types::{Asset, AssetAmount, Settlement}; + +struct EchoVenue; + +#[nexum_venue_sdk::venue] +impl EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + 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 + // the value-flow vocabulary without a real schema. + Ok(IntentHeader { + gives: vec![AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(1)), + amount: (body.len() as u64).to_be_bytes().to_vec(), + }], + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: nexum::intent::types::AuthScheme::Unsigned, + }) + } + + fn submit(body: Vec) -> Result { + // Reading chain state on submit is what justifies the declared + // `chain` capability; the block height is discarded, the point is + // the scoped transport import the manifest declares. + let _ = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(receipt: Vec) -> Result { + if receipt.is_empty() { + Err(VenueError::InvalidReceipt) + } else { + // Settles instantly: the intent reaches a terminal state on the + // first status poll, with no venue-side settlement proof. + Ok(IntentStatus::Settled(None)) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + if receipt.is_empty() { + Err(VenueError::InvalidReceipt) + } else { + Ok(()) + } + } +} + +/// echo-venue as the `nexum-venue-test` conformance target: the adapter's +/// pure header derivation is held to a hand-written golden through the kit's +/// serde mirror types. The macro mints echo-venue's own bindgen +/// `IntentHeader`, so the check bridges it to [`GoldenHeader`] field for +/// field - the pattern the kit documents for macro-built adapters - rather +/// than reusing the SDK's `From`. +#[cfg(test)] +mod conformance { + use super::*; + use nexum::intent::types::AuthScheme; + use nexum_venue_test::{ + GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, + HeaderGolden, HeaderGoldens, + }; + + fn settlement_to_golden(settlement: Settlement) -> GoldenSettlement { + match settlement { + Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), + Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), + } + } + + fn asset_to_golden(asset: Asset) -> GoldenAsset { + match asset { + Asset::NativeToken(settlement) => { + GoldenAsset::NativeToken(settlement_to_golden(settlement)) + } + Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, + Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { + chain_id, + address, + token_id, + }, + Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { + chain_id, + address, + token_id, + }, + Asset::Service(desc) => GoldenAsset::Service { + kind: desc.kind, + summary: desc.summary, + }, + Asset::Offchain(desc) => GoldenAsset::Offchain { + domain: desc.domain, + summary: desc.summary, + }, + } + } + + fn amount_to_golden(amount: AssetAmount) -> GoldenAssetAmount { + GoldenAssetAmount { + asset: asset_to_golden(amount.asset), + amount: amount.amount, + } + } + + fn auth_to_golden(scheme: AuthScheme) -> GoldenAuthScheme { + match scheme { + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, + AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, + AuthScheme::Presign => GoldenAuthScheme::Presign, + AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, + AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + } + } + + fn header_to_golden(header: IntentHeader) -> GoldenHeader { + GoldenHeader { + gives: header.gives.into_iter().map(amount_to_golden).collect(), + wants: header.wants.into_iter().map(amount_to_golden).collect(), + valid_until: header.valid_until, + settlement: settlement_to_golden(header.settlement), + authorisation: auth_to_golden(header.authorisation), + } + } + + /// The adapter derivation the kit checks, bridged to the golden mirror. + fn derive_golden(body: Vec) -> Result { + EchoVenue::derive_header(body).map(header_to_golden) + } + + #[test] + fn derive_header_conforms_to_the_published_golden() { + // The echo contract: gives chain-1 native token whose amount is the + // body length as eight big-endian bytes, wants nothing, and carries + // no authorisation. A conforming adapter reproduces this exactly. + let golden = HeaderGolden { + name: "four-byte-body".to_owned(), + body: vec![1, 2, 3, 4], + header: GoldenHeader { + gives: vec![GoldenAssetAmount { + asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), + amount: 4u64.to_be_bytes().to_vec(), + }], + wants: Vec::new(), + valid_until: None, + settlement: GoldenSettlement::EvmChain(1), + authorisation: GoldenAuthScheme::Unsigned, + }, + notes: Some("amount is the 8-byte big-endian body length".to_owned()), + }; + let goldens = HeaderGoldens { + venue: "echo-venue".to_owned(), + goldens: vec![golden], + }; + goldens.assert_conforms(derive_golden); + } + + #[test] + fn divergent_derivation_is_caught_by_the_golden() { + // A little-endian amount is the classic byte-order bug; the golden + // must reject it, proving the check has teeth on echo-venue. + let goldens = HeaderGoldens { + venue: "echo-venue".to_owned(), + goldens: vec![HeaderGolden { + name: "four-byte-body".to_owned(), + body: vec![1, 2, 3, 4], + header: GoldenHeader { + gives: vec![GoldenAssetAmount { + asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), + amount: 4u64.to_le_bytes().to_vec(), + }], + wants: Vec::new(), + valid_until: None, + settlement: GoldenSettlement::EvmChain(1), + authorisation: GoldenAuthScheme::Unsigned, + }, + notes: None, + }], + }; + assert!(goldens.check(derive_golden).is_err()); + } +} diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index bb9ab42e..c48377b1 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -14,9 +14,8 @@ //! - `strategy.rs` holds the pure logic and tests against the SDK's //! `http::Fetch` seam, logging through the `tracing` facade. It does //! not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, `install_tracing`, the -//! `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -36,28 +35,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// generated by the attribute alongside the wit-bindgen call and the +// `Guest`/`export!` glue. struct HttpProbe; -impl Guest for HttpProbe { +#[nexum_sdk::module] +impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -71,16 +63,11 @@ impl Guest for HttpProbe { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(HttpProbe); diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index e9d922d0..94285268 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -16,8 +16,8 @@ //! - `strategy.rs` holds the pure logic and tests against //! `nexum_sdk::host::Host`. It does not know `wit-bindgen` //! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -42,27 +42,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated -// below. Single source of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; -impl Guest for PriceAlert { +#[nexum_sdk::module] +impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -77,16 +71,11 @@ impl Guest for PriceAlert { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(PriceAlert); diff --git a/modules/examples/price-alert/src/strategy.rs b/modules/examples/price-alert/src/strategy.rs index 0dcccd95..a6c773e8 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/modules/examples/price-alert/src/strategy.rs @@ -1,16 +1,19 @@ //! Pure strategy logic for the price-alert module. //! -//! Every interaction with the world flows through the [`Host`] trait +//! Every interaction with the world flows through the host trait //! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- //! generated free functions live here. The `lib.rs` glue wraps a //! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen //! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. +//! hand the same function a `nexum_sdk_test::MockHost`. The bound is +//! `ChainHost + LoggingHost`, the module's two declared capabilities: +//! its world imports nothing else, so the full `Host` supertrait (which +//! adds local-store) is unimplementable here by design. use alloy_primitives::I256; use nexum_sdk::chain::chainlink::read_latest_answer; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; /// Resolved configuration, parsed from `module.toml::[config]` at @@ -44,7 +47,7 @@ pub enum Direction { /// lets the next block re-poll rather than propagating into the /// supervisor. Only host-level I/O on the persistence side would /// bubble up via `?`, and this module does not touch the store. -pub fn on_block( +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index b5df56f9..be6ef1b7 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -23,7 +23,12 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + "../../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index bcec815b..8c97f74f 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -9,7 +9,7 @@ use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::Fault; use nexum_sdk::prelude::{Address, Bytes, U256}; use shepherd_sdk::cow::{ - CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, + CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, is_already_submitted, }; use shepherd_sdk::prelude::{ BuyTokenDestination, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderKind, @@ -104,6 +104,20 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res ); } Err(err) => { + // Success wearing an error status: the orderbook already + // holds this exact order. Record the receipt under the + // key the dedup guard reads, so the next block idles + // instead of re-posting until `validTo`. + if let CowApiError::Rejected(rejection) = &err + && is_already_submitted(rejection) + { + host.set(&dedup_key, b"")?; + tracing::info!( + uid = %uid_hex, + "stop-loss already on the orderbook; receipt recorded", + ); + return Ok(()); + } // Only a typed orderbook rejection classifies; transport // faults and raw HTTP errors are transient (retry next // block) rather than a terminal drop. @@ -135,7 +149,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res } // `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (PR #55 review): the same flow + `Option` return shape now serves +// (review consolidation): the same flow + `Option` return shape now serves // price-alert + stop-loss from the SDK, with `domain: &str` carrying the // module label into the Warn log. @@ -436,6 +450,50 @@ mod tests { assert_eq!(host.cow_api.call_count(), 1); // no resubmit } + /// A duplicate rejection is success wearing an error status: the + /// orderbook already holds the order (e.g. the local marker was + /// lost), so the receipt must be recorded and the next block must + /// idle instead of re-posting until `validTo`. + #[test] + fn duplicate_rejection_records_receipt_and_idles() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle( + &host, + s.oracle_address, + Ok(oracle_response_json(200_000_000_000)), + ); + + host.cow_api + .respond(Err(CowApiError::Rejected(OrderRejection { + status: 400, + error_type: "DuplicatedOrder".into(), + description: "order already exists".into(), + data: None, + }))); + + on_block(&host, SEPOLIA, &s).unwrap(); + + let uid = programmed_uid(&s); + assert!( + host.store + .snapshot() + .contains_key(&format!("submitted:{uid}")), + "the receipt must land under the key the dedup guard reads", + ); + assert!( + !host + .store + .snapshot() + .contains_key(&format!("dropped:{uid}")), + "already-submitted must never mark the order dropped", + ); + + // Second block: the receipt idles the loop, no re-POST. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index d2d7a54b..b072abd0 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -16,8 +16,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index 63157a8b..cd5c7d41 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -21,8 +21,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use std::sync::OnceLock; diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index eb158a26..14b55f13 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -13,8 +13,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index 5feeadc3..cf1dd031 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -12,8 +12,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 086738f3..09fcb89c 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -13,8 +13,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 1910ae93..91a37a90 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -14,12 +14,10 @@ shepherd-sdk = { path = "../../crates/shepherd-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -thiserror = "2" tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +serde_json = { version = "1", default-features = false, features = ["alloc"] } shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index e6f4fa62..c23eb237 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -24,7 +24,12 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a58a58bf..3ca2de4a 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -7,20 +7,23 @@ //! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under //! `#[cfg(test)]` hand the same functions a //! `shepherd_sdk_test::MockHost`. +//! +//! The module owns decode and evaluate only: log decoding into the +//! keeper watch set, and the `getTradeableOrderWithSignature` poll +//! behind [`ConditionalSource`]. Gate discipline, the `submitted:` +//! journal, submission, and retry dispatch live in the shared +//! composition (`shepherd_sdk::cow::run`). -use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use cowprotocol::{ - COMPOSABLE_COW, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, + COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -66,9 +69,16 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: scan every persisted watch and dispatch ready tranches. +/// Poll entry: run the keeper over every gate-ready watch through the +/// shared composition. The block timestamp arrives in milliseconds; the +/// tick carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { - poll_all_watches(host, &block) + let tick = Tick { + chain_id: block.chain_id, + block: block.number, + epoch_s: block.timestamp / 1000, + }; + run(host, &TwapSource, &tick) } // ---- indexing path ---- @@ -78,64 +88,51 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// `set` overwrites in place, so re-indexing the same log (re-org -/// replay, overlapping subscription windows) produces no observable -/// side effect. +/// The watch set overwrites in place, so re-indexing the same log +/// (re-org replay, overlapping subscription windows) produces no +/// observable side effect. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; + let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; tracing::info!("indexed {key}"); Ok(()) } // ---- poll path ---- -fn poll_all_watches(host: &H, block: &BlockInfo) -> Result<(), Fault> { - let now_epoch_s = block.timestamp / 1000; - let keys = host.list_keys("watch:")?; - for key in keys { - let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { - continue; - }; - if !is_ready(host, owner_hex, hash_hex, block.number, now_epoch_s)? { - continue; - } - let Some(value) = host.get(&key)? else { - continue; - }; - let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; +/// TWAP conditional source: decode the stored `ConditionalOrderParams` +/// and evaluate `getTradeableOrderWithSignature` on chain. A row this +/// source cannot decode polls again next block rather than tearing +/// down the sweep. +struct TwapSource; + +impl ConditionalSource for TwapSource { + type Outcome = Verdict; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + let Ok(params) = ConditionalOrderParams::abi_decode(params) else { + tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); + return Verdict::TryNextBlock { reason: [0; 4] }; }; - let Ok(owner) = owner_hex.parse::

() else { - continue; + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carried an unparseable owner; skipping", + watch.key() + ); + return Verdict::TryNextBlock { reason: [0; 4] }; }; - let outcome = poll_one(host, block.chain_id, &owner, ¶ms); - tracing::info!("poll {key} -> {}", outcome_label(&outcome)); - match outcome { - PollOutcome::Ready { order, signature } => { - submit_ready( - host, - block.chain_id, - owner, - &order, - signature, - &key, - now_epoch_s, - )?; - } - non_ready => { - apply_watch_update(host, outcome_to_update(&non_ready), &key)?; - } - } + let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); + tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); + outcome + } + + fn label(&self) -> &'static str { + "twap" } - Ok(()) } fn poll_one( @@ -143,7 +140,7 @@ fn poll_one( chain_id: u64, owner: &Address, params: &ConditionalOrderParams, -) -> PollOutcome { +) -> Verdict { let call = abi::getTradeableOrderWithSignatureCall { owner: *owner, params: abi::Params { @@ -158,361 +155,94 @@ fn poll_one( match host.request(chain_id, "eth_call", ¶ms_json) { Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) - .unwrap_or(PollOutcome::TryNextBlock), - // A structured JSON-RPC error (the normal shape for an - // `eth_call` revert): the chain backend has already hex-decoded - // the `error.data` payload, so `decode_revert` dispatches - // `PollTryAtBlock` / `PollTryAtEpoch` / `OrderNotValid` / - // `PollNever` straight off the bytes. A revert the decoder does - // not recognise falls through to the safe `TryNextBlock`. - Err(ChainError::Rpc(rpc)) => rpc - .data - .as_deref() - .and_then(|bytes| decode_revert(bytes)) - .unwrap_or_else(|| { - tracing::warn!( - "eth_call reverted ({}); defaulting to TryNextBlock", - rpc.message - ); - PollOutcome::TryNextBlock - }), - // A transport-level fault (timeout, RPC down, ...): retry on the - // next block. - Err(ChainError::Fault(fault)) => { - tracing::warn!("eth_call failed ({fault}); defaulting to TryNextBlock"); - PollOutcome::TryNextBlock + .unwrap_or(Verdict::TryNextBlock { reason: [0; 4] }), + // `LegacyRevertAdapter::classify` is the one policy for what a failed + // poll call means to the watch lifecycle; the diagnostics here + // cover the cases where the raw error carries information the + // outcome alone does not. + Err(err) => { + let outcome = LegacyRevertAdapter::classify(&err); + match &err { + ChainError::Fault(fault) => { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + // A permanent drop deserves its cause on the record: + // the revert selector and the node's message are + // unrecoverable once the watch is gone. + ChainError::Rpc(rpc) if matches!(outcome, Verdict::Invalid { .. }) => { + let selector = rpc + .data + .as_deref() + .and_then(|data| data.get(..4)) + .map(alloy_primitives::hex::encode_prefixed) + .unwrap_or_else(|| "none".to_string()); + tracing::warn!( + "eth_call reverted permanently (selector {selector}, {}); \ + dropping watch", + rpc.message, + ); + } + _ => {} + } + outcome } } } /// Decode a successful `getTradeableOrderWithSignature` return into -/// `Ready { order, signature }`. The wire format is `abi.encode(order, -/// signature)` - the canonical Solidity return tuple - so the two-tuple -/// parameter decode lines up. -fn decode_return(data: &[u8]) -> Option { +/// `Post { order, signature, .. }`. The wire format is the canonical +/// Solidity return tuple `abi.encode(order, signature)`, so the +/// two-tuple parameter decode lines up. The deployed 1.x contract +/// carries no next-poll hint, so `next_poll_timestamp` is synthetic +/// (`0`). +fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { + Some(Verdict::Post { order: Box::new(order), signature, + next_poll_timestamp: 0, }) } -fn outcome_label(o: &PollOutcome) -> &'static str { +fn outcome_label(o: &Verdict) -> &'static str { match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", } } -// ---- key conventions ---- +// ---- test-only seam mirrors ---- +// +// Thin views over the keeper / SDK canon so the dispatch tests can +// seed and inspect the store in the exact shapes production writes. -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") -} +#[cfg(test)] +use nexum_sdk::keeper::watch_key; +#[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) -} - -fn is_ready( - host: &H, - owner_hex: &str, - hash_hex: &str, - block_number: u64, - epoch_s: u64, -) -> Result { - if let Some(next) = read_u64(host, &format!("next_block:{owner_hex}:{hash_hex}"))? - && block_number < next - { - return Ok(false); - } - if let Some(next) = read_u64(host, &format!("next_epoch:{owner_hex}:{hash_hex}"))? - && epoch_s < next - { - return Ok(false); - } - Ok(true) + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) } -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let bytes = host.get(key)?; - Ok(bytes - .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) - .map(u64::from_le_bytes)) -} - -// ---- submission path ---- - -/// `cowprotocol`-side rejection envelope for an `OrderCreation` we -/// failed to assemble. Surfaces in a Warn log; the watch is left in -/// place so the next poll can either re-construct or transition on -/// its own. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the submission warning log can carry `error_kind = -/// unknown_marker` without a match-ladder in the call site. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum BuildError { - /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't - /// know how to map. - #[error("GPv2OrderData carried an unknown enum marker")] - UnknownMarker, - /// `cowprotocol` rejected the body - typically `from == - /// Address::ZERO` or a `validTo` beyond the client-side horizon. - #[error(transparent)] - Cowprotocol(#[from] cowprotocol::Error), -} - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// freshly-polled TWAP tranche. -/// -/// The signed `order.appData` digest is submitted verbatim (the -/// hash-only `OrderCreationAppData::Hash` wire shape) - watch-tower -/// parity. The orderbook joins the document it already has registered -/// for that digest; when it has none, the submit rejects with -/// `INVALID_APP_DATA` and [`classify_api_error`] dispatches the retry. -fn build_order_creation( - order: &GPv2OrderData, - signature: Bytes, - from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; - let signature = Signature::Eip1271(signature.to_vec()); - let creation = OrderCreation::new_app_data_hash_only(&order_data, signature, from, None)?; - Ok(creation) -} - -fn submit_ready( - host: &H, - chain_id: u64, - owner: Address, - order: &GPv2OrderData, - signature: Bytes, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Short-circuit if the orderbook UID for this exact - // (order, owner, chain) tuple is already in our local-store as - // `submitted:`. The poll-tick can re-fire `Ready` for the same - // TWAP child in successive blocks - `getTradeableOrderWithSignature` - // does not know shepherd already POSTed it - and re-submitting - // wastes a submit_order call and emits a misleading - // `DuplicatedOrder` Warn. The UID computation is deterministic - // from on-chain inputs (and matches what the orderbook derives - // server-side from the signed payload), so we can check before - // doing any network work. We also reuse the computed value below - // as the `submitted:{uid}` marker key, so the read and write - // paths agree. - let client_uid_hex = compute_uid_hex(chain_id, order, owner); - if let Some(uid_hex) = client_uid_hex.as_deref() - && host.get(&format!("submitted:{uid_hex}"))?.is_some() - { - tracing::info!("twap {uid_hex} already submitted; skipping poll re-submit"); - return Ok(()); - } - - // CoW Swap UI (and other clients) sign TWAPs with a non-empty - // `appData` hash that points at a JSON document already registered - // with the orderbook. Submit the signed digest verbatim (hash-only - // shape) and let the orderbook join its own registry - watch-tower - // parity. An unregistered digest rejects as `INVALID_APP_DATA` and - // `classify_api_error` dispatches the backoff. - let creation = match build_order_creation(order, signature, owner) { - Ok(c) => c, - Err(e) => { - tracing::warn!("twap submit skipped for {owner:#x}: {e}"); - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID for the marker key so the - // idempotency check at the top of `submit_ready` reads what - // we wrote. In production the server-returned - // UID is the same value (both sides derive it from the - // signed `OrderData` via the canonical - // `digest || owner || valid_to` layout); a divergence - // would be a protocol-level bug worth surfacing rather - // than silently splitting the keyspace. - let marker_uid = client_uid_hex.as_deref().unwrap_or(server_uid.as_str()); - let key = format!("submitted:{marker_uid}"); - // Empty marker - presence of the key is the receipt. - host.set(&key, b"")?; - if let Some(client_uid) = client_uid_hex.as_deref() - && client_uid != server_uid - { - tracing::warn!( - "twap UID divergence: client={client_uid} server={server_uid} \ - (marker stored under client UID for idempotency consistency)" - ); - } - tracing::info!("submitted {key}"); - } - Err(err) => { - apply_submit_retry(host, &err, watch_key, now_epoch_s)?; - } - } - Ok(()) -} - -/// Compute the orderbook UID hex (`0x` + 112 hex chars) for the given -/// on-chain (order, owner, chain) tuple, mirroring what `submit_order` -/// will deduce server-side. Used by [`submit_ready`] to short-circuit -/// poll-tick re-submissions of an already-submitted TWAP child. -/// -/// Returns `None` if the chain id is unsupported by `cowprotocol::Chain` -/// or the order carries an unknown enum marker - both cases also stop -/// the regular submit path downstream, so the caller can fall through -/// to the normal flow and let it surface the appropriate diagnostic. +#[cfg(test)] fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) -} - -// ---- OrderPostError -> retry action ---- - -fn apply_submit_retry( - host: &H, - err: &CowApiError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Only a typed orderbook rejection classifies; transport faults and - // raw HTTP errors are transient, so the watch stays in place. - let action = match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock => { - tracing::warn!("submit retry-next-block: {err}"); - } - RetryAction::Backoff { seconds } => { - let until = now_epoch_s.saturating_add(seconds); - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &until.to_le_bytes(), - )?; - } - tracing::warn!("submit backoff {seconds}s -> next_epoch={until}: {err}"); - } - RetryAction::Drop => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::warn!("submit dropped watch: {err}"); - } - // `RetryAction` is `#[non_exhaustive]`; future variants - // default to "leave the watch in place" (the conservative - // dispatch choice). Once a new variant gets a real meaning - // its arm should be added explicitly. - _ => { - tracing::warn!("submit unknown retry-action: {err} - leaving watch in place"); - } - } - Ok(()) -} - -// ---- PollOutcome lifecycle dispatch ---- - -/// What `apply_watch_update` should do for a given outcome. Kept as a -/// data type (rather than running the effects directly) so the -/// decision is host-free testable. -#[derive(Debug, Eq, PartialEq)] -enum WatchUpdate { - /// Leave the store untouched. Next block re-polls the watch. - NoOp, - /// Write `next_block:` so subsequent polls skip until the given - /// block number is reached. - SetNextBlock(u64), - /// Write `next_epoch:` so subsequent polls skip until the given - /// Unix-seconds timestamp is reached. - SetNextEpoch(u64), - /// Delete the watch and any stale gate keys - TWAP completed, - /// cancelled, or otherwise irrecoverable. - DropWatch, -} - -/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect -/// the contract specifies. `Ready` is handled by the submit -/// path and is rejected here so a caller cannot -/// accidentally erase the watch when an order was actually produced. -fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { - match outcome { - PollOutcome::Ready { .. } => WatchUpdate::NoOp, - PollOutcome::TryNextBlock => WatchUpdate::NoOp, - PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), - PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), - PollOutcome::DontTryAgain => WatchUpdate::DropWatch, - } -} - -fn apply_watch_update( - host: &H, - update: WatchUpdate, - watch_key: &str, -) -> Result<(), Fault> { - match update { - WatchUpdate::NoOp => Ok(()), - WatchUpdate::SetNextBlock(n) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &n.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::SetNextEpoch(t) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &t.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::DropWatch => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::info!("dropped watch {watch_key}"); - Ok(()) - } - } + shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; - use cowprotocol::OrderCreationAppData; + use alloy_primitives::{B256, U256, address, b256, hex}; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; + use shepherd_sdk::cow::{CowApiError, OrderRejection}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -615,45 +345,20 @@ mod tests { let wire = (order.clone(), sig.clone()).abi_encode_params(); match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { + Verdict::Post { order: o, signature: s, + next_poll_timestamp, } => { assert_eq!(o.sellToken, order.sellToken); assert_eq!(o.buyAmount, order.buyAmount); assert_eq!(s, sig); + assert_eq!(next_poll_timestamp, 0, "legacy path carries no hint"); } - other => panic!("expected Ready, got {other:?}"), + other => panic!("expected Post, got {other:?}"), } } - /// The signed `appData` digest goes into the body verbatim as the - /// hash-only shape - no document lookup, no digest re-derivation. - #[test] - fn build_order_creation_submits_app_data_hash_verbatim() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let creation = build_order_creation(&order, sig.clone(), owner).expect("build succeeds"); - assert_eq!(creation.from, owner); - assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); - assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!( - creation.app_data, - OrderCreationAppData::Hash { - hash: order.appData - } - ); - } - - #[test] - fn build_order_creation_rejects_zero_from() { - let err = - build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - #[test] fn watch_key_round_trips_via_parse() { let owner = address!("00112233445566778899aabbccddeeff00112233"); @@ -664,48 +369,6 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - #[test] - fn outcome_try_next_block_is_no_op() { - assert_eq!( - outcome_to_update(&PollOutcome::TryNextBlock), - WatchUpdate::NoOp - ); - } - - #[test] - fn outcome_try_on_block_sets_next_block_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryOnBlock(12_345)), - WatchUpdate::SetNextBlock(12_345), - ); - } - - #[test] - fn outcome_try_at_epoch_sets_next_epoch_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), - WatchUpdate::SetNextEpoch(1_700_000_000), - ); - } - - #[test] - fn outcome_dont_try_again_drops_watch() { - assert_eq!( - outcome_to_update(&PollOutcome::DontTryAgain), - WatchUpdate::DropWatch - ); - } - - #[test] - fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { - let order = Box::new(submittable_order()); - let outcome = PollOutcome::Ready { - order, - signature: Bytes::new(), - }; - assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); - } - // ---- MockHost dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed @@ -1066,8 +729,8 @@ mod tests { } #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle + fn poll_invalid_drops_watch_and_gates() { + // When `LegacyRevertAdapter` produces `Invalid`, the lifecycle // layer must delete the watch and any stale gates. Simulate the // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. @@ -1101,7 +764,8 @@ mod tests { })), ); - on_block(&host, sample_block(1_000)).unwrap(); + let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); assert!( @@ -1115,5 +779,23 @@ mod tests { 0, "revert-to-drop path never submits" ); + // The destructive drop carries its cause: the revert selector + // and the node's message ride the Warn, and the keeper logs + // the removal itself. + let warn = logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("eth_call reverted permanently") + }); + assert!(warn.message.contains("execution reverted")); + let selector_hex = + alloy_primitives::hex::encode_prefixed(&IConditionalOrder::OrderNotValid::SELECTOR[..]); + assert!( + warn.message.contains(&selector_hex), + "the four-byte selector must be greppable: {}", + warn.message, + ); + logs.expect_one(|e| { + e.message + .contains(&format!("dropped watch {watch_key_str}")) + }); } } diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 1546ac42..15a05760 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -399,7 +399,7 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { /// needs no registration step. `validTo` is `u32::MAX` per the /// canonical EthFlow shape (the mock orderbook is /// permissive here, and shepherd's strategy will drop with the -/// expected Info-level log per PR #49). +/// expected Info-level log). fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, diff --git a/wit/nexum-adapter/venue-adapter.wit b/wit/nexum-adapter/venue-adapter.wit new file mode 100644 index 00000000..9656bc47 --- /dev/null +++ b/wit/nexum-adapter/venue-adapter.wit @@ -0,0 +1,30 @@ +package nexum:adapter@0.1.0; + +/// A venue adapter: the second component kind. Where an event-module +/// automates strategy over the six core primitives, a venue adapter +/// speaks one venue's protocol and nothing else. It imports only the +/// scoped transport it needs to reach its venue - chain RPC and +/// messaging - and exports the intent adapter face. It has no +/// local-store, remote-store, identity, or logging import: an adapter +/// structurally cannot touch host key material or persistent state, only +/// move bytes to and from its venue. The host links exactly these +/// imports into an adapter store, so an adapter that reaches for anything +/// else fails to instantiate. +world venue-adapter { + use nexum:host/types@0.2.0.{config, fault}; + + // Scoped transport. Outbound HTTP is wasi:http, linked separately and + // gated per-adapter by the `[[adapters]].http_allow` allowlist in + // engine.toml, the same way event-module treats outbound HTTP; the + // per-adapter `[[adapters]].messaging_topics` scopes the messaging + // content topics an adapter may reach. Time and randomness are + // ambient wasi:clocks / wasi:random. + import nexum:host/chain@0.2.0; + import nexum:host/messaging@0.2.0; + + /// Configure the adapter from its `[config]` before any submission. + /// Mirrors the event-module `init` so the supervisor boots both kinds + /// through the same store, fuel, and restart machinery. + export init: func(config: config) -> result<_, fault>; + export nexum:intent/adapter@0.1.0; +} diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 2cd5c7e2..6c48dc29 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -5,6 +5,8 @@ package nexum:host@0.2.0; /// All `u64` timestamps in this package are milliseconds since the Unix /// epoch, UTC. interface types { + use nexum:intent/types@0.1.0.{receipt, intent-status}; + type chain-id = u64; record block { @@ -58,11 +60,25 @@ interface types { fired-at: u64, } + /// A host-observed status transition for a previously submitted + /// intent, expressed in the venue-neutral `nexum:intent` vocabulary. + /// 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. + receipt: receipt, + /// Where the intent now is in its life at the venue. + status: intent-status, + } + variant event { block(block), chain-logs(chain-logs), tick(tick), message(message), + intent-status(intent-status-update), } /// Opaque config from module.toml [config] section. diff --git a/wit/nexum-intent/adapter.wit b/wit/nexum-intent/adapter.wit new file mode 100644 index 00000000..1c0a28ce --- /dev/null +++ b/wit/nexum-intent/adapter.wit @@ -0,0 +1,31 @@ +package nexum:intent@0.1.0; + +/// The venue face of the intent core, the mirror of the strategy-facing +/// `pool` interface. One installed adapter answers for exactly one venue, +/// so none of these functions carries a `venue` argument: the router +/// resolves a venue id to its adapter and calls the adapter directly. +/// Bodies stay opaque bytes at this boundary; the adapter recovers typing +/// against its own venue schema. The package depends only on its own +/// types, never on `nexum:host`, so the adapter contract's freeze cadence +/// stays independent of host versioning. +interface adapter { + use types.{intent-header, intent-status, receipt, submit-outcome, venue-error}; + + /// Project an opaque intent body onto the stable header guard policy + /// runs on. A pure derivation: no transport, no side effects, so the + /// host can derive and inspect a header before deciding to submit. + derive-header: func(body: list) -> result; + + /// Submit an opaque intent body to this adapter's venue. Success is + /// either the venue's receipt or requires-signing: a transaction the + /// host must sign and send before the intent exists. + submit: func(body: list) -> result; + + /// Report where a previously submitted intent is in its life. + status: func(receipt: receipt) -> result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that settlement can no longer + /// happen: an already in-flight settlement may still win the race. + cancel: func(receipt: receipt) -> result<_, venue-error>; +} diff --git a/wit/nexum-intent/pool.wit b/wit/nexum-intent/pool.wit new file mode 100644 index 00000000..7655292a --- /dev/null +++ b/wit/nexum-intent/pool.wit @@ -0,0 +1,26 @@ +package nexum:intent@0.1.0; + +/// The strategy-module face of the intent core. The host is a router plus +/// a policy checkpoint: it resolves the venue id to the installed adapter, +/// has the adapter derive the header, runs guard policy on it, and only +/// then forwards the call. Bodies are opaque bytes at this boundary; typing +/// is recovered guest-side by venue SDK crates and host-side by the +/// adapter's header derivation. Bodies carry their own routing: there is no +/// chain parameter, a multichain venue's body schema names the chain and +/// the derived header's settlement field exposes the choice to policy. +interface pool { + use types.{intent-status, receipt, submit-outcome, venue-error}; + + /// Submit an opaque intent body to the named venue. Success is either + /// the venue's receipt or requires-signing: a transaction the host + /// must sign and send before the intent exists. + submit: func(venue: string, body: list) -> result; + + /// Report where a previously submitted intent is in its life. + status: func(venue: string, receipt: receipt) -> result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that settlement can no longer + /// happen: an already in-flight settlement may still win the race. + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} diff --git a/wit/nexum-intent/types.wit b/wit/nexum-intent/types.wit new file mode 100644 index 00000000..32c2e16d --- /dev/null +++ b/wit/nexum-intent/types.wit @@ -0,0 +1,132 @@ +package nexum:intent@0.1.0; + +/// The venue-neutral intent ontology: what a deal gives and wants, how the +/// venue authorises it, and how its life at the venue is reported. Built on +/// the nexum:value-flow vocabulary so a submission is described the same +/// way to strategy modules, venue adapters, and guard policy. The package +/// deliberately does not depend on nexum:host: embedding the host fault +/// type in venue-error would pin this contract's freeze cadence to host +/// versioning, so venue-error carries its own transport cases. +/// +/// Identifier hygiene follows the value-flow rule: every id is checked +/// against WIT keywords and the reserved words of the nine binding-target +/// languages, preferring a two-word kebab id wherever a single word is a +/// keyword anywhere (`unsigned` not `none`, a Python keyword; +/// `internal-error` not `internal`, a Swift declaration keyword). +interface types { + use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; + + /// How an intent is authorised at its venue. + variant auth-scheme { + /// An EIP-712 typed-data signature by host-held keys. The only + /// scheme that reaches the identity checkpoint at submit time. + eip712, + /// An EIP-1271 contract signature: consent happened on-chain when + /// the commitment was created, so the host signs nothing here. + eip1271, + /// A pre-signed authorisation recorded at the settlement contract + /// ahead of submission. + presign, + /// A venue-defined off-chain signature scheme. + offchain-sig, + /// No authorisation travels with the body. + unsigned, + } + + /// The adapter-derived description of an intent body: the stable + /// ontology guard policy runs on. Policy has teeth on `gives` (what + /// leaves the user's control); `wants` is display-grade because the + /// host can rarely verify the counterparty's obligation and must not + /// pretend to. + record intent-header { + /// Value leaving the user's control. + gives: list, + /// Value expected in return. Display-grade, not host-verified. + wants: list, + /// Expiry in milliseconds since the Unix epoch, UTC; absent means + /// the venue's own default lifetime applies. + valid-until: option, + /// Where the deal settles. + settlement: settlement, + /// How the venue authorises the intent. + authorisation: auth-scheme, + } + + /// Venue-scoped stable identifier for a submitted intent (for CoW, + /// the 56-byte order UID). Opaque to the host and to policy. + type receipt = list; + + /// Why an intent failed terminally, as reported by the venue. + record fail-reason { + /// Venue-scoped machine-readable code, stable enough for a + /// module to match on. + code: string, + /// Human-readable detail for logs and the consent surface. + detail: string, + } + + /// Where an intent is in its life at the venue. + variant intent-status { + /// Accepted for processing but not yet live at the venue. + pending, + /// Live at the venue and eligible for settlement. + open, + /// Settled. The payload is venue-defined settlement proof (for an + /// EVM venue, typically the settlement transaction hash). + settled(option>), + /// Terminally failed. + failed(fail-reason), + /// Reached its expiry without settling. + expired, + /// Withdrawn before settlement. + cancelled, + } + + /// An EVM call the host must sign and send for the intent to exist + /// on-chain. The adapter only describes the call: the host routes it + /// through the guard's host-signed class, fills the gas and fee + /// fields, and signs, so adapters still structurally cannot move + /// value. Always a call to existing code; adapters cannot deploy. + record unsigned-tx { + /// Chain the transaction must land on. + chain-id: u64, + /// 20-byte address of the contract to call. + to: list, + /// Native value, big-endian unsigned; an empty list is zero. + value: list, + /// ABI-encoded calldata. + input: list, + } + + /// What a successful submit produced. A variant from day one: an + /// on-chain-settlement venue (an ethflow-style order) has no receipt + /// to give until a transaction is signed, and bolting that on later + /// would break every deployed module. + variant submit-outcome { + /// The venue holds the intent; the receipt is its stable id. + accepted(receipt), + /// Settlement requires a host-signed on-chain transaction first. + requires-signing(unsigned-tx), + } + + /// Failure of a pool or adapter call. + variant venue-error { + /// No installed adapter answers to the venue id. + unknown-venue, + /// Body bytes failed to decode against the venue's published + /// schema: malformed bytes or an unknown outer version. + invalid-body(string), + /// The receipt was not issued by this venue or is malformed. + invalid-receipt, + /// The venue refused the intent under its own rules. + rejected(string), + /// Guard policy refused the egress before it reached the venue. + denied(string), + /// The venue or adapter does not support the operation. + unsupported(string), + /// Transport or venue infrastructure failure; retry later. + unavailable(string), + /// Adapter or router failure that is not the caller's fault. + internal-error(string), + } +} diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit new file mode 100644 index 00000000..d626d409 --- /dev/null +++ b/wit/nexum-value-flow/types.wit @@ -0,0 +1,98 @@ +package nexum:value-flow@0.1.0; + +/// The egress-neutral vocabulary for value in motion: where a deal settles, +/// what asset moves, and how much of it. Shared by intent headers, +/// simulation balance diffs, and analyser verdict subjects so that a spend +/// is written the same way in all three places. This is the platform's +/// hardest-freezing contract; the package carries no dependency so it can +/// outlive any interface built on it. +/// +/// Identifier hygiene is a freeze gate. Every identifier below is checked +/// against WIT keywords (including in-flight proposals -- the package is +/// `value-flow`, not `value`, because the component model's value-imports +/// feature is circling that word) and against the reserved words of the +/// nine binding-target languages (Rust, Python, JS, Go, C#, Java, Kotlin, +/// Swift, Dart). A two-word kebab id is preferred wherever a single word is +/// a keyword anywhere: `native-token` not `native` (a Java modifier), +/// `offchain` not `external` (a Dart keyword). WIT parses the rejected +/// spellings today; the cost lands later, as escaped identifiers in the +/// generated bindings for exactly the personas the SDK exists to serve. +interface types { + /// Where a deal comes to rest. A variant from day one: the chain-id + /// plumbing assumes every venue settles on an EVM chain, which the + /// off-chain marketplace target breaks. + variant settlement { + /// Settles on an EVM chain, identified by its chain id. + evm-chain(u64), + /// Settles off-chain: the payload is a jurisdiction or a + /// venue-defined domain. Settlement here is a legal or + /// out-of-band process, not a chain state transition. + offchain(string), + } + + /// A non-token service obligation whose worth the host cannot compute, + /// e.g. Swarm postage: storage capacity for a duration. Policy on a + /// service asset is adapter-attested, not host-verified; the consent + /// surface renders `summary` and must say the host verifies nothing. + record service-desc { + /// Namespaced service kind the venue defines, e.g. `swarm:postage`. + /// Stable enough for policy to match on. + kind: string, + /// Adapter-supplied human-readable description for the consent sheet. + summary: string, + } + + /// A real-world asset whose settlement is a legal process rather than a + /// chain state transition: a deed, a chattel, a registry entry. The host + /// verifies nothing about it. The case name mirrors `settlement.offchain` + /// deliberately: the same concept on two axes -- where the asset lives, + /// and where the deal settles. + record offchain-desc { + /// Jurisdiction or registry domain the asset lives in, e.g. an + /// ISO country code or a venue-defined registry name. + domain: string, + /// Adapter-supplied human-readable description for the consent sheet. + summary: string, + } + + /// A kind of value that can move. The ERC cases carry a bare chain id + /// rather than a `settlement` because an ERC token is inherently EVM; + /// `native-token` wraps `settlement` because a chain's own gas token is + /// the one asset that exists on every settlement domain. Each token + /// tuple carries raw big-endian bytes: a 20-byte address, and for the + /// NFT cases a token id of arbitrary width. + variant asset { + /// The settlement domain's own gas token (ETH, BZZ, ...). Only an + /// on-chain settlement carries a gas token, so `native-token` pairs + /// meaningfully with `settlement.evm-chain`; `native-token(offchain)` + /// is representable but intentionally invalid -- an off-chain domain + /// has no gas token -- so consumers MUST reject that pairing rather + /// than ascribe a meaning to it. + native-token(settlement), + /// An ERC-20 token: (chain id, 20-byte contract address). + erc20(tuple>), + /// An ERC-721 NFT: (chain id, 20-byte contract address, token id). + erc721(tuple, list>), + /// An ERC-1155 token: (chain id, 20-byte contract address, token id). + erc1155(tuple, list>), + /// A non-token service, e.g. storage capacity for a duration. + service(service-desc), + /// A real-world asset settled off-chain. + offchain(offchain-desc), + } + + /// An amount of one asset. `amount` is a big-endian unsigned integer, + /// most-significant byte first, with no fixed width; an empty list is + /// zero. Amounts are never negative -- direction lives in whichever + /// field holds the pair (a header's `gives` vs `wants`), not here. + /// + /// The canonical wire form is minimal-length: no leading zero bytes, so + /// zero is the empty list and five is `[0x05]`, never `[0x00, 0x05]`. + /// Encoders MUST emit the minimal form; decoders MUST compare amounts by + /// integer value, not by byte equality, so a non-minimal encoding from a + /// lenient peer still compares equal to its canonical twin. + record asset-amount { + asset: asset, + amount: list, + } +}