Skip to content

runtime: make the module event type extensible via a generic custom channel#520

Open
mfw78 wants to merge 43 commits into
dev/m1from
feat/m3-extensible-event-abi
Open

runtime: make the module event type extensible via a generic custom channel#520
mfw78 wants to merge 43 commits into
dev/m1from
feat/m3-extensible-event-abi

Conversation

@mfw78

@mfw78 mfw78 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Makes the runtime's module event type extensible, and makes the venue-agnostic boundary true in both WIT and Rust.

  1. Core nexum:host WIT (wit/nexum-host/types.wit): the event variant drops intent-status(intent-status-update) and gains a generic custom(custom-event { kind, payload }). The core now carries only block / chain-logs / tick / message + custom, with no venue / receipt / intent-status vocabulary. The intent-status payload contract lives in the videre-status-body Rust type; the payload rides as opaque bytes, so no WIT record is needed for it.
  2. The shared status-body codec, which is entirely venue-domain (IntentStatus, StatusBody, FailReason, and the IntentStatusUpdate { venue, receipt, status } envelope), is renamed nexum-status-body -> videre-status-body and rehomed to the videre (L2) layer. The generic guest SDK nexum-sdk no longer depends on or re-exports it; the guest-facing surface moves to videre-sdk (videre_sdk::status_body, videre_sdk::event::intent_status_update). Venue guests reach the codec through videre-sdk; the #[keeper] macro already routed through it. Strict-up is preserved: no nexum-* crate depends on any videre-* crate.
  3. scripts/check-venue-agnostic.sh gains two gates: nexum:host carries no venue-domain vocabulary (WIT), and the generic guest SDK nexum-sdk reaches no videre/venue crate and carries no venue status-codec vocabulary (Rust). Both were bite-tested (inject a leak, confirm the gate fails).
  4. Housekeeping: the example module's on_custom logs the raw custom event generically (it never submits an intent), dropping its videre-sdk dependency so a generic example stays venue-free; echo-client keeps its decode since it is a venue module (it submits through videre:venue/client).

Closes #518.

Stacked on feat/m3-dx-polish-cluster (top of M3); injects as an M3 car that rides the ripple into dev/m1.

Why

The core event type was a closed WIT variant that hard-coded intent-status with venue / receipt fields, so a new extension could not add its own event kind without editing the core, and the venue-agnostic L1 leaked at the event type. A generic custom(kind, payload) channel makes the seam extensible and the core venue-free. The Rust status codec carried the same leak one layer down: a nexum-branded crate full of venue vocabulary, re-exported by the generic guest SDK, invisible to the gate. The extraction is only complete once that codec lives in the videre layer and the gate covers the guest SDK.

Testing

All under nix develop (rustc 1.94), independently re-verified against the pushed head:

  • cargo fmt --check and cargo clippy --workspace --all-targets --all-features -- -D warnings: clean.
  • Tests across videre-status-body / videre-sdk / videre-host / nexum-sdk / nexum-runtime: all pass, including the two intent-status E2E round-trip tests (e2e_intent_status_subscription_receives_polled_transitions, e2e_intent_status_flows_through_the_event_loop), which prove the borsh envelope round-trips venue + receipt + status through the wasm guest decode.
  • wasm32-wasip2 guests rebuilt (both the #[module] on_custom and #[keeper] on_intent_status paths compile against the regenerated event enum).
  • check-venue-agnostic.sh: 11/11, including the two new checks, both bite-tested.

One incidental edit: a pre-existing ambiguous [`keeper`] intra-doc link in videre-sdk/src/lib.rs (which fails cargo doc -D warnings on the base) is disambiguated so this PR is not red on rustdoc.

AI Assistance

Design and implementation assisted by Claude: opus for implement, red-team, and gate; sonnet for the PR.

mfw78 added 30 commits July 16, 2026 14:02
Drop the nexum:intent use from nexum:host/types: the intent-status
event now carries an opaque versioned status body and an opaque
receipt, so nexum:host is a leaf package. The core bindgen resolves
against wit/nexum-host alone, module worlds pull the intent packages
only with the pool capability, and the intent vocabulary generates in
the adapter bindgen.

The body codec lives in the new nexum-status-body crate: a leading u8
version tag, then the version's borsh payload (v1: lifecycle status,
optional settlement proof, optional fail reason). Unknown tags fail
closed, a body is never empty, and goldens pin the wire form. The pool
router lowers an adapter-reported status through it (settled is
fulfilled plus proof, failed is cancelled plus reason) and keepers
decode via nexum_sdk::status_body.
Rename the pre-release intent packages into the videre namespace and
reshape them to the pinned 0.1.0 surface: nexum:value-flow becomes
videre:value-flow, nexum:intent splits into videre:types and the
two-faced videre:venue (worker client, provider adapter), and
nexum:adapter retires with its venue-adapter world folded into
videre:venue. nexum:host and shepherd:cow are untouched.

