Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions docs/rfcs/0027-capability-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
title: "Method-level capability detection for protocol extension"
owner: "@ryanleecode"
type: rfc
status: draft
created: 2026-08-16
---

# RFC 0027 — Method-level capability detection for protocol extension

| | |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **RFC Number** | 27 |
| **Start Date** | 2026-08-16 |
| **Description** | Enable runtime discovery of host method support on existing wire IDs, and ensure unregistered method calls fail explicitly instead of hanging indefinitely. |
| **Authors** | Ryan Lee |

## Summary

When TrUAPI adds new methods, products have no mechanism to check whether their embedding host supports them. Calling an unknown method causes the host dispatcher to silently drop the unrecognised wire discriminant. Because the client transport lacks request timeouts, the pending call hangs forever.

This RFC introduces three coordinated fixes:
1. **Backward-compatible probing:** Adds a `Method { id: u8 }` query variant to `system.feature_supported` (wire ID 2, registered by all hosts). Older hosts return a decode error (`MalformedFrame`) instead of dropping the frame, allowing products to reliably infer host capabilities even on pre-RFC deployments.
2. **Explicit failure for unregistered discriminants:** Hosts reply to unregistered wire IDs with a reserved `UNSUPPORTED_METHOD` frame instead of dropping them, settling pending client requests immediately.
3. **Typed error semantics:** Canonicalises `CallError::Unsupported` as the standard response for unwired trait methods, replacing ambiguous `HostFailure { reason: "unavailable" }` strings.

## Motivation

Protocol extension is frequent in TrUAPI (e.g., v0.2 introduced eleven new methods across three groups). Upcoming extensions like the `Swarm` capability (BitTorrent-based content streaming) highlight critical gaps in version compatibility.

### 1. Inconsistent Observability on Unknown Features

When a product targets an older host lacking a new feature, behavior depends arbitrarily on where the unknown identifier appears:

| Call Type | Example | Host Behavior | Product Observability |
| :--- | :--- | :--- | :--- |
| **Unknown Enum Variant** | `permissions.remote_permission(Swarm)` | Decoder rejects variant index; returns `CallError::MalformedFrame`. | **Settles immediately** with error. |
| **Unknown Wire ID** | `swarm.fetch(...)` | Dispatcher finds no handler; drops frame silently (`dispatcher.rs`). | **Hangs indefinitely**; transport never resolves. |

### 2. Unbounded Client Hangs

The client transport (`js/packages/truapi/src/client.ts`) tracks requests in a `pending` map. Entries are settled only when a response matching the expected ID arrives, the transport throws, or the connection closes. Because many methods involve user interaction (e.g., wallet authorization, biometric prompts) with unbounded duration, global client timeouts cannot be safely applied. Dropped frames therefore cause indefinite hangs indistinguishable from crashed hosts.

### 3. Asymmetric Deployment Cadence

Wire IDs are append-only to preserve backward compatibility (newer hosts support older products). However, products are deployed as web bundles that update continuously, whereas native host runtimes (iOS SPM, Android Kotlin packages) update on slower native app release cycles. As a result, newer products routinely run against older hosts.

### 4. Ambiguous "Unavailable" Errors

Unimplemented default trait methods previously returned `CallError::unavailable()`, which mapped to `HostFailure { reason: "unavailable" }`. This conflated permanent lack of feature support with transient operational failures.

---

## Detailed Design

### 1. Capability Probing via `system.feature_supported`

`HostFeatureSupportedRequest` in `truapi::v01::system` is extended with a new variant:

```rust
pub enum HostFeatureSupportedRequest {
/// Query whether the host supports the chain identified by genesis hash.
Chain {
/// Chain genesis hash.
genesis_hash: Vec<u8>,
},
/// Query whether the host has registered a handler for a wire discriminant.
Method {
/// Request or subscription-start discriminant from the wire table.
id: u8,
},
}
```

