feat(runtime): route nexum:intent/pool to installed venue adapters#250
Conversation
37f8439 to
c4e57ac
Compare
be70b63 to
0a3398a
Compare
c4e57ac to
b13657b
Compare
0a3398a to
6f2bdbb
Compare
b13657b to
440b73a
Compare
6f2bdbb to
6806824
Compare
440b73a to
194e8f1
Compare
6806824 to
0e4c79b
Compare
194e8f1 to
6fb3e4d
Compare
0e4c79b to
233e9fc
Compare
6fb3e4d to
f547ca7
Compare
233e9fc to
7593566
Compare
f547ca7 to
caab1dd
Compare
ce4648a to
d074cf6
Compare
b4cdd00 to
2d5c02a
Compare
d074cf6 to
de679c3
Compare
2d5c02a to
a2fe188
Compare
de679c3 to
9de7450
Compare
a2fe188 to
378311c
Compare
9de7450 to
eaebfaf
Compare
378311c to
c3f32b2
Compare
eaebfaf to
aa15304
Compare
c3f32b2 to
e551d21
Compare
aa15304 to
9b7a402
Compare
e551d21 to
2f23d71
Compare
9b7a402 to
f1f8871
Compare
2f23d71 to
a2b95dd
Compare
f1f8871 to
349d31b
Compare
e1ad254 to
560688d
Compare
cf20816 to
b61ed02
Compare
560688d to
e19251f
Compare
b61ed02 to
083a41e
Compare
e19251f to
d2fd2b1
Compare
083a41e to
0eee5c3
Compare
d2fd2b1 to
be53811
Compare
0eee5c3 to
e5c2d0c
Compare
be53811 to
2cf2cf9
Compare
e5c2d0c to
67a033b
Compare
2cf2cf9 to
24bc04f
Compare
67a033b to
058c3b6
Compare
24bc04f to
77688fc
Compare
058c3b6 to
9b5e208
Compare
77688fc to
aa0304e
Compare
9b5e208 to
5b0c585
Compare
aa0304e to
6db2785
Compare
5b0c585 to
e4c6020
Compare
f757151 to
ca3b167
Compare
lgahdl
left a comment
There was a problem hiding this comment.
Well-structured router with good test coverage. Three items below.
| if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { | ||
| return Err(VenueError::Denied(reason)); | ||
| } |
There was a problem hiding this comment.
Guard-deny doesn't charge the caller, so a module that the guard blocks can call submit in a tight loop indefinitely. Each iteration passes quota_admits, acquires the adapter mutex, runs derive_header (a full WASM guest call), and then the guard denies — but quota is never consumed. The adapter stays occupied for the duration of each derive call, blocking every other caller to the same venue.
The existing decode_failures_are_charged_and_stop_re_invoking_the_adapter test shows the pattern; the guard-deny path needs the same treatment:
| if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { | |
| return Err(VenueError::Denied(reason)); | |
| } | |
| if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { | |
| self.charge(caller); | |
| return Err(VenueError::Denied(reason)); | |
| } |
| /// 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; |
There was a problem hiding this comment.
check is synchronous, but the egress-guard epic's real policy will need async calls — oracle price lookups, on-chain allowlist reads, facts-provider queries. Making the trait async now is a cheap one-line change; making it async after the egress-guard epic is underway is a breaking change under delivery pressure.
| fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; | |
| fn check<'a>(&'a self, ctx: &'a GuardContext<'_>) -> futures::future::BoxFuture<'a, GuardVerdict>; |
AllowAllGuard::check becomes Box::pin(async move { GuardVerdict::Allow }), and the call site awaits it.
| /// Whether `caller` has budget left in the current window. Read-only: it | ||
| /// prunes aged charges but does not record one. |
There was a problem hiding this comment.
"Read-only" is inaccurate — the function mutates the ledger: entry(...).or_default() inserts a new VecDeque for first-time callers, and prune() removes expired entries. The parenthetical even admits to pruning.
| /// Whether `caller` has budget left in the current window. Read-only: it | |
| /// prunes aged charges but does not record one. | |
| /// Whether `caller` has budget left in the current window. Does not | |
| /// record a charge — prunes expired entries to get an accurate count. |
76faaec to
24bf6f3
Compare
ca3b167 to
2ef07d6
Compare
24bf6f3 to
892cd0c
Compare
2ef07d6 to
52775b4
Compare
892cd0c to
1f3c3f5
Compare
52775b4 to
1d9b27a
Compare
1f3c3f5 to
a0d9541
Compare
1d9b27a to
aaaa9e1
Compare
Implement the strategy-facing pool import as a host binding linked into every module linker, dispatching to a shared router that owns the installed adapters. - Bindings: a pool-host bindgen world importing nexum:intent/pool, reusing the intent and value-flow types from the venue-adapter bindings via `with`, so the outcome and error the router hands back to a module are the very types an adapter's submit produced. - Router: resolve a venue id to its adapter, serialise invocation per adapter behind an async mutex (the wasmtime Store is not Sync), sequence derive-header, a no-op guard interposition seam, then submit. Status and cancel pass through without the header, guard, or quota. - Quota: a per-caller sliding-window submission budget, with adapter decode failures charged to the caller so a module feeding garbage bodies exhausts its own budget rather than the adapter's fuel. - Supervisor: adapters instantiate first and install into the router; modules then boot carrying the shared handle, rebuilt identically on restart. An init-failed adapter is loaded but not routable. - Manifest: register nexum:intent/pool as a declarable module capability. - Config: a [limits.quota] section resolving the per-caller budget.
What
Adds the host-side implementation of
nexum:intent/pool, the strategy-facing import that routes a module'spool::submit(venue, body)call to the one installed venue adapter for that venue and drives a fixed sequence against it: quota gate, derive-header, guard interposition seam, submit.host/pool_router.rs: thePoolRouter. Resolves a venue id to an installed adapter (unknown venue maps tovenue-error::unknown-venue), serialises calls per adapter via atokioasync mutex held across the guest await (one venue's calls queue, different venues run in parallel; a trap projects tointernal-errorinstead of unwinding into the caller's store), runs aGuardPolicyinterposition seam on the adapter-derived header betweenderive-headerandsubmit(the shippedAllowAllGuardis a no-op;PoolRouterBuilder::with_guardis the seam the egress-guard work hooks into), and enforces a per-caller submission quota (PoolQuota, default 256 charges / 60s sliding window) that also charges decode failures, so a caller feeding garbage exhausts its own budget rather than the adapter's.host/impls/pool.rs,host/impls/mod.rs,host/mod.rs: wirepoolin as a host binding linked into every module linker, delegating to a shared, cheaply cloneablePoolRoutercarried in each store'sHostState(host/state.rs).bindings.rs,engine_config.rs: bindgen thepool_hostworld, reusingvenue_adapter's type modules viawithso there is no lift between two structurally identical copies ofSubmitOutcome/VenueError(no new or modified WIT packages).manifest/capabilities.rs: wire the pool-related capability.supervisor.rs: rework around the new router plumbing.Why
The pool world is the strategy-facing entry point for submitting intents; without a router bound host-side a module has no way to reach an installed venue adapter. Centralising venue resolution, serialisation, the guard seam and the quota in one router keeps that policy off individual adapters and gives the egress-guard work a single seam (
with_guard) to hook into later.Testing
cargo fmt --all -- --checkcargo clippy -p nexum-runtime --all-targets -- -D warnings(aftercargo clean -p nexum-runtime)cargo test -p nexum-runtime(229 tests pass; the concurrency/serialisation determinism test reran deterministically 5x)cargo test --workspace(onlynexum-runtimetouched; no wasm modules or WIT packages changed, so no wasm build/clippy/bindgen-smoke gates apply)AI Assistance
Claude (Opus) used for the implementation.
Closes #229