The 0.1 shapes are EVM-only and thin: single-asset gives/wants,
auth-scheme {eip1271, eip712}, settlement {chain}, uint amounts
(big-endian, minimal-length), asset {native, erc20{token}}, plain-enum
intent-status, the seven-case venue-error with rate-limit, unsigned-tx
{chain, to, value, data}, and the quotation record. Dropped cases
return as additive 0.2+ variants; quote functions land separately.

Rust follows the wire: PoolRouter is VenueRegistry, AdapterActor is
VenueActor, GuardPolicy/AllowAllGuard collapse into EgressGuard (the
unit guard allows every egress), IntentPool is VenueClient, PoolQuota
is SubmitQuota, and a VenueId newtype keys the registry. Quota
exhaustion now reports rate-limited with the window; adapter traps
project onto unavailable. The module capability is client; goldens
and the conformance mirrors follow the single-asset header.
Reset nexum:host and shepherd:cow package and use versions to 0.1.0,
the shared baseline every package now semvers from independently.
wasi:* keeps its own 0.2.x. Drop the 0.1-to-0.2 migration guide and
its references.

Closes #371
A local command (scripts/check-venue-agnostic.sh, just check-venue-agnostic)
and a non-blocking CI job assert nexum-runtime is venue-agnostic: its crate
graph reaches no videre/intent/venue/cow crate, its sources carry no venue
symbol, and nexum:host resolves as a leaf WIT package. The symbol scan is
red by design until the physical host cut lands; the flip to a blocking
gate is tracked for M2.
A deny verdict at the venue-registry guard seam now logs a would-deny
and lets the submission proceed. The checkpoint does not enforce until
the egress-guard epic installs the real policy pipeline; the seam docs
and the venue-adapter docs say so.
The submission charge moves ahead of the guard verdict at the venue
registry, so a denied egress spends one unit exactly as an accepted
submit does: a module spamming denied egress exhausts its own budget
instead of looping free once the guard turns enforcing. A regression
test pins the rate limit on a repeated-deny loop.
Fixture files lead with a format version; a reader fails closed on an
unknown tag and refuses an empty vector or golden set, which would
conform vacuously. Published reference fixtures regenerated.
The registry's watch list grew without bound and was polled O(N) per
cadence. Each watch now carries an eviction deadline and the set carries
a cap, both operator-configurable via [limits.watch]; expired entries
evict unpolled, at the cap a new watch is refused and logged rather
than a live watch dropped, and every successful non-terminal poll
pushes the deadline a full window out, so only a venue silent for the
whole window expires.
The Extension seam becomes a trait contributing a namespace, a
capability namespace, a linker hook, an optional HostService, and an
optional ProviderKind. HostService is a sync, type-erased service held
on a typed per-namespace HostState.services map built once at boot;
ProviderKind carries a provider linker hook plus the one cold dyn
async_trait install path. The worker boot path is unchanged.
…e known table

The capability table and per-module world synthesis move into a new
plain nexum-world library carrying only the core nexum:host rows.
Per-namespace rows come from the composition root's extensions.toml
registry, parsed and passed in by the macro layer, so no host crate
carries a downstream name; synthesis rejects a row that shadows a core
capability or another registration. WIT packages resolve crate-locally
(wit/deps, then wit/) with an ancestor fallback for the transitional
monorepo layout.
ComponentsBuilder grows a fourth logs slot, defaulting to the new
LogPipelineBuilder (the in-memory pipeline sized from [limits.logs]);
with_logs substitutes a custom builder. The Runtime preset names the
slot as LogsBuilder and the launcher cars carry the parameter through.
… the privileged field

The client face resolves the registry from the store's service map
under the videre namespace; no service means every call resolves to
unknown-venue. The boot path seeds the registry into the map until the
videre extension takes it over, provider stores carry an empty map so
the registry never cycles back into a store it owns, and the supervisor
field is gone.
A trap in a routed adapter call marks a shared Liveness dead; the
dispatch-time sweeps restart the provider after the module backoff
policy and quarantine a crash-looper per the poison policy. A dead
venue resolves to unavailable, distinct from unknown-venue, and
adapter_alive_count reports live routability. The flaky-venue fixture
drives the trap-to-recovery and poison paths end to end.
nexum-launch owns the shared CLI, config load, tracing init, and the
preset-driven run; nexum-cli becomes the bare Ext=() engine binary over
CoreRuntime with no cow edge; the cow composition root moves to a new
shepherd binary wiring the cow-api extension as a Runtime preset. The
venue-agnostic guard's crate-graph check now covers the launcher and
the bare binary, and the cow deployment tooling builds shepherd.
…ough the seam

