diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 5dee12b9..11f584b1 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -1,38 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Operator config (`engine.toml`) is separate from module manifest (`module.toml`) ## Context -The engine needs two distinct kinds of configuration: what the **operator** decides at deployment time (which chains to connect to, where the local-store database lives, which modules to boot) and what the **module developer** declares at build time (required and optional capabilities, HTTP allowlist, module-specific config keys). These have different reviewers, different threat models, and change on different cadences. - -The filenames need to signal who owns each file directly. An operator opening a config file should know without prior context whether the file is their concern or the module developer's. A name like `nexum.toml` requires the reader to know that "nexum" refers to the runtime that hosts the module, which is one indirection too many; `module.toml` reads as "the module's manifest" with no prior context. +The runtime carries two kinds of configuration with different owners, reviewers, and change cadences: what the operator decides at deployment time (chains, local-store location, which modules to boot) and what the module developer declares at build time (required and optional capabilities, HTTP allowlist, module config keys). A module's capability declaration is a property of the build, so it belongs in the published bundle, not in the operator's local file. ## Decision -Two distinct files, distinct schemas, distinct loaders: - -- **`engine.toml`** - operator-owned, lives next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (state_dir, log_level), `[chains.]` (rpc_url), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. -- **`module.toml`** - module-developer-owned, ships in the module's bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. +Two files, two schemas, two loaders: -The engine config carries the path to each module's manifest; the two never collapse into one file. The names `engine.toml` and `module.toml` map directly onto the two distinct roles, so a reader reaching either file knows whose concerns it covers. +- **`engine.toml`** operator-owned, next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (`state_dir`, `log_level`), `[chains.]` (`rpc_url`), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. +- **`module.toml`** module-developer-owned, ships in the module bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. -## Considered options - -- **Single `shepherd.toml` with `[engine]`, `[chains]`, `[[modules]]` *and* nested `[modules..capabilities]` per module.** Rejected: conflates operator and developer concerns. A module's capability declaration is a property of the build, not the deployment - it belongs in the artifact, not in the operator's local file. Auditing a module's capabilities also becomes a per-deployment exercise instead of a property visible in the published bundle. -- **Keep the `nexum.toml` filename for the module manifest.** Rejected: the name does not signal who owns the file (engine vs module). `module.toml` reads as "the module's manifest" without prior context. -- **`module.toml` inside the engine config (module entries embed it inline).** Rejected for the same reason as the single-file proposal; also bloats `engine.toml`. -- **Drop `engine.toml` entirely; pass everything as CLI flags or env vars.** Rejected: per-chain RPC URLs and module lists are awkward as flags, and `RUST_LOG` already covers the only thing that env vars naturally express. +The engine config carries each module's manifest path; the two files never collapse into one. ## Consequences -- A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`. -- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3. -- Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates. -- Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged. -- Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- A deployment needs both files. A missing `engine.toml` falls back to no chains and the default `state_dir` (`./data`); the example logging module still runs, chain-backed capabilities report `unsupported`. +- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required. +- Module-bundle redistribution carries `module.toml` with the artifact; engines ship no templates. diff --git a/docs/adr/0002-provider-pool-transport-by-scheme.md b/docs/adr/0002-provider-pool-transport-by-scheme.md index 0cecbb5c..1e0e924b 100644 --- a/docs/adr/0002-provider-pool-transport-by-scheme.md +++ b/docs/adr/0002-provider-pool-transport-by-scheme.md @@ -1,39 +1,28 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Per-chain alloy provider transport selected by URL scheme ## Context -`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport (`eth_subscribe` is push-only over a long-lived connection); request/response works on either HTTP or WebSocket. The operator configures one RPC endpoint per chain in `engine.toml`; the engine has to decide which alloy transport to use. +`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport; request/response works on either HTTP or WebSocket. The operator configures one `rpc_url` per chain in `engine.toml`, and the runtime picks the alloy transport from it. ## Decision -The `ProviderPool::from_config` constructor reads each chain's `rpc_url` and switches by URL scheme prefix: +`ProviderPool::from_config` switches on the URL scheme: -- `ws://` or `wss://` → `ProviderBuilder::new().connect_ws(WsConnect::new(url))`. Pubsub transport. Subscriptions and request/response both work. **This is the recommended configuration for any chain a module subscribes to.** -- `http://` or `https://` → `ProviderBuilder::new().connect_http(parsed)`. HTTP transport. Request/response only; `subscribe-blocks` and `subscribe-logs` surface as `fault.unsupported` to the guest. +- `ws://` / `wss://` connect via `connect_ws`. Pubsub transport: subscriptions and request/response both work. Recommended for any chain a module subscribes to. +- `http://` / `https://` connect via `connect_http`. Request/response only; `subscribe-blocks` and `subscribe-logs` return `fault.unsupported` to the guest. -Both transports erase to `DynProvider` so the rest of the engine is transport-agnostic. - -Alloy is capable of emulating `eth_subscribe` on HTTP via polling, but this is intentionally **not** enabled. The engine takes an opinionated stance favouring WebSockets for subscriptions; operators who want push-based events configure WSS endpoints. HTTP-only chains are supported for `request` traffic but not for subscriptions. +Both erase to `DynProvider`, so the rest of the runtime is transport-agnostic. Alloy can emulate `eth_subscribe` on HTTP by polling; this is deliberately not enabled. ## Non-goals -- **RPC failover, load balancing, and retry policies are explicitly out of scope for the engine.** This logic lives in upstream crates (alloy ships tower-style middleware for timeout / retry / rate-limit / fallback endpoint). The engine does not roll its own. Operators wanting failover configure it via alloy provider builders before passing them through, or rely on the provider's own fallback (Alchemy, Infura, etc. handle it server-side). -- Re-routing requests across chains, rebalancing across pools within a chain, and similar provider-management concerns are likewise alloy's responsibility. - -## Considered options - -- **Force WSS everywhere.** Rejected: many providers (Alchemy, Infura, self-hosted RPC) expose HTTP-only on free tiers, and modules that only need `request` (no subscriptions) shouldn't be blocked by a WSS requirement. -- **Explicit `transport = "ws" | "http"` field per chain in `engine.toml`.** Rejected for 0.2: redundant with the URL scheme, and operators already distinguish `wss://` from `https://` endpoints when copying them from their RPC provider's dashboard. Revisit if we add IPC (`/path/to/geth.ipc`) - scheme alone won't carry that. -- **Open both an HTTP and a WSS connection per chain.** Rejected: doubles connection count for the common case where one endpoint serves both, and forces operators to provide two URLs even when their provider returns identical data on both. +RPC failover, load balancing, and retry policy are out of scope. Alloy ships tower-style middleware for timeout, retry, rate-limit, and fallback endpoints; operators configure it on the provider builder, or rely on their provider's server-side fallback. ## Consequences -- Operators that need subscriptions must supply WSS URLs; HTTP-only chains downgrade to request-only mode at the host call boundary. -- Connection failures at boot are fatal (the engine refuses to start with a broken chain). This is intentional - silent fall-back to a half-functioning state masks misconfiguration that a module then rediscovers at first event. -- Adding IPC support is additive: extend the scheme match with `/` / `file://` and call `connect_ipc`. -- The `DynProvider` erasure costs a virtual dispatch per call - a measurable concern at scale, deferred to M4 if profiling shows it. +- Operators needing subscriptions supply WSS URLs; HTTP-only chains downgrade to request-only at the host call boundary. +- Connection failure at boot is fatal: the runtime refuses to start with a broken chain rather than masking misconfiguration a module rediscovers at first event. +- Adding IPC is additive: extend the scheme match with `file://` and call `connect_ipc`. diff --git a/docs/adr/0003-local-store-namespacing.md b/docs/adr/0003-local-store-namespacing.md index a7a5ac12..8bfe1f87 100644 --- a/docs/adr/0003-local-store-namespacing.md +++ b/docs/adr/0003-local-store-namespacing.md @@ -1,49 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8 +status: accepted --- # Per-module namespacing in `local-store` via 32-byte deterministic hash prefix ## Context -`nexum:host/local-store` is a key-value store shared across all modules the engine runs. Two modules using the same key string (e.g. `"last-block"`) must see disjoint values; one module must never read or overwrite another's data. The engine knows each module's identity at instantiation time, so namespacing is a host-side concern. - -Two properties matter for the namespace prefix: - -1. **Deterministic and unspoofable.** An arbitrary `module_name` string read out of `module.toml` lets a malicious or careless operator give two modules the same name and have one read the other's state. A fixed-size hash derived from the module's canonical identity is harder to collide and removes the operator-supplied-text attack surface. -2. **Composes with ENS-based module discovery** (per `docs/03-module-discovery.md`): when a module is identified by an ENS name (e.g. `twap-monitor.shepherd.eth`), the ENS namehash is a natural prefix. ENS TXT records pinning the `.wasm` content hash provide a separate verification path against the loaded bundle. +`nexum:host/local-store` is a key-value store shared across every module the runtime runs. Two modules using the same key string must see disjoint values, and one module must never read or overwrite another's data. The runtime knows each module's identity at instantiation, so namespacing is a host-side concern. The prefix must be deterministic and unspoofable: an operator-supplied `module_name` string would let two modules collide by name, so the prefix derives from the module's canonical identity as a fixed-size hash. ## Decision -Single redb database file at `EngineConfig.engine.state_dir`, single shared table `nexum:local-store`. Every key handed to redb is composed host-side as: - -``` -[32-byte namespace prefix][raw key bytes] -``` - -The 32-byte prefix is computed deterministically from the module's canonical identity: - -- **ENS-identified modules** (M3+, per `docs/03`): prefix is `ens_namehash(name)` (EIP-137), e.g. `namehash("twap-monitor.shepherd.eth")`. -- **Locally-loaded modules** (current 0.2 scope, no ENS): prefix is `keccak256(module_name)` where `module_name` comes from `module.toml`'s `[module].name` field. - -Both produce a 32-byte digest with the same domain, so a module loaded locally during development and later published under an ENS name can keep its existing state by registering an alias (`alias = keccak256(name)`) the engine recognises during the migration window. The exact alias mechanism is out of scope for this ADR. - -Modules see plain key strings on both the read and write paths; the prefix is invisible to the WIT-facing API. +Single redb database file at `EngineConfig.engine.state_dir`, single shared table. Every key handed to redb is composed host-side as `[32-byte namespace prefix][raw key bytes]`. -## Considered options +The prefix is `keccak256(module_name)`, where `module_name` is `module.toml`'s `[module].name`. keccak256 shares the domain of the ENS namehash, so a module loaded locally and later published under an ENS name (see `docs/03-module-discovery.md`) can keep its state via an alias registered during migration; the alias mechanism is out of scope here. -- **Separator string** (`{module}:{key}`). Rejected: any module name containing `:` collides with another module's `:`-bearing key. A fixed-size hash is unambiguous regardless of payload bytes. -- **`[len:u8][module_name][key]` length-prefixed string.** Rejected: spoofable (the name is operator-supplied text), and does not align with the ENS-based discovery path that 0.3 will introduce. The 32-byte hash is deterministic and namespace-uniform. -- **One redb database file per module.** Rejected: multiplies open file handles linearly in modules, blocks any future cross-module atomic operations (not currently planned but cheap to keep on the table), and complicates backup tooling (N files vs 1). -- **One redb *table* per module within a single file.** Rejected: redb `TableDefinition` lifetimes are `'static`, so table names must be known at compile time. Dynamic table opening per module would force string-leak workarounds and exposes the same name-collision question as separator-based keys. -- **Engine-allocated incrementing module id.** Rejected: stable across reboots only if the engine persists the allocation table, which adds a chicken-and-egg dependency on the local-store itself. Determinism from the name avoids the dependency entirely. +Modules see plain key strings on both paths; the prefix is invisible to the WIT API. ## Consequences -- The prefix is fixed-size (32 bytes) and independent of module name length. Range scans over a single module's keys are O(log n + module-key-count) - fine for our workload. -- Migrations changing the prefix derivation (e.g., switching the local-mode hash function or the ENS resolver) would orphan every existing module's persisted state. The derivation must stay stable through 0.x; ENS-mode introduction in 0.3 happens additively via the alias mechanism, not by changing existing prefixes. -- A module's `list-keys` iterates over the namespace range (32-byte prefix scan); the host strips the prefix before returning to the guest. -- Module data versioning (schema migrations across module versions) is the module's responsibility. The local-store does not version values; modules MAY embed a `schema_version` byte in their stored payloads and migrate on `init` when the read value's version differs from the current code's expectation. -- ENS-based discovery (per docs/03) integrates without a prefix-format change: when a module is loaded by ENS name, the prefix is `namehash(name)`. The corresponding `.wasm` content hash is verified via ENS TXT records before loading, separately from the local-store prefix derivation. -- Spoofing protection: an operator cannot make module A read module B's state by renaming, because the prefix is the hash of the canonical name. Renaming a module to match another's name produces a name conflict the engine refuses at boot, rather than silent state takeover. +- The prefix is fixed-size and independent of key length. A module's `list-keys` iterates the 32-byte prefix range; the host strips the prefix before returning to the guest. +- Changing the prefix derivation would orphan every module's persisted state, so the derivation stays stable through 0.x; ENS-mode namespacing is introduced additively via the alias mechanism, not by changing existing prefixes. +- The store does not version values. Modules that need schema migration embed their own version marker in stored payloads and migrate on `init`. +- An operator cannot make module A read module B's state by renaming: matching names produces a boot-time conflict, not a silent state takeover. 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 c78d14c6..2b587b29 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -1,44 +1,21 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#10 +status: accepted --- -# Patch `cowprotocol` crate to the head of upstream PR #5 +# Patch the `cowprotocol` crate to a maintained fork ## Context -`cowprotocol` v1.0.0-alpha.3 (the version on crates.io) was cut from an early snapshot of `cowdao-grants/cow-rs` PR #5 at commit `1742ffa`. That PR is still open and is the canonical upstream channel for landing additions to the Rust SDK. Its head branch is `bleu/cow-rs:main`, currently at commit `c012404`, carrying 18 follow-up commits the engine materially depends on: - -- `composable::Proof` byte-width fix (consumed by the TWAP poll path). -- `OrderCreation` zero-`from` fast-fail (closes a MEDIUM severity finding in PR #5). -- `order_book` / `composable` submodule splits (cleaner imports on the engine side). - -ADR-0007 commits us to landing three protocol-level primitives into PR #5 directly (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, and `wasm32` feature-gating) by pushing additional commits to its head branch. Each commit advances both PR #5 and the patch rev consumed here. - -There is no published `alpha.4` and no scheduled date for one; the engine cannot wait. +The workspace needs `cowprotocol` changes ahead of any published release: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and a WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) carries neither. ## Decision -Add a workspace-level `[patch.crates-io]` redirecting `cowprotocol` to `https://github.com/bleu/cow-rs` at commit `c012404`. Every crate that declares `cowprotocol = "1.0.0-alpha.3"` (engine, modules, future SDK) silently picks up the patched build with no `Cargo.toml` change at the dependent site. - -This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream PR #5. Pushing to it updates PR #5; the patch rev advances by bumping a single workspace line. +A single workspace-level `[patch.crates-io]` redirects `cowprotocol` to `https://github.com/nullislabs/cow-rs` at a pinned rev. Every crate declaring `cowprotocol` picks up the patched build with no change at the dependent site. Bumping the fork is a one-line rev edit. -## Considered options - -- **Vendor the missing types locally.** Rejected: re-implementing `composable::Proof`, `OrderCreation`, etc. in the engine repo is the AI-duplication anti-pattern that the cow-rs SDK already solves. Reuse over reimplement applies. -- **Pin every dependent to `cow-rs` git directly.** Works but every new workspace member has to remember the git source. `[patch.crates-io]` centralises the override. -- **Open a separate PR per primitive against `cowdao-grants/cow-rs`.** Rejected: fragments the change across multiple PRs when one already exists at the appropriate granularity. Stacking commits on PR #5 keeps the change coherent and lets the cumulative diff be tracked in one place. -- **Wait for `alpha.4` to publish.** No ETA; the TWAP/EthFlow milestone cannot land without `composable::Proof` correct. +Rather than vendor the missing types locally (reuse over reimplement) or pin each dependent to a git source, the `[patch.crates-io]` override centralises the redirect. ## Consequences -- `cargo update` will re-resolve to the same `rev`; the lock pins it. -- 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. +- `cargo update` re-resolves to the same rev; the lock pins it. +- Drop the patch once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the root `Cargo.toml` states the current drop condition. +- Modules built standalone against crates.io see the unpatched release and may hit the bugs the patch closes. diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 6586e26a..0c5a570e 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,40 +1,15 @@ --- status: superseded -implemented-in: nullislabs/shepherd#8 --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` -> **Superseded by the videre venue-adapter architecture.** The -> `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend -> are retired: orderbook submission and status ride the `cow-venue` -> adapter component over `wasi:http`, driven through the -> `videre:venue/client` pool seam. +> **Superseded by the videre venue-adapter architecture.** The `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend are retired: orderbook submission and status ride the `cow-venue` adapter component over `wasi:http`, driven through the `videre:venue/client` pool seam. ## Context -`shepherd:cow/cow-api` exposes two operations: a generic REST passthrough (`request`) and a typed order submission (`submit-order`). Either could be implemented with raw `reqwest` against `api.cow.fi/{slug}/api/v1`, but the published `cowprotocol` crate already ships an `OrderBookApi` client that knows the chain-specific base URL, the canonical paths, and the `post_order` codec. +`shepherd:cow/cow-api` exposed a generic REST passthrough (`request`) and a typed order submission (`submit-order`). The `cowprotocol` crate already shipped an `OrderBookApi` client that knew the chain base URL, canonical paths, and `post_order` codec. -## Decision +## Decision (retired) -At engine boot, construct one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant (currently Mainnet, Gnosis, Sepolia, ArbitrumOne, Base) into a `BTreeMap` keyed by EVM chain id. "Cached" here means built once during boot and reused for the engine's lifetime; clients are not lazy-constructed on each call nor LRU-evicted. The pool implements `Default` so callers instantiate it as `OrderBookPool::default()`; the trait impl populates the map with one entry per `cowprotocol::Chain` variant. - -Both `cow-api` operations consult this pool: - -- `request` resolves the chain's `OrderBookApi`, reads `api.base_url()` for the prefix, joins the module-supplied path, and dispatches via a shared `reqwest::Client`. -- `submit-order` deserialises the JSON `OrderCreation` and calls `OrderBookApi::post_order` directly. The crate handles signing-scheme encoding, error mapping, and `OrderUid` extraction. - -Chains not in `cowprotocol::Chain` return `cow-api-error` carrying `fault.unsupported` at the host call boundary. - -## Considered options - -- **Raw `reqwest` for both.** Rejected: forces us to maintain the chain → base-URL table (drifts whenever cowprotocol adds a chain) and reimplement `post_order`'s body codec and error mapping, the exact duplication the cow-rs SDK already eliminates. -- **`OrderBookApi` for `submit-order`, raw `reqwest` for `request`.** Tempting (request is opaque to the crate) but means two separate chain-resolution paths, two HTTP clients, and a second place to keep the chain set in sync. -- **Build `OrderBookApi` lazily on first call per chain.** Rejected: hides config errors at runtime. Up-front boot construction surfaces unknown chains immediately and amortises away the per-call cost. - -## Consequences - -- Operator-supplied custom orderbook URLs (barn, staging, forked deployments) are out of scope for the default constructor and require a follow-on `OrderBookApi::with_base_url(chain_id, base_url)` constructor in the cow-rs crate (ADR-0007 item 2, not vendored locally). -- Adding a chain means a `cowprotocol::Chain` variant lands in cow-rs first; the engine inherits it on the next patched rev bump. -- The shared `reqwest::Client` enables connection pooling across both `request` and `submit-order` paths. -- Guest-side TWAP and EthFlow modules (ADR-0006) submit orders through this `cow-api` interface; no specialised host helpers wrap it. +At boot, build one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant into a `BTreeMap` keyed by chain id, reused for the runtime's lifetime. `request` resolved the chain client and joined the module-supplied path; `submit-order` deserialized the JSON `OrderCreation` and called `OrderBookApi::post_order`. Chains outside `cowprotocol::Chain` returned `fault.unsupported`. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 43600c13..d59df80f 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -2,56 +2,20 @@ status: superseded --- -# TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +# TWAP and EthFlow run as guest modules using low-level host primitives -> **Superseded by the videre venue-adapter architecture.** The -> strategies-as-guest-modules line holds, but the protocol seam it -> assigned to `shepherd:cow/cow-api` is retired: modules submit typed -> intent bodies through the `videre:venue/client` pool seam and the -> `cow-venue` adapter owns the orderbook edge. +> **Superseded by the videre venue-adapter architecture.** The strategies-as-guest-modules line holds, but the protocol seam it assigned to `shepherd:cow/cow-api` is retired: modules submit typed intent bodies through the `videre:venue/client` pool seam, and the `cow-venue` adapter owns the orderbook edge. ## Context -TWAP (over ComposableCoW) and EthFlow are the two CoW workflows the M2 grant ships modules for. The natural-seeming approach is to add `shepherd:cow/twap` and `shepherd:cow/ethflow` WIT interfaces that the host implements on top of `cowprotocol` crate primitives, so modules would call `twap.poll-and-submit(...)` and `ethflow.submit-from-log(...)` as host functions. This ADR rejects that direction. - -The dividing line is protocol vs implementation. CoW Protocol primitives - order types, signing schemes, the orderbook REST surface - are protocol concerns and belong in shared layers (`cowprotocol` crate, `shepherd:cow/cow-api` interface). TWAP is one of many strategies built _on top of_ those primitives; ComposableCoW is the contract surface a TWAP module observes, but the act of polling, deciding when to submit, and reacting to orderbook errors is application logic. Putting that application logic in the host or in `cowprotocol` couples every consumer to one implementation and one error-handling policy. - -Embedding a concrete TWAP implementation in an SDK is an architectural smell the grant explicitly seeks to alleviate. The grant seeks to enable Shepherd as the runtime where many independent strategy implementations coexist, each compiled to its own WASM module. A specialised `twap` interface in the host would defeat that goal: every Shepherd deployment would have to use the same polling implementation, the same error-mapping, the same retry hints, with no room for different strategies to differ on those choices. +TWAP (over ComposableCoW) and EthFlow are strategies built on top of CoW Protocol primitives, not protocol concerns themselves. The dividing line is protocol vs implementation: order types, signing schemes, and the orderbook surface belong in shared layers; polling, submit timing, and error reactions are application logic. Putting that logic in the host or in `cowprotocol` would force every deployment onto one implementation and one error-handling policy. ## Decision -The `shepherd:cow` WIT package contains only the existing `cow-api` interface (REST passthrough + `submit-order`), which is protocol-level. No `twap` interface, no `ethflow` interface, no host-side helpers specific to either workflow. - -TWAP and EthFlow modules implement their logic in Rust guest code using: - -- **`nexum:host/chain`** - `request` (for `eth_call`, `eth_getLogs`, etc.), `subscribe-blocks`, `subscribe-logs`. -- **`nexum:host/local-store`** - for watch lists, cursors, and backoff state. -- **`nexum:host/logging`** - for structured logs. -- **`shepherd:cow/cow-api`** - `submit-order` for orderbook submission. -- **`cowprotocol` crate** (consumed directly by the module, gated on the wasm32 feature work in ADR-0007) - for protocol types: `Order`, `OrderCreation`, `OrderUid`, signing schemes, `OrderPostError`, etc. -- **`alloy_sol_types`** (or equivalent) - for ABI-aware decoding of `ConditionalOrderCreated`, `OrderPlacement`, `getTradeableOrderWithSignature` return values, and similar Solidity-typed payloads. - -Concretely, a TWAP module's `on_event(block)` handler iterates the local-store watch set, makes an `eth_call` to `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])` via `chain.request`, decodes the return (or revert reason) with `alloy_sol_types`, constructs an `OrderCreation` with `cowprotocol` types, and submits via `cow-api/submit-order`. Orderbook errors are interpreted via `OrderPostError::retry_hint()` (ADR-0007). Backoff state is persisted to `local-store`. All of this lives in module Rust source, not in the engine. - -An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event with `alloy_sol_types`, constructs the `OrderCreation` (with the EIP-1271 signing scheme pointing at the `CoWSwapEthFlow` contract), and submits the same way. Module-side, no host helper required. - -## Considered options - -- **Specialised `shepherd:cow/twap` and `shepherd:cow/ethflow` interfaces** with rich `PollOutcome` variants and per-event host helpers, backed by `composable::poll_and_build_order` and `eth_flow::decode_placement` primitives in the `cowprotocol` crate. Rejected: this puts a single concrete TWAP / EthFlow implementation behind a WIT boundary, forcing every Shepherd deployment to use the same polling policy, the same error-mapping, the same retry hints. It also blurs the protocol-vs-implementation boundary the grant is meant to clarify. Multiple TWAP implementations (different polling cadences, different error tolerances, different cancel-on-loss thresholds) must be able to coexist as separate modules without changing the host or the SDK. -- **Move TWAP / EthFlow primitives into `cowprotocol` crate but skip the WIT interfaces**, leaving modules to call `composable::poll_and_build_order` from guest code. Rejected for the same reason: `cowprotocol` is the protocol SDK, not the strategy SDK. Putting TWAP logic there embeds an implementation in the shared layer, which is the smell the grant seeks to fix. -- **Ship a thin `shepherd-sdk` helper crate** that wraps the low-level primitive calls (eth_call, decode, submit) into a convenient `Twap::poll(...)` interface for guest modules. **Acceptable for M3** because the helper would live in guest-callable code, not behind a WIT boundary - a module that wants different polling policy just doesn't use the SDK helper. The host stays neutral. -- **EthFlow as pure passive observer (no submission)**. Rejected on closer read of `cowprotocol/services/crates/autopilot/src/database/onchain_order_events/ethflow_events.rs`: the canonical CoW flow expects the event to be relayed into the orderbook, which is what autopilot currently does internally. Shepherd's `ethflow-watcher` externalises that role, so the module does submit; just from guest code, not via a specialised host interface. -- **TWAP merkle-proof / `setRoot` support in v1.** Deferred. The 0.2 module only handles `ComposableCoW.create()` (empty proof, single conditional order). `setRoot` polling requires off-chain proof derivation; when a real module needs it, it will be implemented in guest code using the same low-level primitives, possibly with an SDK helper to encapsulate the proof bookkeeping. +The host stays protocol-neutral: no `twap` or `ethflow` host interface. TWAP and EthFlow modules implement their logic in guest Rust over the universal host primitives (`chain`, `local-store`, `logging`) plus the venue submit seam, using `cowprotocol` crate types for `Order` / `OrderCreation` / `OrderUid` / signing schemes and `alloy_sol_types` for ABI decoding. Different polling strategies coexist as separate modules chosen via `engine.toml`'s `[[modules]]`. ## Consequences -- `shepherd:cow@0.1.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. -- `KNOWN_CAPABILITIES` in `crates/nexum-engine/src/manifest.rs` does **not** gain `"twap"` or `"ethflow"` entries. Modules declare the universal capabilities they actually use: `chain`, `local-store`, `logging`, `cow-api`. -- Modules ship larger (~150 LOC each estimated, up from the ~30 LOC the host-helper design implied), because event decoding, eth_call orchestration, OrderCreation construction, and error-hint interpretation now live in guest code. This is the explicit trade-off: more code per module, less coupling, more freedom for different strategies to coexist. -- Different TWAP polling strategies can coexist as different modules. Operators choose which to load via `engine.toml`'s `[[modules]]` array. -- The watch-tower TypeScript implementation remains the closest reference for what a TWAP module's logic looks like, but it is reference material, not a template the Rust module mirrors verbatim. A newer ComposableCoW iteration in development may simplify the polling surface significantly; the relevant decisions live in the module, not the host. -- `OrderPostError` rich variants + `retry_hint()` (ADR-0007 item 1, formerly item 3) become the primary protocol-level contract between the orderbook and any module submitting orders. Modules `match` on the typed error and apply the `RetryHint` (try-next-block / backoff-seconds / drop). This logic is generic across TWAP, EthFlow, stop-loss, and any future strategy. -- The M3 SDK (`shepherd-sdk` crate) is the natural home for ergonomic guest-side helpers: `WatchSet`, `PollLoop`, `BackoffLedger`, decode-and-submit utilities. The SDK is opt-in for module authors and lives entirely on the guest side; the host remains protocol-neutral. -- The architecture and sequence diagrams in `docs/diagrams/` that depict `twap.poll-and-submit` and `ethflow.submit-from-log` host calls reflect the rejected design and must be updated to show modules calling low-level primitives directly. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- `KNOWN_CAPABILITIES` gains no `twap` or `ethflow` entry; modules declare only the universal capabilities they use. +- Modules ship larger because event decoding, `eth_call` orchestration, order construction, and error-hint handling live in guest code. This is the explicit trade-off: more code per module, less coupling. +- `OrderPostError::retry_hint` (ADR-0007) is the orderbook-submit contract shared across strategies. The poll mechanism itself is superseded by [ADR-0013](0013-composable-cow-structured-poll.md). diff --git a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md index 9ff06fa3..f750feac 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -1,48 +1,21 @@ --- -status: proposed +status: accepted --- -# Push CoW Protocol primitives to `cow-rs` first, adopt in `nexum-engine` second +# Push CoW Protocol primitives to `cow-rs` first, adopt in the runtime second ## Context -Implementing ADR-0005 (cow-api backend) and supporting guest-side TWAP / EthFlow modules per ADR-0006 surfaces a recurring question: when the engine or its modules need a piece of CoW Protocol logic that the `cowprotocol` Rust SDK does not yet expose (rich orderbook error variants, custom orderbook URLs, wasm32 compatibility), do we write that logic locally and tidy it up upstream later, or do we add it to the open upstream PR first and only land the engine wiring afterwards? - -The failure mode is well-known: duplicating work that an existing crate could do is the AI-coding anti-pattern most likely to land in a contribution. The same risk applies to any engine-side reimplementation of protocol logic. - -The line between **protocol primitives** (which belong in `cowprotocol`) and **strategy implementations** (which belong in guest modules, per ADR-0006) is the operating principle. This ADR covers only the protocol-primitive additions; TWAP polling and EthFlow event decoding stay in guest modules and are explicitly **not** primitives we push to `cowprotocol`. +When the runtime or its modules need CoW Protocol logic the `cowprotocol` crate does not yet expose, the choice is to write it locally and tidy up upstream later, or add it upstream first and land the wiring afterwards. Duplicating logic an existing crate could own is the anti-pattern to avoid. Protocol primitives (order types, signing schemes, orderbook errors) belong in `cowprotocol`; strategy implementations (TWAP polling, EthFlow decoding) stay in guest modules per ADR-0006. ## Decision -Protocol-level CoW logic - anything that an indexer, a bot, or a non-`nexum` Rust consumer of CoW Protocol would also need to interact with the protocol - lands as additional commits on `cowdao-grants/cow-rs` PR #5 first (head branch `bleu/cow-rs:main`), and is consumed by `nexum-engine` and by guest modules via the `[patch.crates-io]` rev bump (ADR-0004). The engine and the modules never write throwaway local copies of the same logic with the intent to "port later". - -The concrete set of primitives this ADR commits to upstream, in priority order: - -1. **`cowprotocol::OrderPostError` rich variants + `retry_hint(&self) -> RetryHint`** - typed orderbook submission errors (`QuoteNotFound`, `InvalidQuote`, `InsufficientAllowance`, `InsufficientBalance`, `TooManyLimitOrders`, `InvalidAppData`, `AppDataFromMismatch`, `SellAmountOverflow`, `ZeroAmount`, `TransferSimulationFailed`, `ExcessiveValidTo`, …) with a `retry_hint()` helper classifying each into `TryNextBlock`, `BackoffSeconds(u64)`, or `Drop`. Mirrors watch-tower's `API_ERRORS_TRY_NEXT_BLOCK` / `API_ERRORS_BACKOFF` / `API_ERRORS_DROP` tables. Without this, every Rust consumer of CoW reinvents the same mapping, and modules spam the orderbook with permanently-broken orders. **Critical-path, not optional.** - -2. **`cowprotocol::OrderBookApi::with_base_url(chain_id, base_url)`** - custom-URL constructor for barn / staging / forked deployments. Unblocks per-chain orderbook URL overrides in `engine.toml` (ADR-0005). +Protocol-level CoW logic, anything a non-`nexum` Rust consumer of the protocol would also need, lands in `cowprotocol` first and is consumed via the `[patch.crates-io]` rev bump (ADR-0004). The runtime never writes throwaway local copies with intent to port later. -3. **`cowprotocol` `wasm32` compatibility** - feature-gate the `reqwest` dependency so guest modules can use the pure types (`Order`, `OrderCreation`, `OrderUid`, signing schemes, error variants) without dragging in an HTTP client. **Critical for ADR-0006**: modules implement TWAP and EthFlow logic in guest code and need `cowprotocol` types compiled to wasm32. Without this, guest modules fall back to duplicating type definitions. - -Lower-priority follow-ons (`OrderUid::from_slice`, retry middleware on `OrderBookApi`, `OrderCreation::from_gpv2`) are good-to-have but are not blocking for the M2 host or module scope. - -## Considered options - -- **Implement locally, refactor upstream later.** Faster short term but predictably leaves an indeterminate amount of duplicated logic in the engine, contradicts the conventions established on cow-rs PR #5, and grows technical debt every time cow-rs evolves the underlying types. Rejected. -- **Push TWAP / ComposableCoW primitives** (`composable::poll_and_build_order`) into `cowprotocol`. Rejected: TWAP is a concrete strategy on top of the protocol, not part of the protocol. Putting it in the SDK forces every consumer to use one polling implementation and one error-mapping policy. Per ADR-0006, TWAP polling lives in guest module code, not in shared layers. -- **Push EthFlow log-decoding primitives** (`eth_flow::decode_placement`) into `cowprotocol`. **Rejected for the same reason**: EthFlow event decoding is an implementation detail of how a particular module relays orders into the orderbook. The protocol layer defines the order types and the orderbook submission endpoint; the act of decoding an on-chain event into an `OrderCreation` is module-side logic. Modules decode `OrderPlacement` directly with `alloy_sol_types` and construct the `OrderCreation` with the EIP-1271 signing scheme. -- **Wait for cow-rs upstream maintainers to add these on their own.** No evidence anyone else is doing this work; the grant timeline does not permit waiting. -- **Vendor a fork of cow-rs inside `nullislabs/shepherd`.** Worst of all worlds: blocks neither the engine nor cow-rs from drifting, and forces every other CoW consumer to re-derive the same primitives. -- **Host-side `AppDataResolver` (LRU cache + GET against `/api/v1/app_data/{hash}`).** Rejected after verifying watch-tower's behavior: it never fetches app-data. The trader uploads the JSON to the orderbook via `PUT /api/v1/app_data/{hash}` separately; the relayer module just submits and reacts to `INVALID_APP_DATA` (backoff 1 min) / `APPDATA_FROM_MISMATCH` (drop) via the error map in item 1 above. +The primitives this covers: `OrderPostError` rich variants plus `retry_hint`, classifying each submission error into try-next-block / backoff / drop; and `wasm32` compatibility (feature-gating `reqwest`) so guest modules use the pure types compiled to wasm without an HTTP client. ## Consequences -- Every M2 engine or module issue that consumes one of the three primitives above is blocked on the corresponding commit landing in PR #5's head branch. Items 1, 2, 3 can be authored as independent commits and pushed in parallel rather than serially. -- `[patch.crates-io]` rev in the workspace `Cargo.toml` (ADR-0004) is bumped after each push to PR #5; the bump is the engine's signal that a new primitive is consumable. -- Commits added to PR #5 follow its established conventions: alloy reuse over local reimplementation, GPL-3.0, edition 2024, terse rustdoc. -- The engine repo stays small: `nexum-engine` contains WIT, host wiring, supervisor, redb store, alloy provider pool, and `engine.toml` schema, with nothing about CoW Protocol semantics. -- Guest modules consume `cowprotocol` types directly (gated on the wasm32 feature in item 3). The `shepherd-sdk` crate in M3 may add ergonomic wrappers on top, but those live on the guest side, not behind a WIT boundary. -- A follow-on Bleu module - the Rust-side equivalent of `cowprotocol/refunder` (permissionless `invalidateOrder` triggering for expired EthFlow orders) - becomes natural to ship once an ethflow-watcher module lands. Out of scope for M2 but explicitly enabled by the same primitives. -- TWAP polling logic (decode `ConditionalOrderCreated`, eth_call `getTradeableOrderWithSignature`, decode return, build `OrderCreation`) and EthFlow event decoding stay entirely in guest module code. The `cowprotocol` crate provides only the types and the orderbook client; the strategy is the module's. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor. The `cowprotocol` crate now publishes to crates.io (the workspace pins `0.2.0`), so the PR-#5-head consumption model above is historical; a `[patch.crates-io]` git override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor - see the comment above the patch block in the root `Cargo.toml` for the current state._ +- The runtime repo stays free of CoW Protocol semantics: it holds WIT, host wiring, supervisor, redb store, provider pool, and the `engine.toml` schema. +- Guest modules consume `cowprotocol` types directly, gated on the wasm32 feature. +- `cowprotocol` now publishes to crates.io (workspace pins `0.2.0`); a `[patch.crates-io]` override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor (ADR-0004). diff --git a/docs/adr/0008-factory-subscriptions-in-manifest.md b/docs/adr/0008-factory-subscriptions-in-manifest.md index f7fff7da..6b9f253e 100644 --- a/docs/adr/0008-factory-subscriptions-in-manifest.md +++ b/docs/adr/0008-factory-subscriptions-in-manifest.md @@ -1,56 +1,28 @@ --- status: deferred -deferred-to: 0.3 --- -# Dynamic address registration for log subscriptions (deferred to 0.3) +# Dynamic address registration for log subscriptions ## Status -**Deferred to 0.3.** Neither TWAP nor EthFlow (the M2 grant deliverables) needs this capability, and the design's complexity is not justified by current need. - -This ADR is preserved as a reference for the design space; the final shape will be revisited when the first module actually requiring dynamic address registration emerges. +**Deferred.** No current module needs it, and the schema and host-function surface add runtime complexity nothing exercises yet. Preserved as a record of the design space; the shape is revisited when a module actually requiring dynamic address registration emerges. ## Context -Some module archetypes need to track contracts deployed dynamically by a factory, for example Uniswap V3 pools (deployed by `UniswapV3Factory`). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are not known when the module's manifest is authored. - -Neither TWAP nor EthFlow needs this; both subscribe to a single well-known contract per chain. This ADR was originally framed as forward-looking work to land in 0.2's breaking-change window. - -## Why deferred - -Two considerations motivate the deferral: - -1. **`eth_getLogs` already supports topic-only filtering.** The JSON-RPC method accepts a filter without an `address` field, so a module subscribing to a topic across all addresses can be served by the existing primitives if the operator's RPC endpoint cooperates. If topic-only filters at the JSON-RPC layer are good enough for the common case, the engine does not need a manifest-and-host-function mechanism on top. -2. **The schema and host-function surface add engine complexity that no M2 deliverable consumes.** The historical-backfill story is the largest contributor to that complexity and was already trimmed once; deferring the rest in the same spirit avoids paying for a mechanism nothing exercises yet. - -Combined: the dynamic-subscription design is not load-bearing for M2 deliverables, and the simplest path (topic-only `eth_subscribe` filters with module-side address filtering) may suffice for a wide range of indexer use cases. The dynamic-registration mechanism originally proposed (Envio-style `register-address`) addresses scaling concerns at high address counts but should land when a real consumer is on the table to validate the trade-off. - -## Reference design (not adopted in 0.2) - -The original proposal - kept here so future discussions have a starting point - was a hybrid of static topics and dynamic addresses: - -- `[[subscription.template]]` block in `module.toml` declaring `chain_id`, `name`, `event_topics` (no address). -- `chain.register-address(chain_id, template_name, address)` host function for the module to add addresses at runtime. -- `chain.unregister-address(chain_id, template_name, address)` mirror function. -- `log-source.template(string)` variant on the event dispatch so modules route by template name. -- Engine maintains a single aggregated `eth_subscribe logs` per chain per template, with filter `(topic ∈ event_topics) ∧ (address ∈ current_set)`. The address set is mutated as the module discovers new contracts. -- Historical backfill (`from-block` argument on register, paginated `eth_getLogs` orchestration) was contentious and was already trimmed before deferral. +Some module archetypes track contracts deployed by a factory (for example Uniswap V3 pools). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are unknown at manifest authorship. The current CoW modules do not need it; each subscribes to a single well-known contract per chain. -Envio HyperIndex's `context..register()` API is the closest existing pattern, validated in production for indexers tracking thousands of dynamically-discovered contracts. +`eth_getLogs` already accepts a topic-only filter (no `address` field), so a module can subscribe to a topic across all addresses and filter module-side, covering the common case without a new manifest-and-host mechanism. -## Alternatives left open for 0.3 +## Design space (not adopted) -- **Topic-only `[[subscription]]`** (no address field; engine forwards `eth_subscribe logs` with topic-only filter; module client-side filters logs by address it cares about). Simplest, no new host functions. Trade-off: firehose volume for common topics like `Transfer`. -- **Dynamic register-address** (the original reference design above). -- **Engine-extracted factory child addresses** (Ponder-style declarative schema with ABI-aware extraction rules). Schema complexity grows with exotic factory shapes. -- **No factory pattern; modules wanting dynamic discovery use raw `chain.subscribe-logs` with topic-only filter and persist the discovered address set themselves**. +- **Topic-only `[[subscription]]`**: no address field, module filters client-side. Simplest, no new host functions; trade-off is firehose volume for common topics. +- **Dynamic register-address**: a `[[subscription.template]]` block plus `chain.register-address` / `unregister-address` host functions maintaining a per-chain aggregated `eth_subscribe logs` whose address set the module mutates at runtime. Envio HyperIndex's `register()` is the closest existing pattern. +- **Runtime-extracted factory child addresses**: declarative ABI-aware extraction rules; schema complexity grows with exotic factory shapes. -The choice depends on what the first consumer actually needs. +The choice depends on what the first consumer needs. -## Consequences of deferring +## Consequences -- The `shepherd:cow` and `nexum:host` WIT surfaces remain unchanged in 0.2. -- `module.toml` schema does not gain `[[subscription.template]]` in 0.2. -- 0.2 is the breaking-change window; adding any of the above options in 0.3 may require a major version bump if the chosen shape extends `module.toml` or `nexum:host/chain` non-additively. This risk is accepted on the basis that the M2 grant deliverables do not require this surface. -- TWAP and EthFlow modules ship in 0.2 against the existing static `[[subscription]]` declarations (one address per subscription, known at manifest authorship time). This is consistent with how the autopilot ethflow indexer and watch-tower configure their subscriptions today. +- The `nexum:host` WIT surface and the `module.toml` schema stay unchanged: no `[[subscription.template]]`. +- Current modules ship against static `[[subscription]]` (one address per subscription, known at authorship). diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index fde9b463..a205f3dd 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -1,103 +1,25 @@ --- -status: proposed -implemented-in: bleu/nullis-shepherd#12, #13, #15, #22, #23, #24, #25 +status: superseded-in-part --- -# M3 Host trait surface: four per-capability traits + supertrait `Host`, with per-module `strategy.rs` / `lib.rs` split +# Host trait surface: per-capability traits plus a supertrait, with a `strategy.rs` / `lib.rs` split -> **Superseded in part by [ADR-0011](0011-per-interface-typed-errors.md).** The single `HostError` / `HostErrorKind` envelope this ADR mirrors was later replaced by per-interface typed errors over a shared `fault` vocabulary: `ChainHost::request` returns `ChainError`, `CowApiHost::submit_order` returns `CowApiError`, and the remaining traits return `Fault`. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. +> **The error envelope is superseded by [ADR-0011](0011-per-interface-typed-errors.md):** the single `HostError` / `HostErrorKind` record is replaced by per-interface typed errors over a shared `fault` vocabulary. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. ## Context -`docs/05-sdk-design.md` describes a much richer M5+ SDK (`#[nexum::module]` proc macro, alloy `Provider`, `TypedState`, `Signer`, named event handlers with async dispatch). M3's scope was narrower: deliver a testable host abstraction that lets module logic compile against an in-memory mock without a `wasm32-wasip2` toolchain, and that the M2 modules (twap-monitor, ethflow-watcher) can adopt without breaking their existing dispatch. - -The constraint is unusual: `wit_bindgen::generate!` emits per-cdylib types - every module gets its own `HostError`, `Event`, `Log`, etc. - so a single shared SDK type cannot be re-used across the wit boundary. Mocks live in their own crate (`shepherd-sdk-test`) and need to compile for the host target (not wasm). +The runtime needs a testable host abstraction so module logic compiles against an in-memory mock without a `wasm32-wasip2` toolchain. `wit_bindgen::generate!` emits per-cdylib types, so a single shared SDK type cannot cross the WIT boundary; the mocks live in their own crate (`nexum-sdk-test`) and compile for the host target. ## Decision Three coupled choices: -### 1. Four per-capability traits with a supertrait `Host` - -`shepherd-sdk` exposes four traits, one per host import: - -```rust -pub trait ChainHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -pub trait LocalStoreHost { fn get / set / delete / list_keys ... } -pub trait CowApiHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; } -pub trait LoggingHost { fn log(&self, level: LogLevel, message: &str); } - -pub trait Host: ChainHost + LocalStoreHost + CowApiHost + LoggingHost {} -impl Host for T {} -``` - -Module strategy code takes `&impl Host` (or ``), so it can call any of the four interfaces uniformly. Tests inject `shepherd_sdk_test::MockHost`; production inject `WitBindgenHost`. The blanket `impl Host for T` means callers never write `impl Host for MyHost {}` by hand. - -### 2. SDK-side `HostError` mirroring the wit struct field-for-field - -`shepherd_sdk::host::HostError` has the same fields as the wit-bindgen-generated `HostError` in each module crate, but is its own type: - -```rust -pub struct HostError { - pub domain: String, - pub kind: HostErrorKind, - pub code: i32, - pub message: String, - pub data: Option, -} -``` - -Each module's `lib.rs` writes a one-liner `convert_err` and `sdk_err_into_wit` to bridge the two. The traits stay world-neutral: `shepherd-sdk-test` compiles for the host target without needing a wasm toolchain, and the mocks are usable from any module's tests. - -### 3. Per-module `strategy.rs` + `lib.rs` split - -Every module is shaped as: - -- `strategy.rs` - pure logic. Imports `shepherd_sdk::host::{Host, HostError, LogLevel}`. Defines small carrier types (`LogView<'a>`, `BlockInfo`, `Settings`) so the strategy is wit-independent. Tests live here under `#[cfg(test)]` against `MockHost`. -- `lib.rs` - per-cdylib glue. `wit_bindgen::generate!`, the `WitBindgenHost` struct implementing all four traits with `chain::request` / `local_store::*` / `cow_api::submit_order` / `logging::log` calls, the `convert_err` + `sdk_err_into_wit` + `convert_level` helpers, and the `Guest` impl that destructures `types::Event` and delegates to `strategy`. - -Reference implementations: `modules/examples/price-alert/`, `modules/examples/stop-loss/`, `modules/twap-monitor/`, `modules/ethflow-watcher/`. The wit-bindgen adapter is intentionally mechanical and is a candidate for a future declarative macro in `shepherd-sdk` (the `#[nexum::module]` design in doc 05). - -## Considered options - -- **Single fat `Host` trait.** Rejected: pulls every module's tests into mocking the full surface even when the strategy only touches one or two capabilities. The four-trait split lets tests `respond_to` exactly the calls the strategy makes. -- **`#[nexum::module]` proc macro now.** Rejected for M3 scope. The proc macro is the right shape long-term (see doc 05) but adds a macro crate, parsing logic, and a debugging surface we did not need to ship M2 modules with MockHost coverage. The manual adapter is verbose but understandable in one read; we land the macro as M5 work. -- **Re-export wit-bindgen `HostError` from the SDK.** Rejected: the wit-bindgen types are per-cdylib. Re-exporting one module's `HostError` would break all others. A shared SDK struct with field-equivalent shape and module-local `From` impls is the only way the SDK stays world-neutral. -- **Strategy lives in `lib.rs` next to the wit-bindgen adapter.** Rejected after the price-alert refactor showed the dispatch matrix was not unit-testable without MockHost, and the twap-monitor / ethflow-watcher ports confirmed the split. The wit-bindgen adapter is ~150 lines of mechanical glue; the strategy is hundreds of lines of logic - colocating them obscures both. +1. **Four per-capability traits (`ChainHost`, `LocalStoreHost`, `CowApiHost`, `LoggingHost`) with a blanket-implemented supertrait `Host`.** Strategy code takes `&impl Host` and calls any interface uniformly; tests inject `nexum_sdk_test::MockHost`, production injects `WitBindgenHost`. The four-trait split lets a test mock only the calls its strategy makes. +2. **An SDK-side error type mirroring the WIT struct field-for-field**, its own type (the WIT-generated one is per-cdylib), bridged by a one-line `From` in each module's `lib.rs`. This keeps the traits world-neutral so `nexum-sdk-test` needs no wasm toolchain. Superseded by ADR-0011's typed errors. +3. **Per-module `strategy.rs` (pure, wit-independent logic, unit tests against `MockHost`) plus `lib.rs` (per-cdylib `wit_bindgen::generate!` glue, the `WitBindgenHost` impl, and the `Guest` dispatch).** Colocating hundreds of lines of strategy with the mechanical adapter obscures both. ## Consequences -- **Strategy code is testable in native Rust** without `wasm32-wasip2`. Every shepherd-side module ships a unit-test suite that exercises this seam via `MockHost`; CI is the authoritative count. -- **The `WitBindgenHost` adapter is duplicated across modules.** ~150 lines of identical glue (the four trait impls plus the two converters and `convert_level`). Acceptable today; the M5 `#[nexum::module]` macro is the path to eliminate it. -- **`shepherd-sdk-test` does not need wit-bindgen.** It depends only on `shepherd-sdk` and `std`; no wasm toolchain involved. Tests compile and run as plain Rust. -- **`HostError` round-trips lossily at the WIT boundary.** The wit-bindgen and SDK types have identical fields today; if either evolves (new variant on `HostErrorKind`, new field), modules need a one-line `From` update. **Applied in M4**: `HostErrorKind` and `LogLevel` are `#[non_exhaustive]`; each module's `sdk_err_into_wit` and `convert_level` adapter carries a wildcard arm mapping unknown SDK-side variants to `HostErrorKind::Internal` / `Level::Info` respectively. `RetryAction` and `PollOutcome` stay exhaustive (domain-locked to the cow-rs `OrderPostErrorKind::is_retriable` and `IConditionalOrder` Solidity interfaces). -- **The four-trait split is not an interface contract with mfw78's WIT.** WIT defines the wire shape; the SDK traits are a Rust-side ergonomics layer. The two evolve together but are not the same artifact. -- **Future capabilities (e.g. `messaging`, `remote-store`, `http`) add new traits.** Each new host interface becomes a new trait + new `MockX` in `shepherd-sdk-test`, and the supertrait `Host` is bumped to bound on the new trait. Modules that do not use the new capability are unaffected (they only need `` etc. on the subset they actually touch - the supertrait is a convenience for full-surface modules, not a hard requirement). - -## Capability enforcement vs. the WIT world (load-bearing assumption) - -`enforce_capabilities` (in `crates/nexum-engine/src/manifest/capabilities.rs`) checks the loaded component's *actual* import set against the manifest's `[capabilities].required + [capabilities].optional`. A component that imports a `nexum:host/` or `shepherd:cow/` whose `` is a known capability NOT in either list fails to boot with `CapabilityViolation`. - -This interacts with `wit_bindgen::generate!` in a way worth pinning here, because the example modules and the production modules use different strategies: - -| Module | WIT world | `generate!` mode | Capabilities the manifest declares | -|---|---|---|---| -| twap-monitor | `shepherd:cow/shepherd` (supertype) | `generate_all` | logging, local-store, chain, cow-api | -| ethflow-watcher | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, cow-api (chain optional - PR #55 review) | -| stop-loss | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain, cow-api | -| price-alert | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain (no cow-api) | -| balance-tracker | `nexum:host/event-module` | `generate_all` | logging, local-store, chain | - -`price-alert` and `balance-tracker` compile against worlds that import `shepherd:cow/cow-api`, but their manifests do not declare it. Boot succeeds today because the `wasm-tools` / `wit-component` pipeline elides any WIT import the produced `.wasm` does not actually exercise from the component's import section. `enforce_capabilities` then sees the trimmed set and finds nothing missing. - -**The elision is load-bearing**, not a manifest convenience: if a future toolchain bump changes the elision behaviour (or if a module starts importing a capability transitively without declaring it), modules that worked before suddenly fail capability enforcement at boot. - -**Mitigation today**: rely on the elision and treat the assumption as part of the supported build pipeline. Both wasm-tools 1.x and wasmtime 41-45 elide unreferenced imports for our build profile; CI exercises this implicitly on every `cargo build --target wasm32-wasip2`. - -**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. - -**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._ +- Strategy code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. +- New capabilities add a new trait plus a `MockX` in `nexum-sdk-test`; modules that do not use a capability bound only on the subset they touch. +- The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. diff --git a/docs/adr/0011-per-interface-typed-errors.md b/docs/adr/0011-per-interface-typed-errors.md index 662e2445..26a179c3 100644 --- a/docs/adr/0011-per-interface-typed-errors.md +++ b/docs/adr/0011-per-interface-typed-errors.md @@ -6,26 +6,20 @@ status: accepted ## Context -The host once returned one unified envelope, `host-error`, from every imported function and every module export: a record of `domain` (a stringly subsystem tag), a `host-error-kind` enum, a numeric `code` (a JSON-RPC code or an HTTP status, depending on the caller), a `message`, and an optional opaque `data` blob. Dispatch on the failure cause meant reading `kind`, sometimes cross-checking `domain` and `code`. Modules re-named their own domain in every error they built and prefixed each message with the module name, duplicating context the runtime already had (the module name, the interface). - -The envelope conflated two things that want to move independently: the shared cross-domain failure vocabulary (unavailable, timeout, denied, ...) and the per-interface structured detail (a JSON-RPC revert carries a node code and decoded revert bytes; an orderbook rejection carries a typed `{errorType, description}`). Squeezing both through one flat record meant every interface paid for fields it did not use and lost the fields it did. +The host once returned one flat envelope (`host-error`: a stringly `domain`, a `host-error-kind` enum, a numeric `code`, a message, an optional `data` blob) from every function and export. It conflated the shared cross-domain failure vocabulary (unavailable, timeout, denied) with per-interface structured detail (a JSON-RPC revert's node code and decoded bytes, an orderbook rejection's typed `{errorType, description}`). Every interface paid for fields it did not use and lost the fields it did, and modules restated their own identity in every error they built. ## Decision -Adopt the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. - -`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. 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`. Interfaces with nothing to add report `fault` directly (local-store, and now the module exports). +Follow the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. -The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business: it holds the module name and does not need each fault to re-declare it, so the `domain` self-naming and the message-prefix duplication in modules are gone. The supervisor derives its error metric label and structured-log `kind` field from the fault case (via the `HostFault` label), which drops a `format!("{:?}")` allocation per erroring dispatch. +`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. A richer interface embeds `fault` as one case of its own variant and adds only the cases it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. Interfaces with nothing to add report `fault` directly. -`host-error` and `host-error-kind` are deleted from `types.wit` and from every mirror: the runtime host constructors, both guest SDKs, both SDK test crates, and the module glue. The SDK exposes `Fault` (mirroring the wire vocabulary) plus the `HostFault` trait that recovers an embedded fault and a stable snake_case label; `From for Fault` folds a chain error into the shared vocabulary so a strategy aggregating store and chain calls returns one `Fault`. +The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business, so the `domain` self-naming and message-prefix duplication are gone; the supervisor derives its metric label and log `kind` from the fault case via the `HostFault` label. `host-error` and `host-error-kind` are deleted from `types.wit` and every mirror. The SDK exposes `Fault` plus the `HostFault` trait, with `From for Fault` folding a chain error into the shared vocabulary. -This is a pre-1.0 wire break. CI rebuilds every module wasm on a world change, so no compatibility shim is warranted. +This is a pre-1.0 wire break; CI rebuilds every module wasm on a world change, so no shim is warranted. ## Consequences - A caller dispatches on the structured cause by matching the typed variant, with no stringly `domain`/`code` cross-check. -- Interfaces carry exactly the detail they have; the shared cases stay uniform across interfaces and yield one stable label vocabulary for metrics and logs. -- Modules no longer restate their identity or prefix messages; the runtime supplies both. -- The numeric `code` and opaque `data` fields are gone. An interface that needs structured detail (the JSON-RPC code, decoded revert bytes) carries it in a typed case instead. -- Old bindings do not interoperate with the new world. Because this predates 1.0 and CI rebuilds all module wasms per world change, that break is accepted rather than shimmed. +- The shared cases yield one stable label vocabulary for metrics and logs; interfaces carry exactly the detail they have. +- The numeric `code` and opaque `data` fields are gone; structured detail lives in a typed case instead. diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 63764ab3..49fa4ea9 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -1,47 +1,34 @@ --- -status: proposed +status: accepted --- # ComposableCoW poll is a structured non-reverting verdict; the module is a generic handler-agnostic monitor ## Context -ADR-0006 decided that TWAP and EthFlow run as guest modules over low-level host primitives, with the host protocol-neutral and no specialised `shepherd:cow/twap` interface. That decision stands. What ADR-0006 baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])`, decoding the return *or the revert reason* with `alloy_sol_types`, mapping the revert selectors to a rich off-chain `PollOutcome`, and computing its own poll schedule (the TWAP epoch gates). ADR-0006 itself anticipated this change: "A newer ComposableCoW iteration in development may simplify the polling surface significantly." +ADR-0006 kept TWAP and EthFlow as guest modules over low-level host primitives, with the host protocol-neutral. What it baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `getTradeableOrderWithSignature`, decoding the return or the revert reason, mapping revert selectors to an off-chain `PollOutcome`, and computing its own schedule. -That iteration exists. The nullisLabs composable-cow fork (`/code/nullisLabs/composable-cow`, `docs/architecture.md`) splits settlement from polling and makes the polling path structured and non-reverting. `getTradeableOrderWithSignature()` and `checkOrder()` now return a `PollResult { GeneratorResult generator; FillStatus fill; uint filledAmount; Restriction restriction }`. The handler's `poll()` (via `BaseConditionalOrder.poll()`) wraps `generateOrder()` in a try/catch on-chain and returns a `GeneratorResult { code: POST | WAIT_TIMESTAMP | WAIT_BLOCK | TRY_NEXT_BLOCK | INVALID | NEEDS_INPUT; order; nextPollTimestamp; waitUntil; bytes4 reasonCode }`. The revert-decode moved on-chain; the schedule is supplied by the contract (`nextPollTimestamp` sentinels: `0` = poll at `validTo + 1`, `type(uint256).max` = stop); the fill and restriction overlays are composed by the registry, orthogonal to the verdict. - -The consequence is that a monitor no longer needs any handler-specific off-chain logic. TWAP's epoch arithmetic is on-chain in `getNextPollTimestamp()`; the off-chain side reads a struct field. One generic poller drives every handler (TWAP, StopLoss, GoodAfterTime, PerpetualStableSwap, TradeAboveThreshold): none of shepherd's flagship roster emits `PollNeedsOffchainInput`, so none needs an order-module sandbox. - -The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/networks.json` is `networks: {}`, and every chain shepherd targets has only the upstream reverting `ComposableCoW`. The poll wire cannot retarget to a contract that is not on-chain. +The nullisLabs composable-cow fork splits settlement from polling and makes the poll path structured and non-reverting: `getTradeableOrderWithSignature` and `checkOrder` return a `PollResult` whose `GeneratorResult` carries a `code` (`POST` | `WAIT_TIMESTAMP` | `WAIT_BLOCK` | `TRY_NEXT_BLOCK` | `INVALID` | `NEEDS_INPUT`), the order, schedule sentinels, and a `bytes4 reasonCode`. The revert-decode and schedule move on-chain, so a monitor needs no handler-specific off-chain logic and one generic poller drives every handler. ## Decision -The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. - -The chassis (ADR-0009) supplies the mechanism: the watch-set and journal stores are unchanged; the two-gate store (`next_block:` / `next_epoch:`) now holds the contract-supplied `waitUntil` / `nextPollTimestamp` slots, so its value source moves from off-chain revert-decode to the contract verdict while its shape is unchanged. The `ConditionalSource` seam returns a structured `Verdict` mirroring `GeneratorResultCode` plus the hints; it does not decode or schedule. - -Two classification concerns stay separate: the poll verdict comes from the contract (`GeneratorResultCode` + `bytes4 reasonCode`, log only), while the CoW orderbook-API submit-error `errorType` table (the REST POST `/api/v1/orders` response) is a distinct data-driven concern that the module keeps. +The CoW module is a generic ComposableCoW monitor: it indexes `ConditionalOrderCreated`, polls each authorised order, and switches on the poll verdict alone (`POST` submits through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` parks). It decodes no revert selectors, computes no schedule, and holds no per-handler branch. -Migration is HYBRID and gated on deployment: +Migration is hybrid and gated on deployment: -- Migrate the Rust verdict seam now (zero contract dependency): the `ConditionalSource::Outcome` becomes the structured `Verdict`, and the sweep dispatches on it. -- Quarantine the deployed 1.x reverting poll behind a named `LegacyRevertAdapter` that maps the five upstream selectors to the `Verdict` (`PollTryAtEpoch` to `WAIT_TIMESTAMP`, `PollTryNextBlock` to `TRY_NEXT_BLOCK`, `PollNever` / `OrderNotValid` to `INVALID`). The module posts through the target seam against the deployed contract, so the grant demo stays green. -- When the fork's `deployments/networks.json` is non-empty on a shepherd target chain, replace the adapter with a direct `PollResult` struct-read, delete the adapter and `shepherd-sdk/src/cow/composable.rs`, and regenerate golden vectors against `bytes4 reasonCode` and `PollResult`. +- The Rust verdict seam migrates now (zero contract dependency): the poll resolves to a structured `Verdict` and the sweep dispatches on it. +- The deployed 1.x reverting poll is quarantined behind a `LegacyRevertAdapter` that maps each upstream selector onto a `Verdict`, so the module posts through the target seam against the deployed contract. +- When the fork's `deployments/networks.json` is non-empty on a target chain, the adapter is replaced by a direct `PollResult` struct-read and golden vectors regenerate against `reasonCode` / `PollResult`. -Merge gate: the poll retarget (`shepherd-sdk/src/cow/composable.rs`, `modules/*/src/strategy.rs`, and the acceptance of this ADR over the reverting model) must not merge until `deployments/networks.json` is non-empty on a shepherd target chain. +The orderbook-API submit-error `errorType` table (the REST POST response) is a separate data-driven concern the module keeps; it is not the poll contract. -## Considered options +## Current state -- **Redirect now: retarget the poll to the fork's structured surface immediately.** Rejected. The fork is deployed on no chain shepherd targets; every `eth_call` would revert selector-not-found, and shepherd cannot deploy a third party's registry on its own clock. This regresses grant M2 from "posts orders on testnet" to "compiles against an undeployed ABI." -- **Land-then-migrate: merge the M1 train on the old model, migrate wholesale after the grant.** Rejected as the default because it ships a public verdict seam (`PollOutcome`) that is immediately broken by the target, forcing a re-port of the consumer. The seam type carries no contract dependency, so it is migrated now for free. -- **Keep the off-chain revert-decode as the permanent model.** Rejected. It duplicates on-chain the decode the fork does once, per-handler, and it is the source of the handler-specific off-chain logic this ADR removes. +The seam migration has shipped: `crates/composable-cow` exports the structured `Verdict`, the `run` sweep dispatches on it (`crates/composable-cow/src/run.rs`), and the deployed reverting wire is served by `LegacyRevertAdapter`. The fork is deployed on no target chain, so `LegacyRevertAdapter` is the live poll path; the structured non-reverting `PollResult` wire is not yet exercised in production. The wire-swap remains gated on the fork deploying. ## Consequences -- `shepherd-sdk/src/cow/composable.rs` (the five-selector `sol!` mirror, `decode_revert`, `classify_poll_error`, the `PollOutcome` enum) is deleted at the wire-swap; its replacement is a struct-read, not a re-port. Until then it survives only as the `LegacyRevertAdapter`'s guts. -- The `twap-monitor` module generalises into `ccow-monitor`; the TWAP-specific poll body (`poll_one`, `decode_return`, `classify_poll_error`, the module-side `TryAtEpoch` / `TryOnBlock` gates) is removed. Existing MockHost tests remain the behaviour-identity proof through the seam migration. -- The chassis two-gate store is re-documented: `next_block:` and `next_epoch:` are contract-supplied `waitUntil` and `nextPollTimestamp` slots, not off-chain epoch math. -- New capabilities land as follow-ons and are the only places a per-handler off-chain concern reappears: `IOrderModule` for `NEEDS_INPUT` offchainInput acquisition, `IOrderManifest` enumeration with `offchainInput = abi.encode(index)` fan-out, and merkle-payload discovery. None is needed for the flagship roster. -- The rename set threads through the module and its vectors: `getTradeableOrder` to `generateOrder`; `PollTryAtEpoch(uint256, string)` to `PollTryAtTimestamp(uint256, bytes4)`; `PollNever` removed in favour of `OrderNotValid`; `getTradeableOrderWithSignature` returns `PollResult`, not `(order, bytes)`; string reason payloads become `bytes4 reasonCode`; the poll no longer reverts for order conditions. -- `docs/diagrams/sequence-twap.mmd` (already flagged in ADR-0006's consequences) is updated to show the structured poll: one `checkOrder` call returning a verdict and hints, no revert path. -- ADR-0006's host-neutrality decision is unchanged. This ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint()` remains the orderbook-submit contract; it was never the poll contract. +- The five-selector revert mirror survives only as the `LegacyRevertAdapter` internals; at the wire-swap it is replaced by a struct-read, not re-ported. +- The two-gate store holds contract-supplied `waitUntil` / `nextPollTimestamp` slots rather than off-chain epoch math. +- `NEEDS_INPUT` offchainInput acquisition, manifest enumeration, and merkle-payload discovery land as follow-ons; none is needed by the current module roster. +- ADR-0006's host-neutrality decision is unchanged; this ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint` remains the orderbook-submit contract, never the poll contract.