Because `system.feature_supported` (`#[wire(request_id = 2)]`) is registered by all hosts across all published surface versions (`0.2.0` to `0.9.0`):

- **Modern Hosts:** Hosts implementing this RFC inspect the build's wire table — the same table the dispatcher registers its routing maps from — and return `HostFeatureSupportedResponse { supported }`. The host MUST compute `supported` from that table rather than a separate static list, and MUST NOT report `true` for unregistered discriminants. In this repository the equivalence between the wire table and the registered dispatch surface is gated by a test. `id` is a request discriminant or a product-facing subscription-start discriminant — the two frame kinds a product can begin a call with; response, stop, interrupt, receive and host-initiated-subscription ids are not product-callable ids the host MUST answer `false` for.
- **Legacy Hosts:** Hosts predating this RFC attempt to decode payload bytes against the single-variant enum. The decoder rejects variant index 1, and the generated dispatcher wrapper returns `CallError::MalformedFrame`.

#### Conclusive Probing Protocol

To distinguish an old host from payload corruption, callers SHOULD execute a concurrent control probe:

```ts
const [methodProbe, controlProbe] = await Promise.all([
truapi.system.featureSupported({ tag: "Method", value: { id: targetWireId } }),
truapi.system.featureSupported({ tag: "Chain", value: { genesisHash: knownChainHash } }),
]);

// Control OK + Method MalformedFrame -> Host predates RFC 0027 (Method unsupported)
// Method OK -> Response value is authoritative
```

The client SDK exposes a high-level helper resolving method names to wire discriminants at codegen time:

```ts
const isAvailable = await truapi.system.supportsMethod("swarm_fetch");
```

---

### 2. Explicit Response for Unregistered Discriminants

A reserved wire discriminant `UNSUPPORTED_METHOD` is allocated in the server wire table. When `Dispatcher::dispatch` encounters an unknown discriminant, it sends an explicit error frame:

```rust
// Fall-through branch for unrecognized discriminants:
transport.send(ProtocolMessage {
request_id: message.request_id,
payload: Payload {
id: wire_table::UNSUPPORTED_METHOD,
value: encode_versioned_err_payload(CallError::<Infallible>::Unsupported, 1),
},
});
```

- The host MUST echo the caller's `request_id`.
- The host MUST send this frame for any message that matches no registered request, subscription start, or cancel handler.
- The client transport routes `UNSUPPORTED_METHOD` frames directly to the pending request or subscription entry, rejecting the promise with `CallError::Unsupported`.

---

### 3. Canonical `Unsupported` Error Variant

The default implementation of `CallError::unsupported()` is updated across all API trait definitions:

```rust
impl<D> CallError<D> {
/// Convenience for default handlers whose implementation is not wired.
///
/// Returns [`CallError::Unsupported`] (RFC 0027): the host will not serve
/// this method for the lifetime of the connection, so a caller must not
/// retry. `HostFailure` stays reserved for a host that attempted the
/// operation and failed, which a caller may retry.
pub fn unsupported() -> Self {
Self::Unsupported
}
}
```

- A host MUST NOT return `HostFailure` for an unimplemented method.
- `Unsupported` signals that the operation is permanently unhandled on the current host session; callers MUST NOT retry.
- `HostFailure` remains correct for a host that attempted the operation and failed transiently; callers MAY retry that.

---

### Summary of Host Reachability & Compatibility

| Mechanism | Legacy Hosts (Pre-RFC 0027) | Modern Hosts (RFC 0027) |
| :--- | :--- | :--- |
| `feature_supported(Method { id })` | Returns `MalformedFrame` (interpreted as unsupported via control probe) | Returns `HostFeatureSupportedResponse { supported }` |
| Direct Call to Unregistered Wire ID | Frame dropped; client hangs | Returns `UNSUPPORTED_METHOD` frame; client promise rejects |
| Unwired Default Trait Method | Returns `HostFailure { reason: "unavailable" }` | Returns typed `CallError::Unsupported` |