Relocate the venue-adapter and client bindgens, the venue registry, the
advisory egress guard, and the venue-adapter provider kind out of
nexum-runtime into a new videre-host extension crate, registered whole
via videre_host::platform(). The runtime grows two generic seams in
exchange: extension-declared subscription kinds with attribute-filtered
extension events through the event loop, and config-derived preset
extensions. nexum-runtime now carries zero venue, intent, or cow
symbols and the venue-agnostic check passes.
Scan the runtime sources for the charter set only (nexum:intent,
value-flow, VenueAdapter, synthesize_venue, nexum:adapter, PoolRouter),
dropping the loose word-shape sweep that false-flagged the opaque
extension-map fixtures; keep the crate-graph and WIT-leaf checks, and
state the invariant in the nexum-runtime crate charter. The CI job
stays advisory until the physical repo cut.
Drop continue-on-error from the venue-agnostic job, add privileged-field
and host-WIT namespace scans to the zero-leak script, and pin the
invariant with two integration tests: the echo venue boots and routes a
worker's submission purely through the generic extension seam, and the
nexum-runtime crate graph names no venue-shaped crate. A missing wasm
fixture hard-fails the boot oracle under CI so the gate cannot skip
itself.
The derive reaches Vec and ToString through the venue SDK's __private
alloc re-export instead of ::std, so a #![no_std] consumer needs no
extern crate alloc. cow-venue flips to no_std (tests aside) and a
compile-only no-std-probe crate keeps the contract under the workspace
gate.
Replace the convert_fault / sdk_fault_into_wit / convert_level shim
functions with From impls emitted by the macro (orphan-legal in the
per-cdylib expansion), so module glue converts via ? and Into. No
behaviour change.
Add HostTransport, an alloy Transport dispatching JSON-RPC packets
through ChainHost::request, and host.provider(chain) minting a
RootProvider over it, driven by a single-poll block_on. Methods
outside the typed ChainMethod read surface (mirrored guest-side)
fail as -32601 before reaching the host; node errors carry code,
message and decoded revert bytes; host faults surface as typed
transport errors. Chain and ChainId newtypes replace bare u64 ids
at the SDK edge. The hoisted alloy-provider entry drops its native
transport features so the wasm guest build stays transport-free;
the engine and load-gen re-add theirs at the call site.
mfw78 added 8 commits July 17, 2026 06:41
…d venue client

Rename nexum-venue-sdk to videre-sdk across the workspace. Add the
generic Keeper::sweep assembler (WatchSet to Gates to poll to Retrier
to Journal) over a shared Sweep outcome resolving the dangling
ConditionalSource::Outcome; the world-neutral primitives stay in
nexum-sdk. Thread a VenueId newtype through the client seam, add the
owned VenueFault mirror (retry-after-ms preserved) behind ClientError,
mark the client-facing enums non-exhaustive, and seal IntentBody to
its derive.
#[module] stays L1 in nexum-module-macros; #[venue] and derive(IntentBody)
move to videre-macros with the venue-world synthesis. nexum-sdk re-exports
module from nexum-module-macros; videre-sdk re-exports venue and IntentBody
from videre-macros. No macro behaviour change.
Answer existence, value size and key cardinality without transferring
the payload across the wasm boundary. The SDK trait ships default
bodies over get/list-keys so backends opt into a cheaper path; the
redb backend answers contains and len from the entry in place and
count from a bounded range scan.
#[videre_sdk::venue] now takes the impl VenueAdapter block itself:
it asserts the manifest kind, keeps the manifest-derived world
narrowing, remaps the type interfaces onto the SDK bindings for type
identity, and expands to the demoted internal export codegen.
export_venue_adapter! is no longer public API; echo-venue and
flaky-venue author through the trait, the golden bridges are gone,
and the cow body codec is held to the kit's typed vectors.
The keeper author gets the mirror of #[venue]: #[videre_sdk::keeper] on
a handler impl emits the per-cdylib module world (requiring the client
capability), remaps the videre interfaces onto the SDK's shared shims,
completes async handlers on the synchronous guest boundary, and folds
ClientError into the wire fault so ? works in handlers.

