From 75387c0310c5db1e1f10a25157f9b08ad2d64a59 Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 1 Sep 2026 14:42:32 +0530 Subject: [PATCH 1/7] docs(rfc-0028): add nested wire envelope RFC --- docs/rfcs/0028-nested-wire-envelope.md | 140 +++++++++++++++++++++++++ docs/rfcs/_index.md | 1 + 2 files changed, 141 insertions(+) create mode 100644 docs/rfcs/0028-nested-wire-envelope.md diff --git a/docs/rfcs/0028-nested-wire-envelope.md b/docs/rfcs/0028-nested-wire-envelope.md new file mode 100644 index 000000000..ae4ddde2e --- /dev/null +++ b/docs/rfcs/0028-nested-wire-envelope.md @@ -0,0 +1,140 @@ +--- +title: "Nested wire envelope: trait, method, version, and direction" +owner: "@decrypto21" +--- + +# RFC 0028: Nested Wire Envelope: Trait, Method, Version, and Direction + +| | | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 28 | +| **Start Date** | 2026-08-31 | +| **Description** | Replace the flat `(trait, method)` wire discriminant with a single nested address (trait, method, version, direction), so ids run as a dense per-trait sequence instead of a request/response or subscription-quartet spread. | +| **Authors** | Nidish | + +## Summary + +The codec 2 envelope (`[requestId][trait: u8][method: u8][payload]`) still spends its per-trait method-id budget on *direction*: a request/response method reserves two consecutive ids, a subscription reserves four. This RFC moves direction into the payload itself, one decode step past the `(trait, method)` routing pair: version selects a method's own `{Method}Version` enum, and that version wraps a `Request` (or, for subscriptions, a `Subscription`) value whose own variant tag carries direction. The `(trait, method)` address stays exactly as flat as it is today (one `MethodIds` constant per method, unchanged as the dispatcher's routing key); only what a method's *payload* decodes into changes. Each method costs exactly one id, method ids run as a dense 0, 1, 2, ... sequence per trait, and a method's version history is the single place its shape (request/response today, subscription tomorrow) can change without touching its wire address. + +## Motivation + +Codec 2 already fixed the *global* fragmentation problem: every trait now owns a contiguous 256-value method-id block instead of competing for one flat byte. But within a trait, the id budget is still spent two-at-a-time or four-at-a-time on something that is not really part of the method's identity: which direction a given frame flows. `system_get_product_context` is method ids 8 and 9; a four-frame subscription like `account_connection_status_subscribe` is ids 0 through 3. That pattern means: + +- **The 256-value ceiling arrives twice as fast for request/response methods, four times as fast for subscriptions.** A trait with 60 subscription methods exhausts its id space at 15 methods, not 60. +- **Version is addressable nowhere.** Today a method's version history lives entirely inside its payload type (`versioned::account::HostAccountGetRequest::V1(...)`); the wire address that routed the frame there already forgot which version answered it. Debug tooling and wire dumps see only the outer `(trait, method)` pair; the version that actually decided the payload shape is one `Decode` call deeper, invisible until it fully round-trips. +- **A method's shape is locked in by its ids, not its version.** If `foo_request` needs to become a subscription later, that is not a version bump; it's a new set of wire ids (a start/stop/interrupt/receive quartet) replacing the old request/response pair, i.e. a breaking removal plus a breaking addition. There is no way to express "this method grew a streaming variant" as an additive version change. +- **Public release is the last point this can move for free.** Once products in the field are decoding on any fixed shape of this envelope, every registered id becomes permanent; this restructuring is free today and a second breaking wire cutover after release. + +This RFC folds those concerns into the same nested address, at the same moment codec 2's own cutover (`WIRE_CODEC_VERSION` 1 → 2) is already in flight and unreleased. + +## Detailed Design + +### Current shape (codec 2) + +```text +[requestId: SCALE str][trait: u8][method: u8][payload bytes] +``` + +`method` is not one id per method: it is `n` for a request, `n+1` for its response; or `n..n+3` for a subscription's start/stop/interrupt/receive. `RequestFrameIds { trait_id, request_id, response_id }` and `SubscriptionFrameIds { trait_id, start_id, stop_id, interrupt_id, receive_id }` are the generated types that carry this today; the Rust dispatcher keys a `HashMap<(u8, u8), _>` on `(trait_id, request_id)` / `(trait_id, start_id)`, with `stop_id` tracked in a parallel `HashSet`. `payload bytes` already begins with a version tag and, for responses, an `Ok`/`Err` tag (`encode_versioned_ok_payload` / `encode_versioned_err_payload`), but that structure is invisible at the routing layer, because routing has already happened by the time those bytes are read. + +### Proposed shape + +```text +[requestId: SCALE str][trait: u8][method: u8][version: u8][direction: u8][inner payload bytes] +``` + +Two bytes replace what request/response or subscription ids used to encode, and one method id now serves every version and every direction a method ever has: + +```rust +// truapi::versioned: hand-written once, reused by every generated version enum. + +/// Direction tag for a request/response method: which half of the +/// exchange this frame carries. +pub enum Request { + Request(Req), + Response(Res), +} + +/// Direction tag for a subscription method: which half of the four-frame +/// exchange this frame carries. `Stop` carries no payload (cancellation +/// needs no data beyond "this subscription, now"), and `Interrupt` reuses +/// the method's own error type rather than a bespoke shape. `Interrupt(None)` +/// is natural stream completion; `Interrupt(Some(err))` is a failure. Today's +/// implementation sends an empty frame for the former (no representable value +/// otherwise), which this makes an explicit, decodable case instead. +pub enum Subscription { + Start(Start), + Stop, + Interrupt(Option), + Receive(Item), +} +``` + +`Err` is a type parameter, not a fixed type, so `Subscription` is one shape shared by every subscription regardless of how specific that method's own error is: a shared `GenericError` fallback for most subscriptions, a domain-shared error for a family of methods that all fail the same way (every `CoinPayment` subscription reuses `CoinPaymentError`), or a method-specific error for one that needs its own (each `Payment` subscription gets its own). None of the three needs a bespoke wrapper shape. + +The address space stays flat (one `MethodIds { trait_id, method_id }` `pub const` per method, exactly as it is today) because the version/direction structure lives entirely inside the payload type; no per-trait method enum sits between the address and it: + +```rust +// Generated (rust/wire_table.rs): one MethodIds const per method, built +// from the same #[wire_trait(id = N)] / #[wire(id = N)] annotations already +// on the source traits, just no longer split into request_id/response_id/etc. +pub const LOCALE_SUBSCRIBE: MethodIds = MethodIds { trait_id: 208, method_id: 0 }; + +// Hand-written (truapi/src/versioned/locale.rs): the {Method}Version type +// codegen names but does not itself emit; wraps the bare v01 item type +// directly, and the error slot is the CallError wrapper, not a bare D. +pub enum HostLocaleSubscribeVersion { + V1(Subscription<(), v01::HostLocaleSubscribeItem, CallError>), +} +``` + +A frame for `locale_subscribe`'s start half now reads: the `(trait_id, method_id)` pair addresses `LOCALE_SUBSCRIBE`, the version byte selects `V1`, the direction byte selects `Start`, and the remaining bytes are `()` (no start payload). The exact same leading bytes up through `method_id` route the matching `Stop`/`Interrupt`/`Receive` frames: the address never changes across a subscription's lifetime, only the version and direction tags inside the payload do. + +### Routing is unchanged + +The dispatcher's routing key is unchanged: `(trait_id, method_id)`, one `HashMap` lookup, because only inbound-shaped frames (`Request::Request(..)` or `Subscription::Start(..)` / `Subscription::Stop`) ever arrive at a host's dispatcher. A `Request::Response(..)` or `Subscription::Receive(..)`/`Interrupt(..)` arriving inbound is not a routing miss to fall back on; it is a protocol violation, answered with `CallError::MalformedFrame` exactly as an undecodable payload is today. What disappears is the *separate ids*: `response_id`, `start_id`/`stop_id`/`interrupt_id`/`receive_id` stop being fields on the generated `RequestFrameIds`/`SubscriptionFrameIds` structs, replaced by a single `MethodIds { trait_id, method_id }`, and `stop_ids: HashSet<(u8, u8)>` in `Dispatcher` disappears. A `Stop` frame arrives at the same `(trait_id, method_id)` as `Start`, but `dispatch()` peeks the direction tag itself before invoking anything and routes `Stop` straight to `SubscriptionManager::handle_stop`; only `Start` ever reaches the registered handler. + +### Local surfaces this touches + +- **`truapi-macros`**: `#[wire(request_id = N, response_id = N, ...)]` collapses to `#[wire(id = N)]`: one id argument, no `response_id`/`start_id`/`stop_id`/`interrupt_id`/`receive_id` variants left to parse. `#[wire_trait(id = N)]` is unchanged: trait ids keep their own explicit numbering and the `MIN_TRAIT_ID` / `MAX_CODEC_1_METHOD_ID` codec-1 floor guarantee is untouched, since that guarantee is about the *first* byte only. +- **`truapi-codegen/src/rustdoc.rs`**: extracts one `@wire_id=N` doc tag per method instead of up to four (`@wire_request_id`, `@wire_response_id`, `@wire_start_id`, ...). +- **`truapi-codegen/src/rust/wire_table.rs`**: emits a flat `MethodIds { trait_id, method_id }` `pub const` per method, replacing `RequestFrameIds`/`SubscriptionFrameIds`; no per-trait method enum sits between the address and the `{Method}Version` type declarations, which live alongside it. +- **`truapi-codegen/src/rust/dispatcher.rs`**: generated `register_*` functions keep registering `on_request`/`on_subscription` against the single `MethodIds` constant; the generated handler body gains one `match` arm to read the `Request`/`Subscription` tag before reaching the caller's trait method. +- **`truapi-codegen/src/ts.rs`**: mirrors all of the above into the generated TS wire table and client stub. +- **`truapi-server/src/frame.rs`**: `ProtocolMessage`'s hand-rolled `Decode` is unchanged in what it reads for routing (`trait_id`, `method_id`, both flat `u8`s); `encode_versioned_ok_payload`/`encode_versioned_err_payload` and friends go unused by every real method, which instead call `.encode()` directly on the derived `{Method}Version` enum to write `[version][direction][payload]` instead of `[version][Ok/Err][payload]`. The old functions stay in the tree only for `truapi-codegen`'s own synthetic-fixture legacy fallback and its unit tests. +- **`truapi-server/src/dispatcher.rs`**: loses `stop_ids: HashSet<(u8, u8)>`; `dispatch()` peeks the direction tag itself and routes `Stop` straight to `SubscriptionManager::handle_stop`, one decode step above the registered handler; only `Start` ever reaches it. +- **`truapi-server/src/subscription.rs`**: `Interrupt`/`Receive` frames sent to a product carry the `Subscription` direction tag instead of a distinct wire id. +- **`js/packages/truapi/src/client.ts`**: the hand-symmetric client-side router (`createTransport`'s `provider.subscribe` callback, matching on `traitId`/`methodId`) mirrors the Rust dispatcher's change exactly: same `(traitId, methodId)` map, one more decode step for the direction tag. +- **Golden and fixture tests** all need regeneration under the new shape: `truapi-codegen`'s own golden `wire_table.rs`/`dispatcher.rs`, `truapi-server`'s `golden-account-get.bin`, `wire_table_ts_parity.rs`, `wire_result_shape.rs`, `golden_frame.rs`. + +### Compatibility + +This folds into codec 2: `WIRE_CODEC_VERSION` stays `2`, and `MIN_TRAIT_ID`/`MAX_CODEC_1_METHOD_ID` are untouched, since the codec-1/codec-2 boundary is entirely about the first (trait) byte. Codec 2 has not shipped yet, so this is a zero-cost renumbering: there is no codec-2 peer anywhere to break a second time. Folding it into the same unreleased cutover avoids a third wire-breaking version bump before public release. + +## Drawbacks + +- One more decode step and one more byte (the direction tag; the version tag already existed inside the payload, just one level deeper) on every frame. +- `Request` and `Subscription` are new hand-maintained generics every generated version enum now wraps its payload in, rather than naming the payload type directly: a small, permanent indirection at every call site of the generated client. +- Deriving `Encode`/`Decode` on a generic enum requires both type parameters to implement the trait; every existing versioned payload type already does, so no new bound is introduced on source traits, but the generic itself must live in `truapi::versioned` rather than being codegen-local, since every generated module needs to name it. + +## Testing, Security, and Privacy + +Regenerating the golden and fixture tests listed above is the primary verification surface: byte-level goldens (`golden-account-get.bin`, the wire-table/dispatcher parity tests) must be recomputed, not relaxed, exactly as codec 2's own cutover recomputed them rather than loosening their assertions. No new security surface is introduced; `CallError::MalformedFrame` is the correct response to a `Response`/`Receive`/`Interrupt` tag arriving where a `Request`/`Start` tag is expected, rather than accepting it silently. + +## Performance, Ergonomics, and Compatibility + +### Performance + +Routing remains a single `HashMap<(u8, u8), _>` lookup, unchanged in complexity. The added cost is exactly one extra single-byte `Decode` call per frame for the direction (and, where not already implicit, version) tag, negligible next to decoding the payload itself. + +### Ergonomics + +Method ids become a dense, gap-free per-trait sequence (0, 1, 2, ...) instead of today's 0, 2, 4, 6, 8 pattern, which is easier to read off a wire dump and easier to eyeball for accidental collisions in review. A method that starts as plain request/response and later needs a streaming variant expresses that as a new `{Method}Version` variant wrapping `Subscription<...>` instead of `Request<...>`, rather than as a set of wire ids replacing another set. + +### Compatibility + +Covered above: folds into codec 2, no additional version bump. + +## Future Directions and Related Material + +Once every trait's method ids are dense and gap-free under this scheme, a later RFC could reconsider whether trait ids need the same permanent `MIN_TRAIT_ID` reservation once no codec-1 peer remains in the field. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index daa061f95..da6ab50f9 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,3 +26,4 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | +| 0028 | [Nested Wire Envelope: Trait, Method, Version, and Direction](0028-nested-wire-envelope.md) | draft | Nidish | — | From 0d6ba43f6de52a265d9353e79e8c968be395c276 Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 1 Sep 2026 15:53:49 +0530 Subject: [PATCH 2/7] feat(truapi): add the Request/Subscription direction-tag generics RFC 0028 specifies --- docs/rfcs/_index.md | 2 +- rust/crates/truapi/src/versioned.rs | 96 ++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index da6ab50f9..97eb8df90 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,4 +26,4 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | -| 0028 | [Nested Wire Envelope: Trait, Method, Version, and Direction](0028-nested-wire-envelope.md) | draft | Nidish | — | +| 0028 | [Nested Wire Envelope: Trait, Method, Version, and Direction](0028-nested-wire-envelope.md) | draft | Nidish | [#557](https://github.com/paritytech/host-rust-core/pull/557) | diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 4d5e37c26..10a52a31c 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -4,7 +4,12 @@ //! successive versions of one logical message, newest last. A server normalizes //! incoming values to [`Versioned::Latest`] with [`IntoLatest`], handles them in //! latest terms, then maps results back to the caller's version with -//! [`FromLatest`]. The envelopes themselves are generated by `versioned_type!`. +//! [`FromLatest`]. The envelopes themselves are generated by `versioned_type!`, +//! and each variant wraps either [`Request`] or [`Subscription`] rather than a +//! bare payload: version selects a method's shape, direction is carried inside +//! that version's payload rather than addressed by a separate wire id. + +use parity_scale_codec::{Decode, Encode}; /// A versioned message envelope. pub trait Versioned: Sized { @@ -30,6 +35,32 @@ pub trait FromLatest: Versioned { fn from_latest(latest: Self::Latest, target: u8) -> Self; } +/// Direction tag for a request/response method: which half of the exchange +/// this frame carries. Wrapped inside a method's version enum, so a method's +/// version history is the one place its shape can change. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum Request { + /// Product-to-host call. + Request(Req), + /// Host-to-product reply. + Response(Res), +} + +/// Direction tag for a subscription method: which half of the four-frame +/// exchange this frame carries. `Stop` carries no payload; `Interrupt` carries +/// `None` for natural stream completion or `Some(err)` for a failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum Subscription { + /// Product-to-host subscription request. + Start(Start), + /// Product-to-host cancellation. + Stop, + /// Host-to-product termination: `None` on clean completion, `Some` on failure. + Interrupt(Option), + /// Host-to-product streamed item. + Receive(Item), +} + pub mod account; pub mod chain; pub mod chat; @@ -115,4 +146,67 @@ mod tests { .expect("decode"); assert_eq!(original, decoded); } + + #[test] + fn request_direction_tag_roundtrips_both_variants() { + use super::Request; + + let request = Request::::Request(7); + let decoded = Request::::decode(&mut &request.encode()[..]).expect("decode"); + assert_eq!(request, decoded); + assert_eq!(request.encode()[0], 0, "Request must encode discriminant 0"); + + let response = Request::::Response("ok".to_string()); + let decoded = Request::::decode(&mut &response.encode()[..]).expect("decode"); + assert_eq!(response, decoded); + assert_eq!( + response.encode()[0], + 1, + "Response must encode discriminant 1" + ); + } + + #[test] + fn subscription_direction_tag_roundtrips_every_variant() { + use super::Subscription; + + let start = Subscription::::Start(7); + assert_eq!(start.encode()[0], 0, "Start must encode discriminant 0"); + assert_eq!( + Subscription::::decode(&mut &start.encode()[..]).expect("decode"), + start + ); + + let stop = Subscription::::Stop; + assert_eq!(stop.encode()[0], 1, "Stop must encode discriminant 1"); + assert_eq!( + Subscription::::decode(&mut &stop.encode()[..]).expect("decode"), + stop + ); + + let clean_end = Subscription::::Interrupt(None); + assert_eq!( + clean_end.encode()[0], + 2, + "Interrupt must encode discriminant 2" + ); + assert_eq!( + Subscription::::decode(&mut &clean_end.encode()[..]) + .expect("decode"), + clean_end + ); + + let failed = Subscription::::Interrupt(Some("boom".to_string())); + assert_eq!( + Subscription::::decode(&mut &failed.encode()[..]).expect("decode"), + failed + ); + + let item = Subscription::::Receive("hello".to_string()); + assert_eq!(item.encode()[0], 3, "Receive must encode discriminant 3"); + assert_eq!( + Subscription::::decode(&mut &item.encode()[..]).expect("decode"), + item + ); + } } From 7d200ac6ead1900281daed71c31028f720165824 Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 1 Sep 2026 16:07:28 +0530 Subject: [PATCH 3/7] fix(truapi): keep the new direction-tag generics crate-private for now --- rust/crates/truapi/src/versioned.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 10a52a31c..453771f1d 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -5,9 +5,9 @@ //! incoming values to [`Versioned::Latest`] with [`IntoLatest`], handles them in //! latest terms, then maps results back to the caller's version with //! [`FromLatest`]. The envelopes themselves are generated by `versioned_type!`, -//! and each variant wraps either [`Request`] or [`Subscription`] rather than a -//! bare payload: version selects a method's shape, direction is carried inside -//! that version's payload rather than addressed by a separate wire id. +//! and each variant wraps either `Request` or `Subscription` rather than a bare +//! payload: version selects a method's shape, direction is carried inside that +//! version's payload rather than addressed by a separate wire id. use parity_scale_codec::{Decode, Encode}; @@ -39,7 +39,7 @@ pub trait FromLatest: Versioned { /// this frame carries. Wrapped inside a method's version enum, so a method's /// version history is the one place its shape can change. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum Request { +pub(crate) enum Request { /// Product-to-host call. Request(Req), /// Host-to-product reply. @@ -50,7 +50,7 @@ pub enum Request { /// exchange this frame carries. `Stop` carries no payload; `Interrupt` carries /// `None` for natural stream completion or `Some(err)` for a failure. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum Subscription { +pub(crate) enum Subscription { /// Product-to-host subscription request. Start(Start), /// Product-to-host cancellation. From fe49ba1d29cf4c22c76b7f990756d5ecf577405b Mon Sep 17 00:00:00 2001 From: Nidish Date: Tue, 1 Sep 2026 16:30:04 +0530 Subject: [PATCH 4/7] fix(truapi): allow dead_code on the not-yet-referenced direction-tag generics --- rust/crates/truapi/src/versioned.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 453771f1d..ba99b5d8e 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -38,6 +38,9 @@ pub trait FromLatest: Versioned { /// Direction tag for a request/response method: which half of the exchange /// this frame carries. Wrapped inside a method's version enum, so a method's /// version history is the one place its shape can change. +// Not yet referenced outside this module's own tests: the generated code that +// wraps every method's version enum in this lands in a separate stacked PR. +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) enum Request { /// Product-to-host call. @@ -49,6 +52,7 @@ pub(crate) enum Request { /// Direction tag for a subscription method: which half of the four-frame /// exchange this frame carries. `Stop` carries no payload; `Interrupt` carries /// `None` for natural stream completion or `Some(err)` for a failure. +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) enum Subscription { /// Product-to-host subscription request. From c1b96424358595485d1b1ada4b1861e3c724b9a8 Mon Sep 17 00:00:00 2001 From: Nidish Date: Wed, 2 Sep 2026 19:13:04 +0530 Subject: [PATCH 5/7] docs(rfc-0028): use SCALE/Substrate vocabulary and trim detailed design to essentials --- docs/rfcs/0028-nested-wire-envelope.md | 52 +++----------------------- 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/docs/rfcs/0028-nested-wire-envelope.md b/docs/rfcs/0028-nested-wire-envelope.md index ae4ddde2e..8cb025349 100644 --- a/docs/rfcs/0028-nested-wire-envelope.md +++ b/docs/rfcs/0028-nested-wire-envelope.md @@ -14,7 +14,7 @@ owner: "@decrypto21" ## Summary -The codec 2 envelope (`[requestId][trait: u8][method: u8][payload]`) still spends its per-trait method-id budget on *direction*: a request/response method reserves two consecutive ids, a subscription reserves four. This RFC moves direction into the payload itself, one decode step past the `(trait, method)` routing pair: version selects a method's own `{Method}Version` enum, and that version wraps a `Request` (or, for subscriptions, a `Subscription`) value whose own variant tag carries direction. The `(trait, method)` address stays exactly as flat as it is today (one `MethodIds` constant per method, unchanged as the dispatcher's routing key); only what a method's *payload* decodes into changes. Each method costs exactly one id, method ids run as a dense 0, 1, 2, ... sequence per trait, and a method's version history is the single place its shape (request/response today, subscription tomorrow) can change without touching its wire address. +The `(trait, method)` pair that addresses every TrUAPI frame is the same shape as a Substrate extrinsic's `(pallet_index, call_index)`: one byte names the module, the other names the operation within it. Codec 2's envelope (`[requestId][trait: u8][method: u8][payload]`) still spends that per-trait operation budget on *direction*, not just the operation: a request/response method reserves two consecutive ids, a subscription reserves four. This RFC moves direction into the payload itself, one decode step past the `(trait, method)` routing pair, as a further SCALE Enum: the payload's own leading byte selects a method's `{Method}Version` variant, and that variant wraps a `Request` enum (or, for subscriptions, a `Subscription` enum) whose own variant carries direction. The `(trait, method)` address stays exactly as flat as it is today (one `MethodIds` constant per method, unchanged as the dispatcher's routing key); only what a method's *payload* decodes into changes. Each method costs exactly one id, method ids run as a dense 0, 1, 2, ... sequence per trait, and a method's version history is the single place its shape (request/response today, subscription tomorrow) can change without touching its wire address. ## Motivation @@ -35,7 +35,7 @@ This RFC folds those concerns into the same nested address, at the same moment c [requestId: SCALE str][trait: u8][method: u8][payload bytes] ``` -`method` is not one id per method: it is `n` for a request, `n+1` for its response; or `n..n+3` for a subscription's start/stop/interrupt/receive. `RequestFrameIds { trait_id, request_id, response_id }` and `SubscriptionFrameIds { trait_id, start_id, stop_id, interrupt_id, receive_id }` are the generated types that carry this today; the Rust dispatcher keys a `HashMap<(u8, u8), _>` on `(trait_id, request_id)` / `(trait_id, start_id)`, with `stop_id` tracked in a parallel `HashSet`. `payload bytes` already begins with a version tag and, for responses, an `Ok`/`Err` tag (`encode_versioned_ok_payload` / `encode_versioned_err_payload`), but that structure is invisible at the routing layer, because routing has already happened by the time those bytes are read. +`method` is not one id per method: `n` for a request, `n+1` for its response, or `n..n+3` for a subscription's start/stop/interrupt/receive. Routing happens on those bytes alone; the payload's own version (and, for responses, an `Ok`/`Err`) tag is never visible at the routing layer. ### Proposed shape @@ -43,25 +43,14 @@ This RFC folds those concerns into the same nested address, at the same moment c [requestId: SCALE str][trait: u8][method: u8][version: u8][direction: u8][inner payload bytes] ``` -Two bytes replace what request/response or subscription ids used to encode, and one method id now serves every version and every direction a method ever has: +One method id now serves every version and direction a method has. Two hand-written generics, reused by every generated version enum, carry direction: ```rust -// truapi::versioned: hand-written once, reused by every generated version enum. - -/// Direction tag for a request/response method: which half of the -/// exchange this frame carries. pub enum Request { Request(Req), Response(Res), } -/// Direction tag for a subscription method: which half of the four-frame -/// exchange this frame carries. `Stop` carries no payload (cancellation -/// needs no data beyond "this subscription, now"), and `Interrupt` reuses -/// the method's own error type rather than a bespoke shape. `Interrupt(None)` -/// is natural stream completion; `Interrupt(Some(err))` is a failure. Today's -/// implementation sends an empty frame for the former (no representable value -/// otherwise), which this makes an explicit, decodable case instead. pub enum Subscription { Start(Start), Stop, @@ -70,42 +59,13 @@ pub enum Subscription { } ``` -`Err` is a type parameter, not a fixed type, so `Subscription` is one shape shared by every subscription regardless of how specific that method's own error is: a shared `GenericError` fallback for most subscriptions, a domain-shared error for a family of methods that all fail the same way (every `CoinPayment` subscription reuses `CoinPaymentError`), or a method-specific error for one that needs its own (each `Payment` subscription gets its own). None of the three needs a bespoke wrapper shape. - -The address space stays flat (one `MethodIds { trait_id, method_id }` `pub const` per method, exactly as it is today) because the version/direction structure lives entirely inside the payload type; no per-trait method enum sits between the address and it: - -```rust -// Generated (rust/wire_table.rs): one MethodIds const per method, built -// from the same #[wire_trait(id = N)] / #[wire(id = N)] annotations already -// on the source traits, just no longer split into request_id/response_id/etc. -pub const LOCALE_SUBSCRIBE: MethodIds = MethodIds { trait_id: 208, method_id: 0 }; - -// Hand-written (truapi/src/versioned/locale.rs): the {Method}Version type -// codegen names but does not itself emit; wraps the bare v01 item type -// directly, and the error slot is the CallError wrapper, not a bare D. -pub enum HostLocaleSubscribeVersion { - V1(Subscription<(), v01::HostLocaleSubscribeItem, CallError>), -} -``` +`Err` is a type parameter, not a fixed type: most subscriptions share a `GenericError` fallback, a family that fails alike shares one domain error, and a method that needs its own gets one. `Interrupt(None)` is natural completion; `Interrupt(Some(err))` is a failure, replacing today's silent empty-frame convention with an explicit, decodable case. -A frame for `locale_subscribe`'s start half now reads: the `(trait_id, method_id)` pair addresses `LOCALE_SUBSCRIBE`, the version byte selects `V1`, the direction byte selects `Start`, and the remaining bytes are `()` (no start payload). The exact same leading bytes up through `method_id` route the matching `Stop`/`Interrupt`/`Receive` frames: the address never changes across a subscription's lifetime, only the version and direction tags inside the payload do. +The `(trait, method)` address itself never changes: a subscription's start, stop, interrupt, and receive frames all address the same `MethodIds` constant, distinguished only by the version and direction bytes inside the payload. ### Routing is unchanged -The dispatcher's routing key is unchanged: `(trait_id, method_id)`, one `HashMap` lookup, because only inbound-shaped frames (`Request::Request(..)` or `Subscription::Start(..)` / `Subscription::Stop`) ever arrive at a host's dispatcher. A `Request::Response(..)` or `Subscription::Receive(..)`/`Interrupt(..)` arriving inbound is not a routing miss to fall back on; it is a protocol violation, answered with `CallError::MalformedFrame` exactly as an undecodable payload is today. What disappears is the *separate ids*: `response_id`, `start_id`/`stop_id`/`interrupt_id`/`receive_id` stop being fields on the generated `RequestFrameIds`/`SubscriptionFrameIds` structs, replaced by a single `MethodIds { trait_id, method_id }`, and `stop_ids: HashSet<(u8, u8)>` in `Dispatcher` disappears. A `Stop` frame arrives at the same `(trait_id, method_id)` as `Start`, but `dispatch()` peeks the direction tag itself before invoking anything and routes `Stop` straight to `SubscriptionManager::handle_stop`; only `Start` ever reaches the registered handler. - -### Local surfaces this touches - -- **`truapi-macros`**: `#[wire(request_id = N, response_id = N, ...)]` collapses to `#[wire(id = N)]`: one id argument, no `response_id`/`start_id`/`stop_id`/`interrupt_id`/`receive_id` variants left to parse. `#[wire_trait(id = N)]` is unchanged: trait ids keep their own explicit numbering and the `MIN_TRAIT_ID` / `MAX_CODEC_1_METHOD_ID` codec-1 floor guarantee is untouched, since that guarantee is about the *first* byte only. -- **`truapi-codegen/src/rustdoc.rs`**: extracts one `@wire_id=N` doc tag per method instead of up to four (`@wire_request_id`, `@wire_response_id`, `@wire_start_id`, ...). -- **`truapi-codegen/src/rust/wire_table.rs`**: emits a flat `MethodIds { trait_id, method_id }` `pub const` per method, replacing `RequestFrameIds`/`SubscriptionFrameIds`; no per-trait method enum sits between the address and the `{Method}Version` type declarations, which live alongside it. -- **`truapi-codegen/src/rust/dispatcher.rs`**: generated `register_*` functions keep registering `on_request`/`on_subscription` against the single `MethodIds` constant; the generated handler body gains one `match` arm to read the `Request`/`Subscription` tag before reaching the caller's trait method. -- **`truapi-codegen/src/ts.rs`**: mirrors all of the above into the generated TS wire table and client stub. -- **`truapi-server/src/frame.rs`**: `ProtocolMessage`'s hand-rolled `Decode` is unchanged in what it reads for routing (`trait_id`, `method_id`, both flat `u8`s); `encode_versioned_ok_payload`/`encode_versioned_err_payload` and friends go unused by every real method, which instead call `.encode()` directly on the derived `{Method}Version` enum to write `[version][direction][payload]` instead of `[version][Ok/Err][payload]`. The old functions stay in the tree only for `truapi-codegen`'s own synthetic-fixture legacy fallback and its unit tests. -- **`truapi-server/src/dispatcher.rs`**: loses `stop_ids: HashSet<(u8, u8)>`; `dispatch()` peeks the direction tag itself and routes `Stop` straight to `SubscriptionManager::handle_stop`, one decode step above the registered handler; only `Start` ever reaches it. -- **`truapi-server/src/subscription.rs`**: `Interrupt`/`Receive` frames sent to a product carry the `Subscription` direction tag instead of a distinct wire id. -- **`js/packages/truapi/src/client.ts`**: the hand-symmetric client-side router (`createTransport`'s `provider.subscribe` callback, matching on `traitId`/`methodId`) mirrors the Rust dispatcher's change exactly: same `(traitId, methodId)` map, one more decode step for the direction tag. -- **Golden and fixture tests** all need regeneration under the new shape: `truapi-codegen`'s own golden `wire_table.rs`/`dispatcher.rs`, `truapi-server`'s `golden-account-get.bin`, `wire_table_ts_parity.rs`, `wire_result_shape.rs`, `golden_frame.rs`. +The dispatcher still keys on `(trait_id, method_id)`, one lookup. Only inbound-shaped frames (`Request::Request`, `Subscription::Start`/`Stop`) ever reach it; an outbound-shaped frame arriving inbound is a protocol violation, answered with `CallError::MalformedFrame`, exactly as an undecodable payload is today. ### Compatibility From de8e9286756d06d6ac98c74e9b6f9122e650176b Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 3 Sep 2026 16:50:52 +0530 Subject: [PATCH 6/7] fix(truapi): reorder Subscription's discriminants so Receive follows Start --- docs/rfcs/0028-nested-wire-envelope.md | 6 ++++-- rust/crates/truapi/src/versioned.rs | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/rfcs/0028-nested-wire-envelope.md b/docs/rfcs/0028-nested-wire-envelope.md index 8cb025349..4007260cf 100644 --- a/docs/rfcs/0028-nested-wire-envelope.md +++ b/docs/rfcs/0028-nested-wire-envelope.md @@ -53,12 +53,14 @@ pub enum Request { pub enum Subscription { Start(Start), - Stop, - Interrupt(Option), Receive(Item), + Interrupt(Option), + Stop, } ``` +`Start` and `Receive` lead, in the same position as `Request`'s own two variants, so a subscription's first two directions share `Request`'s discriminants and partly decode the same way. + `Err` is a type parameter, not a fixed type: most subscriptions share a `GenericError` fallback, a family that fails alike shares one domain error, and a method that needs its own gets one. `Interrupt(None)` is natural completion; `Interrupt(Some(err))` is a failure, replacing today's silent empty-frame convention with an explicit, decodable case. The `(trait, method)` address itself never changes: a subscription's start, stop, interrupt, and receive frames all address the same `MethodIds` constant, distinguished only by the version and direction bytes inside the payload. diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index ba99b5d8e..25d326d84 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -50,19 +50,22 @@ pub(crate) enum Request { } /// Direction tag for a subscription method: which half of the four-frame -/// exchange this frame carries. `Stop` carries no payload; `Interrupt` carries -/// `None` for natural stream completion or `Some(err)` for a failure. +/// exchange this frame carries. `Start` and `Receive` lead, in the same +/// position as [`Request`]'s own two variants, so subscription decoding is +/// partly shape-compatible with request decoding. `Stop` carries no payload; +/// `Interrupt` carries `None` for natural stream completion or `Some(err)` +/// for a failure. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) enum Subscription { /// Product-to-host subscription request. Start(Start), - /// Product-to-host cancellation. - Stop, - /// Host-to-product termination: `None` on clean completion, `Some` on failure. - Interrupt(Option), /// Host-to-product streamed item. Receive(Item), + /// Host-to-product termination: `None` on clean completion, `Some` on failure. + Interrupt(Option), + /// Product-to-host cancellation. + Stop, } pub mod account; @@ -182,7 +185,7 @@ mod tests { ); let stop = Subscription::::Stop; - assert_eq!(stop.encode()[0], 1, "Stop must encode discriminant 1"); + assert_eq!(stop.encode()[0], 3, "Stop must encode discriminant 3"); assert_eq!( Subscription::::decode(&mut &stop.encode()[..]).expect("decode"), stop @@ -207,7 +210,7 @@ mod tests { ); let item = Subscription::::Receive("hello".to_string()); - assert_eq!(item.encode()[0], 3, "Receive must encode discriminant 3"); + assert_eq!(item.encode()[0], 1, "Receive must encode discriminant 1"); assert_eq!( Subscription::::decode(&mut &item.encode()[..]).expect("decode"), item From d8e9a7026aca7766db30c7d6ed09edf38ce04693 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 4 Sep 2026 18:22:45 +0530 Subject: [PATCH 7/7] fix(truapi): pin explicit codec indices on Request/Subscription variants --- rust/crates/truapi/src/versioned.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 25d326d84..6d79435eb 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -37,15 +37,19 @@ pub trait FromLatest: Versioned { /// Direction tag for a request/response method: which half of the exchange /// this frame carries. Wrapped inside a method's version enum, so a method's -/// version history is the one place its shape can change. +/// version history is the one place its shape can change. Each variant's +/// index is pinned explicitly so a later addition can't silently shift an +/// existing one's wire value. // Not yet referenced outside this module's own tests: the generated code that // wraps every method's version enum in this lands in a separate stacked PR. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) enum Request { /// Product-to-host call. + #[codec(index = 0)] Request(Req), /// Host-to-product reply. + #[codec(index = 1)] Response(Res), } @@ -54,17 +58,22 @@ pub(crate) enum Request { /// position as [`Request`]'s own two variants, so subscription decoding is /// partly shape-compatible with request decoding. `Stop` carries no payload; /// `Interrupt` carries `None` for natural stream completion or `Some(err)` -/// for a failure. +/// for a failure. Each variant's index is pinned explicitly so a later +/// addition can't silently shift an existing one's wire value. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub(crate) enum Subscription { /// Product-to-host subscription request. + #[codec(index = 0)] Start(Start), /// Host-to-product streamed item. + #[codec(index = 1)] Receive(Item), /// Host-to-product termination: `None` on clean completion, `Some` on failure. + #[codec(index = 2)] Interrupt(Option), /// Product-to-host cancellation. + #[codec(index = 3)] Stop, }