Skip to content

Connect reference ​

Connect is the opt-in interaction envelope family (spoke-connect capability flag) for cross-process SPOKE hosts: signed manifest exchange, session context, remote op invocation, and extensible authentication. The family is additive — baseline compliance and baseline schemas stay unchanged. Field tables below trace to the committed schemas in schemas/connect/.

The six envelopes ​

ConnectHello — signed manifest exchange ​

Required: protocol_version, peer_id, nonce, host, signature, extensions.

FieldNotes
protocol_versionThe protocol_version value consumers set in a ConnectHello is 1 (the hello exchange has not been bumped; the hello signed-field set and spoke-connect-hello-jcs-v1 algorithm are unchanged). Protocol version 2 is current as the normative connect-protocol version: it adds required per-envelope signatures on the post-hello wire, enforced internally by the RemoteAdapter and never set by consumers as a field value (see Envelope authentication (protocol_version 2)). Bindings' protocolVersion() reports the hello version (1).
peer_idSender network identity — protocol v1: libp2p identity-spec PeerId string for Ed25519 (base58btc identity multihash of the protobuf PublicKey). Opaque to protocol logic; the trust root for the noise-peerid allowlist
nonceSingle-use replay nonce, bound into the signed object
peer_nonceResponder-only dial binding: the initiator's nonce, echoed by the responder and bound into its signed object. Absent in initiator hellos; initiators reject a responder hello whose peer_nonce differs from their own nonce
hostFull embedded HostCapabilityManifest (includes host.extensions); part of the signed object
signaturebase64url (no padding) of the raw signature bytes over the JCS-canonicalized signed object
extensionsProduct bag; not covered by the signature

ConnectSession — established session context ​

Required: session_id, initiator_peer_id, responder_peer_id, opened_at, negotiated_capabilities, initial_sequence, extensions (+ signature in protocol_version 2).

FieldNotes
session_idOpaque session id (UUID recommended; not schema-enforced)
initiator_peer_id / responder_peer_idThe peer that dialed / the peer that accepted; must equal the authenticated hello peer_id values
opened_atSession open time (UTC)
negotiated_capabilitiesIntersection (or agreed subset) of both hosts' capabilities[]; includes spoke-connect when both declare it
initial_sequenceFirst invoke request uses this sequence — const 0 for protocol versions 1 and 2
signaturev2 only, required, minLength 86 maxLength 86 — base64url (no padding) of the 64-byte Ed25519 signature over the JCS-canonicalized signed object (spoke-connect-session-jcs-v1); see Envelope authentication (protocol_version 2)
extensionsProduct namespace bag

ConnectInvokeRequest / ConnectInvokeResponse — remote op calls ​

ConnectInvokeRequest required: session_id, sequence, request_id, op, payload, extensions (+ signature in protocol_version 2).

FieldNotes
session_idOpaque session id
sequenceMonotonic per-session outbound from this sender; logical u64 capped at 2^53−1 (JSON-safe)
request_idCaller-generated correlation id (UUID recommended)
opOpen vocabulary. Core list (documented, not enforced): upsert, promote, relate, check, assemble, extract, project, compute; reserved port.* prefix for RemoteAdapter port methods (see Port-method ops (RemoteAdapter); the extract core op is documented in Knowledge extraction and ownership (RemoteAdapter))
payloadOpaque JSON — a full existing ops request envelope for the named op when targeting SPOKE ops
authOptional mid-session proof blob; primary auth is the hello. Shape is method-specific when used. When present on protocol_version 2 wire, auth is included in the JCS signed object
signaturev2 only, required, minLength 86 maxLength 86 — base64url (no padding) of the 64-byte Ed25519 signature over the JCS-canonicalized signed object (spoke-connect-invoke-request-jcs-v1); see Envelope authentication (protocol_version 2)
extensionsProduct namespace bag

ConnectInvokeResponse is the success { payload } or { error } — the same one-failure dialect as the ops wire; failures reuse the shared ErrorEnvelope. Both branches add a required signature in protocol_version 2:

Branchv2 signature
Success { session_id, sequence, request_id, payload, extensions }Required, minLength 86 maxLength 86 — spoke-connect-invoke-response-jcs-v1 over {session_id, sequence, request_id, payload}
Error { session_id, sequence, request_id, error, extensions }Required, minLength 86 maxLength 86 — spoke-connect-invoke-response-jcs-v1 over {session_id, sequence, request_id, error}

ConnectAuthChallenge / ConnectAuthResponse — extensible auth ​

ConnectAuthChallenge required: challenge_id, method, challenge, extensions. ConnectAuthResponse required: challenge_id, method, proof, extensions.

FieldNotes
challenge_idCorrelation id echoed by the response
methodOpen vocabulary. Core list (documented, not enforced): noise-peerid, capability-token; reserved name: did
challenge / proofOpaque method-specific material; for capability-token, proof is { v, claims, sig } with sig over JCS(claims) only

Identity ​

peer_id is the network trust root: the libp2p identity-spec PeerId (Ed25519, base58btc identity multihash). host_id is an advisory application label inside the embedded HostCapabilityManifest. The two stay distinct — one host may present multiple peer ids over time, and one peer id derives from exactly one Ed25519 public key.

Signed hello (spoke-connect-hello-jcs-v1) ​

  1. Both sides of a connection exchange a signed ConnectHello.
  2. The signed object is {protocol_version, peer_id, nonce, host} for the initiator hello (4 fields — peer_nonce absent), and {protocol_version, peer_id, nonce, host, peer_nonce} for the responder hello (5 fields — peer_nonce = the initiator's nonce, dial binding). Top-level extensions and signature are excluded.
  3. The object is canonicalized with RFC 8785 JCS (RFC 8785).
  4. The bytes are signed with the Ed25519 keypair; the raw signature is encoded base64url without padding (RFC 4648 §5).
  5. The receiver accepts only when: protocol version is 1, the claimed peer_id equals the authenticated remote peer, the key derives that peer id, the peer is on the configured allowlist (empty allowlist rejects all — fail-closed), the signature verifies over the role-aware field set, and the (peer_id, nonce) pair is new (single-use, process lifetime).
  6. Dial binding: the initiator additionally requires the responder's signed peer_nonce to equal its own nonce — a captured responder hello cannot be replayed into a fresh dial (e.g. after a client restart resets the in-memory nonce store).

Envelope authentication (protocol_version 2) ​

Protocol version 2 authenticates every post-hello trust-affecting envelope — ConnectSession, ConnectInvokeRequest, ConnectInvokeResponse — at the protocol layer using per-envelope JCS + Ed25519 signed-field sets, the same construction as spoke-connect-hello-jcs-v1 extended to three new algorithm ids. Receivers verify envelope authenticity at the protocol layer, independent of transport-level TLS or Noise; a transport-supplied authenticated peer identity does not relax the rules.

Algorithm ids ​

Algorithm idEnvelope
spoke-connect-hello-jcs-v1ConnectHello (unchanged)
spoke-connect-session-jcs-v1ConnectSession
spoke-connect-invoke-request-jcs-v1ConnectInvokeRequest
spoke-connect-invoke-response-jcs-v1ConnectInvokeResponse

All four share the construction: RFC 8785 JCS → UTF-8 bytes → Ed25519 sign/verify → base64url without padding. The signing key is the sender's peer-identity Ed25519 private key; verification uses the public key that derives the authenticated hello peer_id.

Authenticated field sets ​

Each signed object is a strict subset of the wire envelope (exact keys, no others). extensions and signature are excluded — extensions are not covered by the signature and stay outside trust decisions:

  • ConnectSession: {session_id, initiator_peer_id, responder_peer_id, opened_at, negotiated_capabilities, initial_sequence}
  • ConnectInvokeRequest: {session_id, sequence, request_id, op, payload} and additionally auth when present on the wire
  • ConnectInvokeResponse success branch: {session_id, sequence, request_id, payload}
  • ConnectInvokeResponse error branch: {session_id, sequence, request_id, error}

The two response branches are signed over their respective field sets; the signature field must be the canonical base64url (no padding) encoding of the 64 raw signature bytes.

Verify rules ​

For every v2 post-hello envelope the receiver: (1) presence-checks signature; (2) runs the canonical-encoding round-trip check; (3) builds the signed object per the locked field set; (4) canonicalizes with RFC 8785 JCS; (5) verifies with the peer's hello Ed25519 public key; (6) asserts the session binding (session_id bound to an established session, peer ids matching the authenticated hellos). Any failure rejects the envelope. Signatures bind to the session: a session_id not bound to an established session rejects, and an envelope captured from one session replayed into another fails verification against the other session's hello key.

Version strategy ​

DirectionBehavior
v2 peer ↔ v1 peerThe v2 side observes protocol_version: 1 in the verified hello and refuses to establish — the v1 side cannot produce signed session/invoke envelopes. Dial fails closed
Both v2Session establishes under v2 rules; all post-hello envelopes carry the required signature
Both v1Legacy v1 interop only
Unknown version (> 2)A hello advertising an unknown version fails closed at the version gate — the version check is hello verification step 1, before signature verification — treated like a mixed-version dial

The hello signed-field set (4-field initiator / 5-field responder) is unchanged; the dial-binding peer_nonce rule is preserved.

Error mapping ​

Envelope-auth failures use the shared ErrorEnvelope vocabulary: auth_failed covers missing, invalid, non-canonical, or field-set-drifted signatures and session-binding mismatches. On the RemoteAdapter surface these surface as SpokeResult rejects — INTERNAL_ERROR with details.kind ∈ {envelope_auth_missing, envelope_auth_invalid, envelope_auth_session_unbound} — while a mixed- or unknown-version hello fails the dial with the dedicated kind: RemoteAdapterError::ProtocolVersionMismatch (Rust) / CoreError with code: "protocol_version_mismatch" (TS), surfaced over FFI as FfiError.Dial with kind: "protocol_version_mismatch"; no adapter instance exists. The version gate is hello verification step 1 — before signature verification — so the dedicated kind fires on any mismatched-version hello, valid signature or not.

Enforcement ​

The RemoteAdapter (./remote subpath in TypeScript, remote-adapter feature in Rust) and the connect-client enforce v2 per-envelope authentication internally on every post-hello envelope they emit or accept, with nothing to configure: the dial verifies the signed ConnectSession snapshot at establish, every outbound invoke request is signed, and every correlated response is verified after the correlation echo check. The hello exchange remains at protocol version 1. ConnectAuthChallenge / ConnectAuthResponse carry method-specific proofs that are already signature-bound; they are outside the v2 per-envelope signing.

Ordering and correlation ​

Per-session, per-direction monotonic sequence counters start at 0; a sequence overflow closes the session and opens a new one. Invoke responses echo session_id / sequence / request_id — the correlation check fails on any mismatch. The receiver enforces inbound sequence monotonicity and answers replayed or out-of-order sequences with an invalid_sequence wire envelope.

Session-core state machine ​

The session core tracks one logical state per local node per session:

StateMeaning
DisconnectedNo transport session; no outbound sequence for this session
HandshakingTransport up; hellos in flight; invokes not yet authorized
EstablishedBoth hellos accepted; session_id assigned; outbound counter = 0; inbound expected = 0
ClosedSession unusable (sequence exhaustion, transport loss, auth failure, local shutdown); open a new session — no sequence wrap
TransitionTriggerGuards / effects
Disconnected → HandshakingTransport connect/accept—
Handshaking → EstablishedLocal accept of remote hello and remote accept of local helloAllowlist + signature + nonce single-use; dial binding (responder's signed peer_nonce = initiator's nonce); session peer ids bound to the authenticated hello peer_ids; negotiated_capabilities = agreed subset; outbound counter = 0 and inbound expected = 0
Handshaking → ClosedAny hello gate failureNonce of a rejected hello is not recorded
Established → EstablishedOutbound invokeAtomically allocate sequence = last + 1 starting at 0; attach a new request_id; send
Established → EstablishedInbound invokeAccept iff sequence == next_expected_inbound (start 0), then advance; else reject with a wire error and no handler side effect
Established → EstablishedInbound responseAccept iff it echoes session_id, sequence, and request_id of a pending request; else correlation failure
Established → ClosedNext outbound sequence would exceed 2^53−1, transport loss, or local shutdownNo wrap-around

On a v2 wire, sequence/correlation checks run first but do not advance session state until envelope-auth verification passes (see Envelope authentication (protocol_version 2)).

Auth methods ​

MethodHow it works
noise-peeridThe handshake default: allowlist admission plus the signed hello, with the remote peer authenticated by the transport (noise)
capability-tokenStep-up / mid-session grant: a trusted issuer signs a short claim set (iss / sub / aud / capabilities / exp, optional iat / jti) over JCS with Ed25519; the proof rides the challenge/response exchange or per-invoke auth. Verification enforces issuer trust, subject/audience binding, expiry, and clock skew. An empty trusted-issuer list disables the method (fail-closed)

Capability vocabulary ​

Each operation maps to the capability it requires on the session's negotiated_capabilities (the dispatch gate evaluates the negotiated set, not the remote manifest alone):

OperationRequired capability
upsert, promote, relate, check, assemble (and the port.* baseline ops)spoke-baseline
extractke-extraction
project, compute (and port.computable.*)l2-computable
listForkTimelineEvents (and port.fork.*)l5-fork
port.scope.list_knowledge_entries, port.scope.list_timeline_events, port.fork.list_timeline_events carrying a non-empty scope.viewpointThe row capability plus ke-ownership
Product-defined operationsThe capability the product documents

A capability-token grant authorizes session membership for the ops its capabilities[] covers, but it does not replace negotiated_capabilities — both the token grant and the negotiated set must allow an op when the token gate is active.

Port-method ops (RemoteAdapter) ​

The RemoteAdapter proxies each BaselinePorts method as a connect invoke with a reserved port.* product op and an opaque snake_case payload:

MethodopRequest payloadSuccess payload
getKnowledgeEntryport.knowledge.get{ "entry_id": string }KnowledgeEntry
putKnowledgeEntryport.knowledge.put{ "entry": KnowledgeEntry, "expected_base_revision": number | null }KnowledgeEntry
getRelationport.relation.get{ "relation_id": string }Relation
putRelationport.relation.put{ "relation": Relation, "expected_base_revision": number | null }Relation
listKnowledgeEntriesport.scope.list_knowledge_entries{ "scope": Scope }KnowledgeEntry[]
listTimelineEventsport.scope.list_timeline_events{ "scope": Scope }TimelineEvent[]
putFindingsport.finding.put{ "findings": Finding[] }Finding[]
listRulesport.rule.list{ "rule_refs": string[] }Rule[]
listPeerHostCapabilityManifestsport.host.list_peer_manifests{}HostCapabilityManifest[]
projectport.computable.project{ "session_id": string, "entry_id": string, "state": ComputableFieldMap }ProjectResponse
computeport.computable.compute{ "session_id": string, "entry_id": string, "computable": ComputableFieldMap, "settle": boolean }ComputeResponse
listForkTimelineEventsport.fork.list_timeline_events{ "scope": { "scope_id": string, "fork_id": string } }TimelineEvent[]
getHostCapabilityManifest(none — session cache)—The remote hello host, cached at establish; no round-trip

The optional l2-computable (project / compute) and l5-fork (listForkTimelineEvents) families ship in the same catalogue, capability-gated like the baseline rows — see Optional port families for the declare / serve / drive / deny contract across the library and the native bindings.

Optional port families ​

Two optional families extend the port catalogue beyond spoke-baseline: l2-computable (project / compute sessions) and l5-fork (fork-branch timeline queries). They follow the same port pattern as the baseline rows — a manifest-declared capability, a responder-served provider face, and a RemoteAdapter proxy method — with one extra rule: the family must be negotiated. Both manifests must declare it in capabilities[], so the session's negotiated_capabilities (the both-hello intersection) contains it; the responder's dispatch gate evaluates the negotiated set, and a family only one side declared is denied like an unknown op.

Declare ​

FamilyCapabilityOpsProvider face
Computable sessionsl2-computableproject / computeComputablePort
Fork timelinesl5-forklistForkTimelineEventsForkTimelineQueryPort

The provider faces are the operations-crate contracts (@42ch/spoke-operations); the composed FullPorts type is BaselinePorts & ComputablePort & ForkTimelineQueryPort.

Serve (responder) ​

The responder serves a port.* invoke through its injected ports provider. Optional families ride the same seam with a structural probe before dispatch — gate → probe → serve/deny:

FaceShape
TypeScript — connectResponder({ ports })A ports provider implementing BaselinePorts plus the optional methods (project / compute / listForkTimelineEvents); the composed FullPorts type covers all twelve serve methods
Rust — ConnectResponderOptions.portsArc<dyn RemoteServePorts>: the blanket impl serves every family when the provider implements BaselinePorts + ComputablePort + ForkTimelineQueryPort; mixed hosts compose via RemoteServePortsComposite::new(baseline, computable, fork), passing None for faces they do not provide
FFI — connect_responder_ffi ports argumentOptional foreign-callback PortsHandler implementing the port catalogue below plus the extract service face; see the callback table below
FFI PortsHandler methodFamilyServes op
get_knowledge_entry(entry_id)baselineport.knowledge.get
put_knowledge_entry(entry_json, expected_base_revision)baselineport.knowledge.put
get_relation(relation_id) / put_relation(relation_json, expected_base_revision)baselineport.relation.get / port.relation.put
list_knowledge_entries(scope_json) / list_timeline_events(scope_json)baselineport.scope.list_knowledge_entries / port.scope.list_timeline_events
put_findings(findings_json)baselineport.finding.put
list_rules(rule_refs)baselineport.rule.list
list_peer_host_capability_manifests()baselineport.host.list_peer_manifests
project(project_request_json)l2-computableport.computable.project
compute(compute_request_json)l2-computableport.computable.compute
list_fork_timeline_events(scope_json)l5-forkport.fork.list_timeline_events
extract(extract_request_json)ke-extractionextract

Each PortsHandler method takes the request payload as a JSON string and returns the success payload as a JSON string. get_host_capability_manifest is not in the catalogue — it is the session cache, never served through the ports handler. A method that does not serve an op raises FfiError.Rejected, which passes through to the invoker as an application reject; an absent ports handler (constructor ports = None) keeps the documented deny branch for every port.* invoke.

Drive (adapter) ​

The RemoteAdapter proxies the three optional ops on the established session like any baseline method:

FaceMethods
TypeScript RemoteAdapterproject(request) / compute(request) / listForkTimelineEvents(scope) — typed requests, SpokeResult settles
Rust RemoteAdapterproject(request) / compute(request) / list_fork_timeline_events(scope) — the same contract
FFI RemoteAdapterFFIproject(project_request_json) / compute(compute_request_json) / list_fork_timeline_events(scope_json) — JSON in / JSON out

Deny mapping ​

Optional-port denials surface through the same row as every other dispatch deny: the responder gate answers the wire code op_unsupported (family not negotiated — neither side declared it, or only one did) and the RemoteAdapter maps it to a CAPABILITY_PORT_MISSING reject with details.wire_code = "op_unsupported"; over FFI the same row is FfiError.Rejected with code: "CAPABILITY_PORT_MISSING" and the preserved wire_code. A host that declared a family but serves no provider face for it — absent ports, or a probe-missing method — answers the same deny branch, so the caller always observes the denial, never a silent success.

Knowledge extraction and ownership (RemoteAdapter) ​

Two capabilities extend the adapter beyond the port catalogue: ke-extraction for the extract core op, and ke-ownership for the Scope-bearing ops that carry a reader viewpoint. Both are ordinary capability strings declared in both peers' HostCapabilityManifest.capabilities[], so the session's negotiated_capabilities (the both-hello intersection) must contain the flag before the op is served. The manifest declares capabilities and roles independently: the offering extract host announces the input-source role, which is descriptive metadata about that host, while ke-extraction and ke-ownership are the capability flags that gate dispatch.

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:

FaceMethod
TypeScript RemoteAdapterextract(request) — a typed ExtractRequest in, SpokeResult<ExtractResponse> out
Rust RemoteAdapterextract(request) — the same contract
FFI RemoteAdapterFFIextract(extract_request_json) — ExtractRequest JSON in, ExtractResponse success-branch JSON out
Wire positionShape
opextract
Request payloadThe ExtractRequest itself — { run_id, sources, entry_types?, extensions? } verbatim. run_id is a non-empty correlation id, sources a non-empty SourceAnchor list whose per-anchor optional span narrows the referenced artifact, and the payload carries those references with source content staying host-local
Success payloadThe ExtractResponse success branch — { candidates, run }. An empty candidates array is a successful zero-result run; every returned candidate carries status: "provisional"; run.run_id echoes the request verbatim, and run.method / run.coverage_hint are the product's advisory run metadata
ErrorThe ExtractResponse error branch reuses the shared ErrorEnvelope and travels the application reject path

The serving host owns source loading and extraction: the loader value stays host-local in its port, and the requester needs only the negotiated flag. Serving is a connect-owned service face — TypeScript RemoteExtractService.extract(request) composed into the responder's ports (BaselinePorts & Partial<RemoteExtractService>, structurally probed as a function-valued ports.extract), Rust RemoteExtractService probed with RemoteServePorts::as_extract and opted into a mixed host through RemoteServePortsComposite::with_extract, and over FFI the PortsHandler.extract(extract_request_json) callback bridged by into_remote_serve_ports. Serving order is gate → probe → serve/deny, so a host that declares ke-extraction and serves no extract service answers the same deny branch as a host that serves no ports face.

The ownership gate (ke-ownership) ​

The three Scope-bearing ops — port.scope.list_knowledge_entries, port.scope.list_timeline_events, port.fork.list_timeline_events — require ke-ownership in addition to their row capability when payload.scope.viewpoint is a non-empty string. The gate reads payload.scope.viewpoint as supplied and treats any non-empty string as ownership-bearing, so every other Scope shape keeps the op on its row capability alone. A malformed Scope stays the existing INVALID_INPUT reject. viewpoint is a reader context supplied in the request payload, and the entries and governance values the query returns are unchanged.

The requirement is a capability: it is evaluated at the remote dispatch boundary from the request payload, and the dispatch table applies it to the three Scope-bearing rows above. The responder applies it as a supplementary gate after the row-capability gate and before probing or calling a provider; the router applies it as a hard capability filter during peer selection, so peer advertisement supplies the filter's input and a selected peer applies its own gate and can still refuse. The dialing adapter sends the request straight to the peer and surfaces the peer's denial.

Refusal surfaces ​

Every refusal settles through the existing error vocabulary:

Refusal originObserved reject
The required capability sits outside the negotiated set (ke-extraction, or ke-ownership on a viewpoint-bearing Scope)The dispatch deny: wire op_unsupported mapped to CAPABILITY_PORT_MISSING with details.wire_code = "op_unsupported"
The flag is negotiated and the host serves no service face for the op (an extract request against a host that serves no extract service, or an optional family whose provider method is unset)The same dispatch-deny branch
A serving callback declines the invoke itselfThe callback's application reject passes through verbatim — when the callback declines extraction that code is CAPABILITY_PORT_MISSING and wire_code stays unset, so a refused extraction stays distinguishable from a missing capability
The router finds the required capability unadvertised across its peer set — a viewpoint-bearing Scope requestThe existing local terminal reject: CAPABILITY_PORT_MISSING with wire_code = kind = no_capable_peer

Tools (reverse invokes) ​

The manifest's tools[] (embedded in the hello host) declares the tool ABIs a peer can serve from the session. A tools.* invoke is a normal signed ConnectInvokeRequest in the reverse direction — the op string IS the capability string — and the surface is symmetric: either side of an established session registers handlers for its own declared tools and can invoke the peer's declared tools with the same invokeTool face. The demo and the reference provider (fixtures/toy-world/) ship byte-identical descriptors for tools.toy_world.roll_dice and tools.toy_world.lore_lookup.

Manifest tools[] field table ​

Each entry in HostCapabilityManifest.tools is a ToolDescriptor (schemas/data/tool-descriptor.schema.json):

FieldTypeNotes
schema_versionnumberThe shared SchemaVersion
capability_idstringThe tool capability string tools.<ns>.<tool_id> matching ^tools\.[a-z][a-z0-9_-]*\.[a-z0-9][a-z0-9_-]*$; the namespace must be owned by the declaring manifest (namespaces[] membership)
opstringThe wire op for this tool; MUST equal capability_id (draft-07 cannot express cross-field equality, so the helpers enforce it)
descriptionstringHuman-readable tool description
inputobjectOpaque JSON Schema draft-07 subschema describing the tool arguments; an empty object {} declares no constraint
outputobjectOpaque JSON Schema draft-07 subschema describing the success result; an empty object {} declares no constraint
idempotentbooleanAdvisory idempotency metadata (default false); the protocol defines no idempotency-key machinery

validateManifestTools (spoke-operations) checks a manifest's tools[] against the manifest itself: each descriptor is valid, its capability_id appears in capabilities[], its namespace is owned in namespaces[], and tool ids are unique. listTools returns the descriptors in declaration order. Both helpers are pure functions — the library does not call them automatically; the demo host runs them on the dialer's manifest at discovery time, and integrators should call them wherever they gate on a manifest.

Tool invocation helpers ​

@42ch/spoke-operations ships three helpers for the invoke path (Rust twins validate_tool_arguments / ToolInvokePort / orchestrate_invoke_tool in the spoke-operations crate):

  • validateToolArguments(descriptor, args) — structural argument gate with frozen granularity: args must be a JSON object; when descriptor.input declares top-level "type": "object" with a "required": [...] list, every listed key must be present in args. It rejects with INVALID_INPUT and details.field ("arguments" for a non-object payload, "input" for a malformed subschema) plus details.missing for absent keys; input: {} is a vacuous pass (unconstrained). There is no deeper JSON-Schema checking — full validation stays consumer- or fixture-side.
  • ToolInvokePort — optional injection seam for remote tool invocation: invokeTool(request) with ToolInvokeRequest { capability_id, arguments } resolving to SpokeResult<ToolInvokeResponse { result }>. The family is standalone — not folded into BaselinePorts, and capability gating is per-tool (the capability string itself). The port does not re-validate request arguments: callers run validateToolArguments before invoking.
  • orchestrateInvokeTool(port, request) — the frozen orchestration sequence: (1) capability-id grammar gate (parseToolCapabilityId) → INVALID_INPUT; (2) runtime port guard (null/undefined port or a structurally missing invokeTool) → CAPABILITY_PORT_MISSING with details.capability = request.capability_id; (3) port.invokeTool(request) returned as-is. Argument validation is not re-run here — only request grammar is validated.

The tools.* dispatch rule ​

A tools.* invoke dispatches when both conditions hold:

  1. Negotiated — the op string itself is in the session's negotiated_capabilities; both peers declared the tool's capability id, so the pair negotiated it. The tools family is self-describing: it does not ride on spoke-baseline.
  2. Registered — the serving side has a handler registered for the exact capability id (registerToolHandler on the RemoteAdapter or the responder).

Both gates are fail-closed: an unnegotiated tool answers the dispatch-deny code op_unsupported, and a negotiated tool with no registered handler answers op_unsupported (handler-or-deny serving). A throwing handler answers the error branch through toErrorEnvelope; the serve loop never crashes.

The request payload carries the tool arguments as { "arguments": <opaque JSON> }; the success payload is { "result": <opaque JSON> } — invokeTool extracts the result and rejects a success payload without one.

Reverse-invoke semantics ​

invokeTool(capabilityId, args) issues a signed ConnectInvokeRequest with op = capabilityId toward the peer and resolves with the tool's result. Deny answers map through the shared error row: op_unsupported / capability_missing → CAPABILITY_PORT_MISSING reject with details.wire_code preserved — the caller observes the denial, never a silent success. The face exists on both the RemoteAdapter and the responder (connectResponder): the host invokes the dialer's tools mid-orchestration with the responder's face, and a dialer invokes the host's declared tools with the adapter's face. A non-tools. id fails fast (grammar error, INVALID_INPUT). The handler registry does not mutate the manifest — descriptor truth for discovery stays in tools[]. A declared-but-unregistered tool passes validateManifestTools — registration is not part of the manifest — and is denied at invoke time with CAPABILITY_PORT_MISSING.

Discovery and peering ​

Explicit peering is the production path: hosts are configured with listen addresses and dial each other out-of-band (configured addresses or direct dial). The connect wire carries no discovery fields — discovery is transport-side and session admission stays fully gated by the allowlist and signed-hello gates.

Transport ​

One JSON connect envelope per message over an ordered, reliable, bidirectional byte stream (TCP, WebSocket, yamux, libp2p request-response). Framing delimiters, retries, and payload limits are transport-adapter-owned.

Embedding model ​

EmbeddingWhat ships
Language-native clientThe wire contract and session-core rules implemented in the host language (the TypeScript @42ch/spoke-connect client with WebSocket transport)
Native bindingsThe shared session core exported into host languages via FFI (C# NuGet, Kotlin Maven, Swift SPM, Go modules, Python PyPI, C/C++ git)
Rust referenceThe published spoke-connect crate: session-core reference, uniffi binding source, and a rust-libp2p transport stack (crate README)

The session-core rules — allowlist, peer_id derive and reverse, hello crypto, nonce, request correlation, sequence, capability-token auth, and the dispatch gate — are shared across every language and locked by golden vectors. Thin client conveniences (Session, negotiatedCapabilities, generateNonce) are provided where the host runtime benefits from them.

Error vocabulary ​

EntryWhere it appearsMeaning
auth_failedErrorEnvelope.codeToken auth failures (missing or invalid token when required; signature / issuer / audience / subject / expiry / malformed proof) and all envelope-auth failures (missing, invalid, or non-canonical signature; field-set drift; session-binding mismatch)
invalid_sequenceErrorEnvelope.codeReplayed or out-of-order inbound sequence
op_unsupportedErrorEnvelope.codeUnknown op, or a token valid but without the capability for the requested op
capability_missingErrorEnvelope.codeThe op's required capability is absent from the effective grant
no_capable_peerRouter reject details.wire_code / details.kindTerminal router reject when no registered peer passes the hard selection gates (CAPABILITY_PORT_MISSING); register a satisfying peer and re-invoke
envelope_auth_missing / envelope_auth_invalid / envelope_auth_session_unboundRemoteAdapter reject details.kindEnvelope-auth rejection kinds on INTERNAL_ERROR rejects (waiter only; session state untouched)
handshakeDial failure details.kind (FfiError.Dial)Hello signature / identity / nonce verification failure (version mismatches surface as protocol_version_mismatch); the dial surface is {config, handshake, timeout, protocol_version_mismatch} and no adapter instance exists
protocol_version_mismatchDial failure details.kind (FfiError.Dial)A hello advertising a mixed or unknown protocol version fails the dial with the dedicated kind — RemoteAdapterError::ProtocolVersionMismatch / CoreError("protocol_version_mismatch"); no adapter instance exists
transport / session_closed / timeout / panic / correlation_mismatch / sequence_exhaustedRemoteAdapter reject details.kindINTERNAL_ERROR reject kinds for transport I/O, session loss, invoke timeout, panic containment, correlation mismatch, and sequence exhaustion