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.
| Field | Notes |
|---|---|
protocol_version | The 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_id | Sender 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 |
nonce | Single-use replay nonce, bound into the signed object |
peer_nonce | Responder-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 |
host | Full embedded HostCapabilityManifest (includes host.extensions); part of the signed object |
signature | base64url (no padding) of the raw signature bytes over the JCS-canonicalized signed object |
extensions | Product 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).
| Field | Notes |
|---|---|
session_id | Opaque session id (UUID recommended; not schema-enforced) |
initiator_peer_id / responder_peer_id | The peer that dialed / the peer that accepted; must equal the authenticated hello peer_id values |
opened_at | Session open time (UTC) |
negotiated_capabilities | Intersection (or agreed subset) of both hosts' capabilities[]; includes spoke-connect when both declare it |
initial_sequence | First invoke request uses this sequence — const 0 for protocol versions 1 and 2 |
signature | v2 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) |
extensions | Product namespace bag |
ConnectInvokeRequest / ConnectInvokeResponse — remote op calls
ConnectInvokeRequest required: session_id, sequence, request_id, op, payload, extensions (+ signature in protocol_version 2).
| Field | Notes |
|---|---|
session_id | Opaque session id |
sequence | Monotonic per-session outbound from this sender; logical u64 capped at 2^53−1 (JSON-safe) |
request_id | Caller-generated correlation id (UUID recommended) |
op | Open 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)) |
payload | Opaque JSON — a full existing ops request envelope for the named op when targeting SPOKE ops |
auth | Optional 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 |
signature | v2 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) |
extensions | Product 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:
| Branch | v2 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.
| Field | Notes |
|---|---|
challenge_id | Correlation id echoed by the response |
method | Open vocabulary. Core list (documented, not enforced): noise-peerid, capability-token; reserved name: did |
challenge / proof | Opaque 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)
- Both sides of a connection exchange a signed
ConnectHello. - The signed object is
{protocol_version, peer_id, nonce, host}for the initiator hello (4 fields —peer_nonceabsent), and{protocol_version, peer_id, nonce, host, peer_nonce}for the responder hello (5 fields —peer_nonce= the initiator's nonce, dial binding). Top-levelextensionsandsignatureare excluded. - The object is canonicalized with RFC 8785 JCS (RFC 8785).
- The bytes are signed with the Ed25519 keypair; the raw signature is encoded base64url without padding (RFC 4648 §5).
- The receiver accepts only when: protocol version is 1, the claimed
peer_idequals 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). - Dial binding: the initiator additionally requires the responder's signed
peer_nonceto 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 id | Envelope |
|---|---|
spoke-connect-hello-jcs-v1 | ConnectHello (unchanged) |
spoke-connect-session-jcs-v1 | ConnectSession |
spoke-connect-invoke-request-jcs-v1 | ConnectInvokeRequest |
spoke-connect-invoke-response-jcs-v1 | ConnectInvokeResponse |
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 additionallyauthwhen present on the wireConnectInvokeResponsesuccess branch:{session_id, sequence, request_id, payload}ConnectInvokeResponseerror 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
| Direction | Behavior |
|---|---|
| v2 peer ↔ v1 peer | The 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 v2 | Session establishes under v2 rules; all post-hello envelopes carry the required signature |
| Both v1 | Legacy 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:
| State | Meaning |
|---|---|
Disconnected | No transport session; no outbound sequence for this session |
Handshaking | Transport up; hellos in flight; invokes not yet authorized |
Established | Both hellos accepted; session_id assigned; outbound counter = 0; inbound expected = 0 |
Closed | Session unusable (sequence exhaustion, transport loss, auth failure, local shutdown); open a new session — no sequence wrap |
| Transition | Trigger | Guards / effects |
|---|---|---|
Disconnected → Handshaking | Transport connect/accept | — |
Handshaking → Established | Local accept of remote hello and remote accept of local hello | Allowlist + 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 → Closed | Any hello gate failure | Nonce of a rejected hello is not recorded |
Established → Established | Outbound invoke | Atomically allocate sequence = last + 1 starting at 0; attach a new request_id; send |
Established → Established | Inbound invoke | Accept iff sequence == next_expected_inbound (start 0), then advance; else reject with a wire error and no handler side effect |
Established → Established | Inbound response | Accept iff it echoes session_id, sequence, and request_id of a pending request; else correlation failure |
Established → Closed | Next outbound sequence would exceed 2^53−1, transport loss, or local shutdown | No 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
| Method | How it works |
|---|---|
noise-peerid | The handshake default: allowlist admission plus the signed hello, with the remote peer authenticated by the transport (noise) |
capability-token | Step-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):
| Operation | Required capability |
|---|---|
upsert, promote, relate, check, assemble (and the port.* baseline ops) | spoke-baseline |
extract | ke-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.viewpoint | The row capability plus ke-ownership |
| Product-defined operations | The 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:
| Method | op | Request payload | Success payload |
|---|---|---|---|
getKnowledgeEntry | port.knowledge.get | { "entry_id": string } | KnowledgeEntry |
putKnowledgeEntry | port.knowledge.put | { "entry": KnowledgeEntry, "expected_base_revision": number | null } | KnowledgeEntry |
getRelation | port.relation.get | { "relation_id": string } | Relation |
putRelation | port.relation.put | { "relation": Relation, "expected_base_revision": number | null } | Relation |
listKnowledgeEntries | port.scope.list_knowledge_entries | { "scope": Scope } | KnowledgeEntry[] |
listTimelineEvents | port.scope.list_timeline_events | { "scope": Scope } | TimelineEvent[] |
putFindings | port.finding.put | { "findings": Finding[] } | Finding[] |
listRules | port.rule.list | { "rule_refs": string[] } | Rule[] |
listPeerHostCapabilityManifests | port.host.list_peer_manifests | {} | HostCapabilityManifest[] |
project | port.computable.project | { "session_id": string, "entry_id": string, "state": ComputableFieldMap } | ProjectResponse |
compute | port.computable.compute | { "session_id": string, "entry_id": string, "computable": ComputableFieldMap, "settle": boolean } | ComputeResponse |
listForkTimelineEvents | port.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
| Family | Capability | Ops | Provider face |
|---|---|---|---|
| Computable sessions | l2-computable | project / compute | ComputablePort |
| Fork timelines | l5-fork | listForkTimelineEvents | ForkTimelineQueryPort |
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:
| Face | Shape |
|---|---|
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.ports | Arc<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 argument | Optional foreign-callback PortsHandler implementing the port catalogue below plus the extract service face; see the callback table below |
FFI PortsHandler method | Family | Serves op |
|---|---|---|
get_knowledge_entry(entry_id) | baseline | port.knowledge.get |
put_knowledge_entry(entry_json, expected_base_revision) | baseline | port.knowledge.put |
get_relation(relation_id) / put_relation(relation_json, expected_base_revision) | baseline | port.relation.get / port.relation.put |
list_knowledge_entries(scope_json) / list_timeline_events(scope_json) | baseline | port.scope.list_knowledge_entries / port.scope.list_timeline_events |
put_findings(findings_json) | baseline | port.finding.put |
list_rules(rule_refs) | baseline | port.rule.list |
list_peer_host_capability_manifests() | baseline | port.host.list_peer_manifests |
project(project_request_json) | l2-computable | port.computable.project |
compute(compute_request_json) | l2-computable | port.computable.compute |
list_fork_timeline_events(scope_json) | l5-fork | port.fork.list_timeline_events |
extract(extract_request_json) | ke-extraction | extract |
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:
| Face | Methods |
|---|---|
TypeScript RemoteAdapter | project(request) / compute(request) / listForkTimelineEvents(scope) — typed requests, SpokeResult settles |
Rust RemoteAdapter | project(request) / compute(request) / list_fork_timeline_events(scope) — the same contract |
FFI RemoteAdapterFFI | project(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:
| Face | Method |
|---|---|
TypeScript RemoteAdapter | extract(request) — a typed ExtractRequest in, SpokeResult<ExtractResponse> out |
Rust RemoteAdapter | extract(request) — the same contract |
FFI RemoteAdapterFFI | extract(extract_request_json) — ExtractRequest JSON in, ExtractResponse success-branch JSON out |
| Wire position | Shape |
|---|---|
op | extract |
Request payload | The 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 payload | The 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 |
| Error | The 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 origin | Observed 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 itself | The 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 request | The 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):
| Field | Type | Notes |
|---|---|---|
schema_version | number | The shared SchemaVersion |
capability_id | string | The 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) |
op | string | The wire op for this tool; MUST equal capability_id (draft-07 cannot express cross-field equality, so the helpers enforce it) |
description | string | Human-readable tool description |
input | object | Opaque JSON Schema draft-07 subschema describing the tool arguments; an empty object {} declares no constraint |
output | object | Opaque JSON Schema draft-07 subschema describing the success result; an empty object {} declares no constraint |
idempotent | boolean | Advisory 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:argsmust be a JSON object; whendescriptor.inputdeclares top-level"type": "object"with a"required": [...]list, every listed key must be present inargs. It rejects withINVALID_INPUTanddetails.field("arguments"for a non-object payload,"input"for a malformed subschema) plusdetails.missingfor 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)withToolInvokeRequest { capability_id, arguments }resolving toSpokeResult<ToolInvokeResponse { result }>. The family is standalone — not folded intoBaselinePorts, and capability gating is per-tool (the capability string itself). The port does not re-validate request arguments: callers runvalidateToolArgumentsbefore invoking.orchestrateInvokeTool(port, request)— the frozen orchestration sequence: (1) capability-id grammar gate (parseToolCapabilityId) →INVALID_INPUT; (2) runtime port guard (null/undefinedport or a structurally missinginvokeTool) →CAPABILITY_PORT_MISSINGwithdetails.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:
- 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 onspoke-baseline. - Registered — the serving side has a handler registered for the exact capability id (
registerToolHandleron 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
| Embedding | What ships |
|---|---|
| Language-native client | The wire contract and session-core rules implemented in the host language (the TypeScript @42ch/spoke-connect client with WebSocket transport) |
| Native bindings | The shared session core exported into host languages via FFI (C# NuGet, Kotlin Maven, Swift SPM, Go modules, Python PyPI, C/C++ git) |
| Rust reference | The 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
| Entry | Where it appears | Meaning |
|---|---|---|
auth_failed | ErrorEnvelope.code | Token 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_sequence | ErrorEnvelope.code | Replayed or out-of-order inbound sequence |
op_unsupported | ErrorEnvelope.code | Unknown op, or a token valid but without the capability for the requested op |
capability_missing | ErrorEnvelope.code | The op's required capability is absent from the effective grant |
no_capable_peer | Router reject details.wire_code / details.kind | Terminal 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_unbound | RemoteAdapter reject details.kind | Envelope-auth rejection kinds on INTERNAL_ERROR rejects (waiter only; session state untouched) |
handshake | Dial 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_mismatch | Dial 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_exhausted | RemoteAdapter reject details.kind | INTERNAL_ERROR reject kinds for transport I/O, session loss, invoke timeout, panic containment, correlation mismatch, and sequence exhaustion |
Related
- Open your first connect session — the flow end to end.
- Connect from the TypeScript client — the language-native client surface.
- RemoteAdapter over a Transport — dial a remote peer over a consumer
Transportand call itsBaselinePortssurface. - Route across multiple peers — the router recipe over N registered adapters.
- Expose and invoke remote tools — advertise, register, discover, and reverse-invoke tools over a session.
- Connect from native bindings — the FFI bindings with install pins.
- Connect architecture — session lifecycle, envelope authentication, and capability routing.
- Protocol reference — the
spoke-connectcapability flag.