---

## Drawbacks

1. **Inference Overhead:** Legacy detection relies on interpreting `MalformedFrame` as "host predates capability". While the paired control query prevents misclassification, it requires dual queries on legacy hosts.
2. **Registration vs Implementation Gap:** A positive response (`supported: true`) indicates the method is registered in the host binary dispatcher table, but does not guarantee the host provided a custom implementation instead of the default trait fallback (`CallError::Unsupported`). Products must still handle `Unsupported` at call time.
3. **Discriminant Allocation:** Consumes one permanent `u8` discriminant (`UNSUPPORTED_METHOD`) from the finite 256-value wire table space.
4. **Behavioral Change for `unavailable()`:** Existing client code asserting on string matching for `HostFailure { reason: "unavailable" }` will observe `CallError::Unsupported`.

---

## Alternatives

- **Monolithic `system.get_capabilities` Method:** Exposing a single method returning all supported capabilities was considered. Rejected because legacy hosts drop unknown wire IDs, causing the initial capability fetch itself to hang on the very hosts requiring detection.
- **Protocol Version Negotiation Bump (`Versioned::V2`):** Bumping the root handshake version to negotiate a feature bitmap was rejected because old hosts fail the handshake completely rather than allowing graceful partial-feature fallback.
- **Client-Side Request Timeouts:** Implementing blanket client timeouts was rejected because methods requiring user interaction (biometrics, manual authorization, hardware key signing) have indefinite completion times.
- **Method Name String Queries (`Method { name: String }`):** Using method names instead of `u8` IDs in `HostFeatureSupportedRequest` was rejected to avoid wire bloat and string parsing overhead on resource-constrained embedded runtimes.

---

## Unresolved Questions

1. **Discriminant ID Assignment:** Which exact `u8` index should be assigned to `UNSUPPORTED_METHOD` in `wire_table.rs`?
2. **Implementation Introspection:** Can proc-macro code generation distinguish overridden trait methods from default trait bodies at compile time to avoid reporting `supported: true` for default `Unsupported` stubs?
3. **RFC 0002 Reconciliation:** RFC 0002 recommended returning `false` for unknown permission enum variants rather than errors. Should that recommendation be formally scoped strictly to permission evaluation?
1 change: 1 addition & 0 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/truapi/pull/354) |
| 0027 | [Method-level capability detection for protocol extension](0027-capability-detection.md) | draft | Ryan Lee | — |
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
title: "Capability probes must answer from the routed subset of the product-side wire table, gated exhaustively"
date: 2026-08-16
category: architecture-patterns
module: truapi-server
problem_type: architecture_pattern
component: tooling
severity: critical
applies_when:
- "Adding a wire-protocol capability probe or discovery query answered from a generated id table"
- "Asserting that two tables or maps are the same set by construction"
tags: wire-protocol, capability-probe, codegen, registration-gate, rfc-0027
---

# Capability probes must answer from the routed subset of the wire table, gated exhaustively

## Context

RFC 0027 adds `system.feature_supported(Method { id })`: a product probes a
wire discriminant and the host answers whether the id opens a call. The first
design answered from the generated `WIRE_TABLE` directly, assuming the table
and the dispatcher's registered handler set were "the same set by
construction" — `register()` binds every registrar unconditionally. A review
found the table contains entries the product-facing dispatcher never
registers: `#[wire(host_initiated)]` subscriptions (e.g.
`chat_custom_message_render`) are started by the host and served to the
product, so a product cannot begin a call with their ids. The probe answered
`true` for a host-initiated start id while dispatch dropped the frame — the
probe-then-hang failure RFC 0027 exists to eliminate — with the gate still
green because it sampled only one request id and one unallocated id.

## Guidance

1. When a probe asks "does this id open a call on this build", derive the
answer from the same table the dispatcher routes from — but only the
product-callable subset. Host-initiated entries must be excluded. In this
repository the generator parses `#[wire(host_initiated)]` and emits a
`host_initiated` flag on every `WireEntry` row; `method_entry_registered`
answers `false` for those rows. The platform-facing funnel forwards
`Chain` queries and answers `Method` queries in-core, so no host learns
wire discriminants.
2. Never assert a table/registration equivalence by sampling a few ids. An
equivalence claim that protects a guarantee ("advertised answers equal
routed behavior") must be asserted exhaustively: every one of the 256
possible ids, both directions, against the live registered request/start
key set (exposed by a test-only accessor on the dispatcher). A wrong
answer at any id fails the build.
3. Wire-format invariants belong in commit messages; code comments state the
current invariant. "Variant index 1; a host that cannot decode it answers
MalformedFrame" is current state; "appended after Chain so older
encodings keep their bytes" is history, and the repository forbids
migration-narrating doc comments.

## Why This Matters

A capability probe is a promise: a caller that probes `true` will send the
frame or begin the call and expects it to be served or rejected, never
silently dropped. Sampling gates pass wrong answers on the ids they do not
sample — the same false confidence the probe itself is meant to prevent.
Exhaustive comparison against the live registration surface turns the
guarantee into a build failure.

## When to Apply

- Any new discovery/probe endpoint whose answer derives from a generated id
or route table.
- Any equivalence claim ("these collections cannot diverge by construction")
between a generated table and a live registration map — verify the
construction, then pin the equivalence with a test that fails rather than
samples.
- When wire ids extend into host-initiated or host-only channels on a
product-facing protocol, mark them so callable-id answers exclude them.

## Examples

Probe semantics before/after the fix (simplified):

```rust
// BEFORE: table membership == supported (lies for host-initiated rows).
pub fn method_entry_registered(id: u8) -> bool {
WIRE_TABLE.iter().any(|entry| match entry.kind {
Request(ids) => ids.request_id == id,
Subscription(ids) => ids.start_id == id,
})
}

// AFTER: host-initiated rows (generator-emitted flag) are never callable.
pub fn method_entry_registered(id: u8) -> bool {
WIRE_TABLE.iter().any(|entry| match (entry.kind, entry.host_initiated) {
(Request(ids), _) => ids.request_id == id,
(Subscription(ids), false) => ids.start_id == id,
(Subscription(_), true) => false,
})
}
```

## Related

- docs/rfcs/0027-capability-detection.md — the RFC whose probe semantics this pattern pins
22 changes: 22 additions & 0 deletions ios/truapi-host/Sources/TrUAPIHost/truapi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5191,6 +5191,20 @@ public enum HostFeatureSupportedRequest: Equatable, Hashable {
* Chain genesis hash.
*/genesisHash: Data
)
/**
* Ask whether `id` opens a method on this host build (RFC 0027).
*
* `id` is a request discriminant or a product-facing
* subscription-start discriminant — the two frame kinds a product can
* begin a call with. Variant index 1; a host that cannot decode it
* answers `CallError::MalformedFrame`, which is RFC 0027's no-support
* signal. `Chain` stays variant index 0.
*/
case method(
/**
* Request or subscription-start discriminant from the wire table.
*/id: UInt8
)



Expand All @@ -5215,6 +5229,9 @@ public struct FfiConverterTypeHostFeatureSupportedRequest: FfiConverterRustBuffe
case 1: return .chain(genesisHash: try FfiConverterData.read(from: &buf)
)

case 2: return .method(id: try FfiConverterUInt8.read(from: &buf)
)

default: throw UniffiInternalError.unexpectedEnumCase
}
}
Expand All @@ -5227,6 +5244,11 @@ public struct FfiConverterTypeHostFeatureSupportedRequest: FfiConverterRustBuffe
writeInt(&buf, Int32(1))
FfiConverterData.write(genesisHash, into: &buf)


case let .method(id):
writeInt(&buf, Int32(2))
FfiConverterUInt8.write(id, into: &buf)

}
}
}
Expand Down
Loading