VenueClient<V> replaces the stringly byte-level trait: a Venue marker
carries the VenueId and body schema, calls encode through IntentBody,
and the byte seam under it (VenueTransport, native AFIT) dispatches
statically with zero boxing. HostVenues binds the seam to the module's
own videre:venue/client import; the SDK bindgen collapses to one
import-only world so linking it never obliges a module to export the
adapter face. CowClient becomes the VenueClient<CowVenue> alias, and
the echo-keeper example drives echo-venue end to end (quote, submit,
status, cancel), proven by a platform e2e test.
Add an Order typestate builder over the 12-field CoW OrderBody with
SellToken and BuyToken newtypes, so a keeper cannot swap sides or skip
a required field: sell/buy entry fixes the kind, the counter-side limit
and expiry are compile-time required states, and the optionals default.

Seal the extension traits: Host and HostFault (nexum-sdk), RuntimeTypes
and Runtime (nexum-runtime), and VenueTransport (videre-sdk, the pool
seam's successor) each gain a doc-hidden sealing marker an implementor
opts into, so the surfaces can grow without silent downstream breakage.

Apply #[non_exhaustive] uniformly across the public error and label
enums, adding wildcard folds at the cross-crate match sites.

Emit the mirrored vocabularies from single-source consts in
nexum-world: the capability name consts and CORE_IFACES feed both the
world-synthesis table and the runtime registry, and the fault-label
consts feed the runtime's label projection with the SDK's strum labels
pinned to them in test.

Fix the operator logs that debug-formatted venue faults: the SDK
Fault's rate-limited Display now carries the retry-after hint, the
runtime's fault_message keeps it, and the venue registry renders wire
errors through message projections instead of the bindgen's debug
Display, so rate-limited retry-after-ms survives to the log line. The
golden-bridge sweep found no residue.
@mfw78
mfw78 force-pushed the feat/m3-extensible-event-abi branch from a795685 to 93e51cd Compare July 21, 2026 03:10
The core nexum:host `event` variant dropped `intent-status(intent-status-update)` for a generic `custom(custom-event)` channel, so the venue-agnostic core no longer names venue/receipt at the WIT boundary and a second extension can add its own event kind with no core WIT change.

The intent-status payload moved into videre's own WIT (`videre:types` intent-status-update) and rides the custom channel: videre-host emits `custom` with kind "intent-status" and the borsh envelope as payload, and `videre_sdk::event::intent_status_update` recovers it typed by matching the kind and decoding. nexum-status-body gained the shared kind const and the envelope codec while the status body stays the payload's inner encoding.

The core `#[module]` macro swapped its `on_intent_status` handler for a generic `on_custom`; the `#[keeper]` macro keeps the typed `on_intent_status`, decoding it from the custom event. check-venue-agnostic gained a WIT-vocabulary gate that fails on venue/receipt/intent-status in nexum:host.

Closes #518
@mfw78
mfw78 force-pushed the feat/m3-extensible-event-abi branch from 93e51cd to 4e0a313 Compare July 21, 2026 03:11
The status-body codec is wholly venue-domain: the IntentStatus lifecycle at the venue, the venue-reported FailReason, the opaque StatusBody bytes, and the {venue, receipt, status} IntentStatusUpdate envelope keyed by INTENT_STATUS_KIND.

Rename the crate nexum-status-body to videre-status-body so it sits in the videre (L2) layer, and stop the generic guest SDK re-exporting it.

nexum-sdk no longer depends on the codec nor re-exports it as status_body, so venue vocabulary no longer leaks into the venue-agnostic guest SDK.

The guest-facing surface now lives on videre-sdk, which depends on videre-status-body and re-exports it as videre_sdk::status_body; the event decode path and the keeper macro reach it there.

Venue-consuming example modules gain a videre-sdk dependency and decode through videre_sdk::status_body.

Extend check-venue-agnostic.sh with a nexum-sdk scan: its crate graph must reach no videre or venue crate, and its sources must name none of the intent-status codec vocabulary.

The wire form and behaviour are unchanged; the intent-status E2E tests pass unaltered.
@mfw78 mfw78 changed the title runtime: extract intent-status behind a generic custom event runtime: make the module event type extensible via a generic custom channel Jul 21, 2026
The videre:types intent-status-update record was declaration-only:
wit-bindgen prunes it (no world function references it, the payload
rides as opaque bytes), so it generated no binding; the wire contract
lives in videre-status-body's Rust type.

The example module decoded intent-status in on_custom despite never
submitting an intent, pulling videre-sdk in gratuitously. It now logs
the raw custom event generically and drops the videre-sdk dependency.
echo-client keeps its decode: it submits through videre:venue/client.
@mfw78
mfw78 force-pushed the feat/m3-extensible-event-abi branch from dcb8243 to a6cd37a Compare July 21, 2026 04:37
mfw78 added 2 commits July 21, 2026 07:21
The Rust symbol scan inherited the old charter (nexum:intent, nexum:adapter,
PoolRouter) - names retired in #428/#447 that cannot reappear - while the
live venue vocabulary went unchecked. A re-introduced videre_host::VenueRegistry
in nexum-runtime/src would have passed. Switch to the post-rename set so the
scan guards the names that actually signal a leak.
The privileged-field scan checked venue_registry|pool_router, neither of
which can appear: the field is registry: Arc<VenueRegistry> and pool_router
was retired in #428/#447. Match VenueRegistry so a router field reappearing
in nexum-runtime/src fails the gate.

@lgahdl lgahdl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a genuinely important architectural fix (closing a real venue-agnostic leak that survives at both the WIT and Rust level), but I found two significant problems, both empirically verified rather than just read:

1. The e2e test this PR cites as proof of correctness actually fails on its own tip. I ran it in a worktree checked out at this exact commit: e2e_intent_status_flows_through_the_event_loop (crates/videre-host/tests/platform.rs:435) asserts a log message contains "intent status update from venue cow", but the test boots modules/example, whose new on_custom (line 70) only logs "custom event kind {kind} ({len} payload bytes)" — it never calls videre_sdk::event::intent_status_update to decode the payload, so venue/receipt/status never appear anywhere in the log. Actual failure observed: test result: FAILED ... records were: ["example module init...", "custom event kind intent-status (26 payload bytes)"]. This directly contradicts the PR body's claim that this test "proves the borsh envelope round-trips venue + receipt + status through the wasm guest decode" — no decode happens on this path at all. Either give example::on_custom a real decode call, or point the test at a module that actually does one (e.g. echo-client), and fix the assertion text to match.

2. Even once fixed and merged, this PR cannot reach the repos where the leak actually lives in production right now. This PR is based on feat/m3-dx-polish-cluster, an ancestor of the real carve line — git merge-base --is-ancestor confirms the actual carve commits (a4c83de, 1726d3d, etc.) are NOT descendants of this PR's base, and vice versa; the two lines genuinely diverged. I checked the actual, currently-live nullislabs/nexum-runtime repo directly: its wit/nexum-host/types.wit still has the hardcoded intent-status(intent-status-update) variant this PR removes — the leak this PR fixes is still present in the real, separately-running L1 repo, completely unaffected by whatever happens to this PR in the old monorepo. Compounding this, scripts/check-venue-agnostic.sh (the file this PR patches with two new gates) was itself relocated to nexum/scripts/check-venue-agnostic.sh by the carve and no longer exists at this path on the post-carve line at all — so this diff can't even be applied verbatim to the real target repo. This fix needs to be independently re-authored and landed directly against nullislabs/nexum-runtime and nullislabs/videre-nexum-module, not merged here and expected to propagate.

Smaller items:

  • crates/videre-sdk/src/lib.rs:122 and the WIT-vocab gate's comment both claim the intent-status envelope is now "documented by the videre:types intent-status-update record" / "lives in videre's WIT now" — verified against wit/videre-types/types.wit, no such record exists there or anywhere; it's a plain Rust/borsh struct in videre-status-body, crossing the wire as opaque bytes. Both comments should say that instead.
  • The new IntentStatusUpdate::encode/decode (unlike the StatusBody codec it wraps) carries no version tag — previously this was a native, ABI-schema-checked WIT record; now a host/guest skew on this envelope silently misdecodes instead of failing closed like StatusBody does.
  • The two new check-venue-agnostic.sh gates are themselves genuinely sound — I ran them directly and confirmed both catch a manually re-injected leak (re-adding the WIT record, re-adding a StatusBody symbol to nexum-sdk), so no repeat of the stale-pattern bugs found in #432/#449/#450 there. The "bite-tested" claim just isn't verifiable from the diff itself (no fixture/commit shows it), only from the commit message.

@mfw78
mfw78 force-pushed the feat/m3-dx-polish-cluster branch from b4bd0e5 to 63ce698 Compare July 22, 2026 14:26
Base automatically changed from feat/m3-dx-polish-cluster to dev/m1 July 22, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants