Skip to content

RemoteAdapter over a Transport ​

A RemoteAdapter turns a remote SPOKE connect peer into a drop-in async BaselinePorts surface: you supply a message-oriented Transport, the adapter dials and completes the signed-hello handshake through it, and you then call the same port methods — getKnowledgeEntry, putRelation, listTimelineEvents, and the rest — as if the peer were local. orchestrateUpsert(adapter, req) and the other orchestrate* calls run unchanged on the caller.

The adapter ships in two packages: the TypeScript @42ch/spoke-connect/remote subpath and the Rust remote-adapter cargo feature of spoke-connect. Both enforce protocol version 2 envelope authentication internally (see Envelope authentication in the Connect architecture).

1. The Transport seam ​

A Transport is a consumer-implemented seam that carries connect envelopes between the adapter and the remote peer. It is message-oriented: one call moves exactly one connect envelope.

MethodContract
send(envelope)Accepts exactly one connect envelope's bytes
recv()Returns the next inbound envelope; blocks until one arrives or the connection closes
close()Releases resources; idempotent

One envelope per call — a byte-stream carrier applies length-prefix (or equivalent) delimiting before handing envelopes to the adapter. The packages ship an in-memory loopback pair for tests: loopbackTransportPair() (TypeScript) / loopback_transport_pair() (Rust) return the client and server ends of one connection, and closing either end fails the peer's pending recv exactly like a real connection drop. The loopback pair is test-only; WebSocket and other carriers are consumer-side implementations of the same three methods.

2. TypeScript — @42ch/spoke-connect/remote ​

bash
pnpm add @42ch/[email protected]

connectRemoteAdapter dials through your transport — signed hello exchange, allowlist check, session snapshot verification — and resolves to an established adapter:

ts
import { derivePeerIdFromEd25519Pubkey, getPublicKeyEd25519 } from "@42ch/spoke-connect";
import { connectRemoteAdapter } from "@42ch/spoke-connect/remote";

const adapter = await connectRemoteAdapter({
  transport,           // your Transport implementation
  localIdentity: { seed }, // 32-byte Ed25519 seed
  localManifest,       // your HostCapabilityManifest
  remotePubkey,        // the remote peer's 32-byte Ed25519 public key
  allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)],
  invokeTimeoutMs: 5000, // optional; bounds the handshake and each invoke
});
OptionMeaning
transportYour Transport implementation; the adapter sends and receives envelopes through it
localIdentity.seed32-byte Ed25519 seed for the local connect identity
localManifestYour HostCapabilityManifest, advertised in the signed hello
remotePubkeyThe remote peer's 32-byte Ed25519 public key; the remote peer_id is derived from it and must be on the allowlist (fail-closed)
allowlistPeer ids this adapter accepts; the remote peer_id must be listed
invokeTimeoutMsOptional per-invoke timeout; on elapse only that call fails and the session stays usable (default 5000)

The established adapter exposes read-only session info — state, sessionId, remotePeerId, remoteManifest — and close():

ts
adapter.state;          // "Established"
adapter.sessionId;      // the remote-assigned session id
adapter.remotePeerId;   // the authenticated remote peer_id
adapter.remoteManifest; // the remote peer's HostCapabilityManifest

adapter.close();        // releases the session; idempotent

A dial failure — configuration error, handshake rejection, or dial timeout — rejects the connectRemoteAdapter promise; no adapter instance exists.

3. Rust — the remote-adapter feature ​

bash
cargo add spoke-connect --features remote-adapter
cargo add async-trait

The Transport trait mirrors the TypeScript interface (async fn methods, Send + Sync; close defaults to a no-op):

rust
use async_trait::async_trait;
use spoke_connect::remote::{Transport, TransportError};

#[async_trait]
impl Transport for MyTransport {
    async fn send(&self, envelope: &[u8]) -> Result<(), TransportError> {
        // deliver exactly one envelope's bytes to the peer
        Ok(())
    }

    async fn recv(&self) -> Result<Vec<u8>, TransportError> {
        // return the next inbound envelope, or Err(TransportError::Closed)
        // when the connection closes
        Ok(Vec::new())
    }

    async fn close(&self) -> Result<(), TransportError> {
        // release resources; idempotent
        Ok(())
    }
}

connect_remote_adapter performs the dial and returns Arc<RemoteAdapter>:

rust
use std::sync::Arc;
use spoke_connect::remote::{
    connect_remote_adapter, RemoteAdapterOptions, RemoteIdentity,
};

let adapter = connect_remote_adapter(RemoteAdapterOptions {
    transport: Arc::new(my_transport),
    local_identity: RemoteIdentity { seed: client_seed }, // 32-byte Ed25519 seed
    local_manifest: client_manifest,                      // your HostCapabilityManifest
    remote_pubkey: host_pubkey,                           // remote peer's 32-byte Ed25519 public key
    allowlist: vec![peer_id_host.into()],
    invoke_timeout_ms: None,  // None uses the default (5000 ms)
    capability_token: None,   // optional capability-token proof attached as `auth`
})?;

Session info comes back as Options (populated once the session establishes); close() is synchronous:

rust
adapter.state();           // RemoteAdapterState::Established
adapter.session_id();      // Option<String>
adapter.remote_peer_id();  // Option<String>
adapter.remote_manifest(); // Option<HostCapabilityManifest>

adapter.close();

4. Call the BaselinePorts methods ​

The adapter implements the same async BaselinePorts six families as a local adapter — knowledge entries, relations, scope queries, findings, rules, and the host manifest views — so orchestrate* calls run unchanged on the caller:

ts
import { orchestrateUpsert } from "@42ch/spoke-operations";

const response = await orchestrateUpsert(adapter, upsertRequest);
rust
use spoke_operations::{orchestrate_upsert, SpokeResult};

// SpokeResult is a plain Ok/Reject enum — there is no `?` support, so match
// it explicitly (the same pattern the crate's own tests use).
let response = match orchestrate_upsert(adapter.as_ref(), upsert_request).await {
    SpokeResult::Ok(response) => response,
    SpokeResult::Reject(reject) => return Err(reject), // your error path
};

You can also call the port methods directly:

ts
const put = await adapter.putKnowledgeEntry(entry, null);
const got = await adapter.getKnowledgeEntry(entry.entry_id);

Each port method maps to a reserved port.* product op (port.knowledge.put, port.relation.get, …) carried as the invoke op; the mapping is internal to the adapter. See Port-method ops in the wire reference.

Optional port families ​

Beyond the baseline six families, the adapter ships the optional l2-computable (project / compute) and l5-fork (listForkTimelineEvents) faces. They are plain port methods on the same established session — the demo client drives all three round-trips over a real WebSocket (examples/connect-demo/client/src/main.ts):

ts
    const projectedResult = requireOk(
      await adapter.project({
        session_id: COMPUTABLE_SESSION_ID,
        entry_id: COMPUTABLE_ENTRY_ID,
        state: { ...PROJECT_STATE },
      }),
    );

    const computedResult = requireOk(
      await adapter.compute({
        session_id: COMPUTABLE_SESSION_ID,
        entry_id: COMPUTABLE_ENTRY_ID,
        computable: { ...COMPUTE_DELTA },
        settle: true,
      }),
    );

    forkEvents = requireOk(
      await adapter.listForkTimelineEvents({
        scope_id: DEMO_SCOPE_ID,
        fork_id: DEMO_STORM_FORK_ID,
      }),
    );

The PROJECT_STATE and COMPUTE_DELTA constants the snippet spreads are defined in the demo client source (examples/connect-demo/client/src/main.ts): the static state the session projects and the delta compute merges.

The family must be negotiated: both manifests declare it in capabilities[], so the session's negotiated_capabilities contains it. The demo gates its optional steps on its own manifest's declarations — the negotiated set is the intersection, so a server that did not declare a family denies loudly instead of being skipped. Denials map through the shared dispatch-deny row: wire op_unsupported / capability_missing → CAPABILITY_PORT_MISSING reject with details.wire_code preserved (section 6 below).

The Rust adapter exposes the same faces as project / compute / list_fork_timeline_events; over FFI the same methods live on RemoteAdapterFFI (per-language casing in the symbol map). Serving the families on the responder side — the library ports option, the Rust RemoteServePorts seam, and the foreign-callback PortsHandler — is documented in Optional port families.

5. Remote extraction and the ownership gate ​

Two further surfaces ride the same established session, each behind its own capability flag: the extract core op delegates a whole extraction to the peer, and the ownership gate conditions the Scope-bearing ops on a reader viewpoint. Declare each flag in both peers' HostCapabilityManifest.capabilities[] — negotiated_capabilities is the both-hello intersection, so a flag both sides declared is negotiated and the invoke is served, and a flag present on one side only answers the responder's deny branch.

Remote extraction (ke-extraction) ​

extract is a core op served as a whole-operation service face: the adapter delegates the whole extraction and decodes the peer's wire ExtractResponse. The request carries source references, the serving host owns source loading and extraction, and the loader value stays host-local inside its own machinery:

ts
const result = await adapter.extract(request); // request: ExtractRequest

request is { run_id, sources, entry_types?, extensions? }: a non-empty correlation id plus a non-empty SourceAnchor list, each anchor's optional span narrowing the referenced artifact. The success branch is { candidates, run } — an empty candidates array is a successful zero-result run, every returned candidate carries status: "provisional", and run.run_id echoes the request verbatim. The Rust reference drives the same op with adapter.extract(request).await, and over FFI the method is RemoteAdapterFFI.extract(extract_request_json).

Serving extraction is a connect-owned service face: the TypeScript ports provider adds RemoteExtractService.extract(request), the Rust reference injects a RemoteExtractService (composing a mixed host with RemoteServePortsComposite::with_extract), and the FFI responder serves it through PortsHandler.extract(extract_request_json). The manifest declares capabilities and roles independently: the offering extract host announces the input-source role as descriptive metadata about that host, while ke-extraction is the capability flag that gates dispatch of the op.

The ownership gate (ke-ownership) ​

The three Scope-bearing port ops — port.scope.list_knowledge_entries, port.scope.list_timeline_events, and port.fork.list_timeline_events — additionally require ke-ownership when the request's Scope carries a non-empty viewpoint string. The gate reads payload.scope.viewpoint as supplied and treats any non-empty string as ownership-bearing, so a Scope whose viewpoint is empty or unset keeps serving under the op's row capability alone. The method call is unchanged — the existing scope query carries the gate. The Rust reference drives the same witness as adapter.list_knowledge_entries(&scope).await, and over FFI it is RemoteAdapterFFI.list_knowledge_entries(scope_json):

ts
const listed = await adapter.listKnowledgeEntries({
  scope_id: DEMO_SCOPE_ID,
  viewpoint: holderId,
});

The two flags are independent: a host can serve extraction, the ownership gate, both, or neither, and each is declared on both sides of the hello exchange. Refusals stay on the existing error vocabulary — the dispatch deny, the missing service face, a serving callback's own refusal, and the router's terminal reject are catalogued in Refusal surfaces.

The demo drives both surfaces end to end in examples/connect-demo/client/src/main.ts, against a host that declares both flags in examples/connect-demo/server/src/adapter/mock-adapter.ts.

6. Concurrency and errors ​

Concurrent port calls on one established session are allowed: outbound sequence is allocated at send time, responses demultiplex on request_id, and completions may arrive out of order. Each pending invoke carries an adapter-owned timeout; on elapse only that call fails and the session stays usable.

Port calls settle to SpokeResult; invoke-path failures surface as rejects:

FailureSurface
Transport I/OINTERNAL_ERROR reject with details.kind = "transport"
Session closed / connection lossINTERNAL_ERROR reject with details.kind = "session_closed"; the adapter transitions to Closed and every pending invoke fails
Invoke timeoutINTERNAL_ERROR reject with details.kind = "timeout" (that waiter only)
Correlation mismatchINTERNAL_ERROR reject with details.kind = "correlation_mismatch"
Sequence exhaustionINTERNAL_ERROR reject with details.kind = "sequence_exhausted"; the session closes — open a new one
Envelope-auth rejectionINTERNAL_ERROR reject with details.kind ∈ {envelope_auth_missing, envelope_auth_invalid, envelope_auth_session_unbound} (that waiter only; the session stays usable)
Dispatch deny (op_unsupported / capability_missing wire codes)CAPABILITY_PORT_MISSING reject with details.wire_code
Unknown wire codesINVALID_INPUT reject with details.wire_code

Dial / hello / allowlist / nonce failures happen before an adapter exists: connectRemoteAdapter rejects (TypeScript) or returns Err(RemoteAdapterError) with Config / Handshake / ProtocolVersionMismatch / Timeout variants (Rust).

7. Envelope authentication ​

The adapter enforces protocol version 2 per-envelope authentication internally on every post-hello envelope, with nothing to configure. Connect architecture explains the property; the wire reference holds the algorithm ids, the signed field sets, the verify rules, and the per-envelope enforcement detail.

8. Loopback smoke ​

The in-repo loopback pair gives you the whole flow with no network: the server end is served by the repository's test loopback host (tests/remote/loopback-host.ts — test-only), the client end is dialed by connectRemoteAdapter:

ts
import { derivePeerIdFromEd25519Pubkey, getPublicKeyEd25519 } from "@42ch/spoke-connect";
import { connectRemoteAdapter, loopbackTransportPair } from "@42ch/spoke-connect/remote";
// startLoopbackHost is an in-repo test fixture, NOT a package export —
// consumers write their own host (or copy this one from the linked file).
import { startLoopbackHost } from "<repo>/packages/spoke-connect-ts/tests/remote/loopback-host.ts";

const clientSeed = /* your 32-byte Ed25519 seed */;
const hostSeed = /* the remote peer's 32-byte Ed25519 seed */;

const pair = loopbackTransportPair();

// Server end (test-only): the repository's loopback host serves a local
// async BaselinePorts adapter over the server end of the pair.
const host = await startLoopbackHost({
  transport: pair.server,
  seed: hostSeed,
  clientPubkey: getPublicKeyEd25519(clientSeed),
  allowlist: [derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(clientSeed))],
  adapter: toyWorldAdapter,
  hostManifest,
});

// Client end — the shipped consumer surface.
const adapter = await connectRemoteAdapter({
  transport: pair.client,
  localIdentity: { seed: clientSeed },
  localManifest,
  remotePubkey: getPublicKeyEd25519(hostSeed),
  allowlist: [derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(hostSeed))],
});

const put = await adapter.putKnowledgeEntry(entry, null);
const got = await adapter.getKnowledgeEntry(entry.entry_id);

adapter.close();
host.close();

The same flow is the journey's terminal step: RemoteAdapter from native bindings drives the identical handshake over FFI with a foreign-callback Transport.

Next steps ​