# SPOKE full documentation > Concatenated English docs bundle generated by tooling/docs/build-llms.mjs. > Normative schemas live in schemas/; normative prose lives in .agents/specs/. > Page order: reference, explanation, how-to, tutorials, packages, release. Two products exchange SPOKE wire shapes through their adapters Two products exchange SPOKE wire shapes through their adapters ## Start here If you run a product that stores narrative knowledge, [implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — pick the port families for the capabilities you claim, and call the matching orchestrators. If you run a separate SPOKE host and want to talk to another host, [open a connect session](https://spoke.42ch.dev/tutorials/first-connect-session) or jump straight to the [TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) / [native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) / [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding). Integrating your own message-oriented `Transport`? Dial remote hosts through a [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter), [route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing), or drive the same adapter surface through a [native binding](https://spoke.42ch.dev/how-to/remote-adapter-native-binding). Expose tools the host can discover and reverse-invoke mid-orchestration with [remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools). Complete beginners follow the two tutorials in order: [Install and create your first KnowledgeEntry](https://spoke.42ch.dev/tutorials/install-and-first-entry), then [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session). ## Why SPOKE SPOKE is a protocol of JSON Schema wire contracts for narrative knowledge: independent products exchange consistency-check and context-assembly I/O through shared data and ops shapes, so each product stops inventing local formats for the same concepts. The repository is the SSOT — hand-authored schemas, generated TypeScript and Rust wire types, and pure operations libraries. - **One wire dialect** — eleven data objects and five baseline ops (plus the optional `project` / `compute`, `extract`, and 33-op `spoke-memory` families) - **Domain Profiles over open strings** — ontology vocabulary publishes over open strings and optional `modules.*` bags - **Capability flags** — claim `spoke-baseline`, or declare `l2-computable`, `l5-fork`, `spoke-cognition`, `narrative-modules`, `ke-extraction`, `ke-ownership`, `spoke-connect`, `spoke-memory` explicitly - **Language parity** — one lockstep SemVer across npm and crates.io - **Opt-in connect** — signed cross-process interaction envelopes with per-session ordering and extensible auth ## Further reading - [CONCEPTS.md](https://github.com/42ch-dev/spoke/blob/main/CONCEPTS.md) — protocol vocabulary, defined in wire terms - [README.md](https://github.com/42ch-dev/spoke/blob/main/README.md) — repository overview, install, and quick start - [Package quick-start](https://spoke.42ch.dev/packages/quick-start) — npm and crates.io install pins - [Version & release](https://spoke.42ch.dev/release/versioning) — lockstep SemVer for integrators # For agents This page is the reading contract for code assistants and automated integrators. The rest of the site is written for human integrators; the facts here tell an agent where to read, which source wins, and which mistakes generated code usually makes. ## Ground truth When sources disagree, use this order: | Rank | Source | Why | |------|--------|-----| | 1 | `schemas/` | Hand-authored JSON Schema. Field names, required sets, patterns and closed objects are defined here | | 2 | `.agents/specs/` | Normative protocol prose: layers, data model, ops, memory, connect, bindings | | 3 | `/reference/*` | Field tables and envelopes derived from the schemas | | 4 | `/how-to/*`, `/tutorials/*` | Procedures and code samples; they assume the reference facts | | 5 | `/explanation/*` | Concepts and boundaries | | 6 | `README.md` | Human entry point; not normative | A docs page that contradicts `schemas/` is a docs bug. Do not generate code from the contradiction. ## Read order 1. `/reference/protocol` — schema inventory, capability flags, `extensions` and `modules` contract. 2. `/reference/data-model` — object fields. Read the object before writing a literal. 3. `/reference/ops` — request and response envelopes, `Scope`, `ErrorEnvelope`. 4. The capability reference you need: `/reference/memory`, `/reference/mind-state`, `/reference/connect`. 5. `/how-to/implement-adapter` and `/how-to/orchestrate-ops` — ports and `orchestrate*` sequences. 6. `/explanation/concepts` — layers and dual-concern pairs, when a name looks ambiguous. ## Task routing | Task | Read first | Then | |------|-----------|------| | Persist KnowledgeEntry data | `/reference/data-model` | `/how-to/implement-adapter` | | Call an op | `/reference/ops` | `/how-to/orchestrate-ops` | | Add a capability flag | `/reference/protocol` | `/explanation/concepts` | | Serve memory | `/reference/memory` | `/how-to/implement-adapter` | | Open a cross-process session | `/reference/connect` | `/how-to/connect-ts-client` | | Proxy adapter ports over a transport | `/how-to/connect-remote-adapter` | `/reference/connect` | | Call or expose a remote tool | `/reference/connect` | `/how-to/connect-remote-tools` | | Bind from another language | `/how-to/connect-native-bindings` | `/how-to/remote-adapter-native-binding` | | Pin versions | `/packages/quick-start` | `/release/versioning` | ## Capability flags `spoke-baseline` is the only baseline claim. Every other flag is additive and declared explicitly. | Flag | Adds | |------|------| | `l2-computable` | `body.state` / `body.computable`, `TimelineEvent.computable_logs`, `project` and `compute` | | `l5-fork` | `fork_id` / `parent_fork_id` on TimelineEvent, `Scope.fork_id` | | `spoke-cognition` | `modules.cognition`, `Scope.cognition`, `modules.observation`, derivative `MindState` | | `narrative-modules` | the `modules` bag on KnowledgeEntry, AssemblePacket and TimelineEvent | | `ke-extraction` | the `extract` op family and `ExtractionPort` | | `ke-ownership` | `KnowledgeEntry.owner` / `disclosure`, `Scope.viewpoint` | | `spoke-connect` | the six connect envelopes | | `spoke-memory` | 13 memory models and 33 `memory.*` ops; `spoke-memory-reflection` and `spoke-memory-transfer` are sub-flags | ## Common traps - `Rule` is checker **input**; `Finding` is checker **output**. `entry_type: "rule"` is an ontology label, not the L6 object. - `TimelineEvent` is the L5 when-axis object. `entry_type: "event"` is an ontology label; one concept may map to both. - `MindState` is derivative. The authority stays on the holder KnowledgeEntry `modules.cognition` and `modules.mental`. - Connect envelopes carry `signature` in the committed schema, which encodes the protocol_version 2 wire shape. - Sequences are per direction and start at 0. A response echoes `session_id`, `sequence` and `request_id`. - Ops results are `SpokeResult`. Branch on `ok` and `SpokeRejectCode`, never on thrown exception types. - Adapter writes are optimistic-concurrency controlled: `expectedBaseRevision: null` means create; a number must equal the stored revision. - Op dispatch rejects before any handler runs. Unknown or unnegotiated tools, static `port.*` families, `extract`, and a viewpoint-bearing Scope without `ke-ownership` answer `op_unsupported`. `capability_missing` answers a known `memory.*` op whose family is not negotiated, and a cognition-bearing request (`Scope.cognition` or `cognition_filter`) without `spoke-cognition`. Do not emulate a family you did not declare. ## Machine-readable bundle - [`/llms.txt`](https://spoke.42ch.dev/llms.txt) — index with one line per page. - [`/llms-full.txt`](https://spoke.42ch.dev/llms-full.txt) — every English page concatenated in reading order, generated from the page sources by `tooling/docs/build-llms.mjs`. # Protocol reference SPOKE is a shared **wire dialect** for narrative products: one set of JSON Schema contracts for knowledge data and operations, so products exchange KnowledgeEntry data and ops on a common protocol surface. The protocol spans three columns plus an opt-in connect family. ## The three columns | Column | Contents | |--------|----------| | **Data wire** | Eleven data objects: KnowledgeEntry, Relation, SourceAnchor, Finding, AssemblePacket, HostCapabilityManifest, Rule, TimelineEvent, CognitionRecord, MindState, ToolDescriptor ([`schemas/data/`](https://github.com/42ch-dev/spoke/tree/main/schemas/data)), plus shared definitions in [`schemas/common/`](https://github.com/42ch-dev/spoke/tree/main/schemas/common); the optional `spoke-memory` family adds thirteen memory models under the same authoring rules | | **Ops wire** | Five baseline operation families — upsert, extract→promote, relate, check, assemble — as transport-agnostic request/response envelopes ([`schemas/ops/`](https://github.com/42ch-dev/spoke/tree/main/schemas/ops)), plus optional `project` / `compute` under `l2-computable`, optional `extract` under `ke-extraction`, and the 33-op `spoke-memory` family | | **Operations library** | Hand-written behavior over the generated wire types: pure lifecycle helpers, capability-sliced adapter ports, and injection orchestration (`@42ch/spoke-operations` TypeScript; `spoke-operations` Rust, lockstep SemVer) | ## Connect family (opt-in) Six interaction envelopes ([`schemas/connect/`](https://github.com/42ch-dev/spoke/tree/main/schemas/connect), capability flag `spoke-connect`) add cross-process interaction: a signed hello that embeds `HostCapabilityManifest` by `$ref`, session context, invoke request/response wrapping existing ops envelopes as opaque payloads, and auth challenge/response. The family is additive — baseline compliance and baseline schemas stay unchanged. See [Connect reference](https://spoke.42ch.dev/reference/connect). ## Schema inventory and codegen posture The wire inventory is **114 committed `*.schema.json` files**: 2 common + 24 data + 82 ops + 6 connect envelopes. The optional `spoke-memory` family contributes 79 of them (13 data models + 33 request/response pairs). Baseline integrators use **21** of those files (2 common + 9 data + 10 baseline ops): the nine baseline data schemas include `ToolDescriptor`, while `CognitionRecord` and `MindState` are the two optional non-memory data files. `schemas/` is the only hand-authored wire truth; generated TypeScript (`@42ch/spoke-schemas`) and Rust (`spoke-schemas`) output is committed and mirrors the schema tree. `pnpm run verify-codegen` fails the build if the generated tree drifts from `schemas/`; schema changes and regenerated output land in the same commit. ## Extensions contract Every durable record object — KnowledgeEntry, Relation, SourceAnchor, Finding, AssemblePacket, HostCapabilityManifest, Rule, TimelineEvent, CognitionRecord, MindState — carries the required `extensions.` bag; core fields stay closed (`additionalProperties: false`). `ToolDescriptor` is a closed descriptor without an extensions bag. | Bag | Shape | Role | |-----|-------|------| | `extensions.` | required `ExtensionMap` on every durable data object; namespace keys are product-chosen ids matching `^[a-z][a-z0-9_-]*$`; values are opaque JSON objects | One product's private bag. Adapters preserve unknown namespaces and keys on round-trip | | `modules.*` | optional `ModuleMap` (capability-flagged `narrative-modules`) on KnowledgeEntry, AssemblePacket, and TimelineEvent; keys are functional-dialect ids (`activation`, `placement`, `activation_trace`, `cognition`, `mental`, `observation`, …); values are structured JSON, inner shapes handbook-defined | Cross-product functional dialects shared by narrative hosts. Unknown module keys round-trip | Placement rule: a **cross-product functional dialect** uses `modules.*`; **product data** uses `extensions.`. On `HostCapabilityManifest`, `extensions` carries deployment metadata — roles, capabilities, and namespace ownership are core manifest fields. ## Capability flags | Flag | What it adds | |------|--------------| | `spoke-baseline` | L0–L8 semantics via the five ops wire families, `HostCapabilityManifest` + baseline `HostManifestPort`, and the shared `Scope` / `error-envelope` definitions. Baseline compliance stands alone; optional flags are additive | | `l2-computable` | `body.state` / `body.computable` on KnowledgeEntry, `TimelineEvent.computable_logs`, and `project` / `compute` ops | | `l5-fork` | `fork_id` / `parent_fork_id` branch metadata on TimelineEvent and `Scope.fork_id` filtering | | `spoke-cognition` | one family (no subflags) for standard attributable cognition: `modules.cognition` on the holder KnowledgeEntry (Belief, epistemic knowledge/ignorance/undetermined, Perception records plus narrated world facts; every completed Belief, epistemic or Perception record carries a declared foundational `q` and may carry distinct metrics), `Scope.cognition` selection with attribution-preserving projection, `modules.observation` on TimelineEvent (could-perceive access), Memory `Content.cognition` with `cognition_filter`, and derivative `MindState` cognition. Advertisement covers only the surfaces a host actually serves; unsupported requirements reject explicitly — see [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) and [MindState reference](https://spoke.42ch.dev/reference/mind-state) | | `narrative-modules` | the optional `modules` (`ModuleMap`) bag on KnowledgeEntry + AssemblePacket + TimelineEvent | | `ke-extraction` | the optional `extract` op family — provisional `KnowledgeEntry` candidates from referenced source material — plus the standalone `ExtractionPort` and the `orchestrateExtract` boundary | | `ke-ownership` | `KnowledgeEntry.owner` / `KnowledgeEntry.disclosure` governance fields and the shared `Scope.viewpoint` reader selector | | `spoke-connect` | the opt-in interaction envelope family; hosts that speak it list the flag in `HostCapabilityManifest.capabilities` | | `spoke-memory` | the optional per-subject memory family: thirteen models and 33 `memory.*` ops with a standalone `MemoryPort`. Two optional sub-flags — `spoke-memory-reflection` (3 reflection ops) and `spoke-memory-transfer` (5 export/import ops) — each require `spoke-memory`. See [Memory reference](https://spoke.42ch.dev/reference/memory) | ## Cognition negotiation and rejection `spoke-cognition` is one capability flag. A host lists it in `HostCapabilityManifest.capabilities` only when every mandatory rule for the surfaces it actually serves is implemented: a KnowledgeEntry-only host serves the cognition module, `modules.observation` and `Scope.cognition` filtering/projection without Memory; a host serving both KnowledgeEntry and Memory also satisfies the Memory cognition row (recorded-at-time content validation, filtered list/recall, reflection basis, transfer digests, scrub). The manifest string alone is not conformance evidence. Negotiation reuses the existing signed-hello intersection: `negotiatedCapabilities(local, remote)` grants `spoke-cognition` only when both manifests declare it. Baseline ops still require `spoke-baseline`; a payload-dependent guard adds the cognition requirement only when the dispatched request carries its declared carrier (`scope.cognition` on `check`, `assemble`, `port.scope.list_knowledge_entries`, `port.scope.list_timeline_events`, `port.fork.list_timeline_events`; `cognition_filter` on `memory.recall`, `memory.records.list` and the three `memory.reflections.*` reads). When the negotiated set lacks the family, a request that requires it takes the existing `capability_missing` branch with no handler side effect. Records without optional cognitive fields stay ordinary missing data and raise no error. After schema decoding, an unknown predicate, a known-but-unimplemented view/dimension/metric filter, or a requested recorded-at-time source on a KnowledgeEntry carrier rejects the whole request through the existing error envelope: `code: "invalid_input"`, `message: "Unsupported cognition requirement"`, `details.reason: "unsupported_cognition_requirement"`, and `details.field` naming the carrier-relative path of the requirement. The response carries no silent stripping, no ignored conjunction, no partial result and no fallback to unfiltered output. ## Repository layout `schemas/` (SSOT) · `tooling/codegen/` · `packages/spoke-schemas` + `packages/spoke-operations` (TypeScript) · `crates/spoke-schemas` + `crates/spoke-operations` (Rust) · `fixtures/toy-world/` (conformance samples and reference adapters). ## Related - [Data model reference](https://spoke.42ch.dev/reference/data-model) — field tables for all eleven data objects. - [Ops wire reference](https://spoke.42ch.dev/reference/ops) — request/response envelopes, `Scope`, `ErrorEnvelope`. - [Connect reference](https://spoke.42ch.dev/reference/connect) — the opt-in envelope family. - [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — `q`, optional metrics, filter grammar and projections. - [MindState reference](https://spoke.42ch.dev/reference/mind-state) — the derivative L5 temporal view (`spoke-cognition`). - [Memory reference](https://spoke.42ch.dev/reference/memory) — the optional `spoke-memory` family: models, ops, capability flags, provider guidance. - [Concepts](https://spoke.42ch.dev/explanation/concepts) — the nine layers and how capabilities map onto them. - [`schemas/README.md`](https://github.com/42ch-dev/spoke/blob/main/schemas/README.md) — schema file inventory. # Data model reference The data layer defines the durable wire objects narrative products exchange. Objects are transport-agnostic; the required `extensions.` bag and the closed-core rule are specified once in the [extensions contract](https://spoke.42ch.dev/reference/protocol#extensions-contract). Field tables below trace to the committed schemas in [`schemas/data/`](https://github.com/42ch-dev/spoke/tree/main/schemas/data) and [`schemas/common/`](https://github.com/42ch-dev/spoke/tree/main/schemas/common). ## Shared definitions | Definition | Shape | Notes | |------------|-------|-------| | `SchemaVersion` | integer ≥ 1 | Wire schema version | | `Timestamp` | string, RFC 3339 UTC | Created / updated / occurred times | | `ExtensionMap` | object; keys `^[a-z][a-z0-9_-]*$`, values opaque JSON objects | Product namespace bag; round-trip preserves unknown namespaces and keys | | `ModuleMap` | object; keys `^[a-z][a-z0-9_-]*$`, values structured JSON (object or array) | Cross-product functional-dialect bag; round-trip preserves unknown module namespaces | | `SourceSpan` | `{ start, end }` (inclusive start, exclusive end) | Span within a source artifact | | `TimelineScale` | open string; core list `brief`, `narrative`, `moment` | L5 projection tier | | `ForkId` | string ≥ 1 char | Opaque world-history branch identity (`l5-fork`) | | `Scope` | object; required `scope_id`; optional `viewpoint` (`ke-ownership`), `cognition` (`spoke-cognition`) | Shared ops selector — see [Ops wire reference](https://spoke.42ch.dev/reference/ops), [Ownership governance](https://spoke.42ch.dev/reference/data-model#ownership-governance-ke-ownership) and [Cognition](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) | | `BodyAttribute` | `{ trait_type, value, display_type?, max_value? }` | ERC721-style trait item; duplicate `trait_type` allowed at array level | | `ComputableFieldMap` | open map of field names to domain values | Shared by `body.state` and `body.computable` under `l2-computable` | | `ComputableLogEntry` | `{ logged_at, entry_id, changes[] }` + optional `session_id` / `message` | Moment-scale presentation of computable field changes (`l2-computable`) | | `MentalFieldMap` | open map of mental-field names to domain values | `MindState.snapshot`; matches the `modules.mental` nine-field vocabulary | | `MindDelta` | `{ path, previous?, next? }` | `MindState.deltas[]`; `path` is an RFC 6901 JSON Pointer rooted at `/modules/mental/`, `/modules/cognition/records/...` or `/modules/cognition/world_facts/...` | ## KnowledgeEntry The atomic knowledge-base unit. Required: `schema_version`, `entry_id`, `entry_type`, `canonical_name`, `status`, `body`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `entry_id` | string | Stable id, opaque to the protocol | | `entry_type` | open string | Core list (documented, not enforced): `character`, `location`, `event` (ontology label — distinct from the TimelineEvent wire object), `scene`, `act`, `organization`, `item`, `conflict`, `info_point`, `era`, `worldbuilding`, `note`, `research`, `ability`, `rule` (ontology label — distinct from the L6 Rule wire object). Products MAY emit values outside this list | | `canonical_name` | string ≥ 1 char | Human-stable name | | `status` | open string | Core list (documented, not enforced): `provisional`, `confirmed`, `deprecated`, `merged`, `deleted` | | `body` | closed object | `summary?`, `tags[]?`, `attributes[]?` (BodyAttribute); `state?` / `computable?` (ComputableFieldMap) under `l2-computable` | | `source_anchor` | SourceAnchor, optional | Provenance pointer | | `revision` | integer ≥ 0 | Optimistic concurrency revision | | `created_at` / `updated_at` | Timestamp | | | `extensions` | ExtensionMap, required | | | `modules` | ModuleMap, optional | Capability-flagged `narrative-modules`; carries per-entry dialects (e.g. `modules.activation`); `modules.cognition` carries the closed cognition module under `spoke-cognition` | | `owner` | string ≥ 1 char, optional | Holder-KnowledgeEntry `entry_id` in the product's collaboration context (`ke-ownership`) | | `disclosure` | open string, optional | Governance disclosure vocabulary; the sole core value is `owner-private` (`ke-ownership`) | ## Relation Directed edge between two KnowledgeEntries (or a KnowledgeEntry and a source anchor). Required: `schema_version`, `relation_id`, `relation_type`, `from_id`, `to_id`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `relation_id` | string | Stable relation id | | `relation_type` | open string | Core list (documented, not enforced): `related_to`, `parent_of`, `member_of`, `located_in`, `participates_in`, `causes`, `foreshadows` | | `from_id` / `to_id` | string | Source / target endpoint ids | | `label` | string, optional | Human label | | `metadata` | open object, optional | | | `revision` | integer ≥ 0 | Optimistic concurrency revision | | `extensions` | ExtensionMap, required | | ## SourceAnchor Pointer to a source artifact span (manuscript, scene, external locator). Required: `schema_version`, `source_id`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `source_id` | string | Opaque source locator; products define the grammar | | `span` | SourceSpan, optional | Byte or character span within the source | | `label` | string, optional | Human label | | `mime_type` | string, optional | MIME type of the referenced source | | `extensions` | ExtensionMap, required | | ## Finding Checker output — a distinct artifact from a KnowledgeEntry `body`. Required: `schema_version`, `finding_id`, `severity`, `status`, `title`, `description`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `finding_id` | string | Stable finding id | | `severity` | open string | Core list (documented, not enforced): `info`, `warning`, `error` | | `status` | open string | Core list (documented, not enforced): `open`, `resolved`, `dismissed` | | `title` / `description` | string | Short title and detail text | | `kind` | string, optional | Checker kind or category | | `target_entry_id` | string, optional | KnowledgeEntry the finding targets | | `source_anchor` | SourceAnchor, optional | Provenance pointer | | `suggested_fix` | string, optional | Suggested remediation text | | `text_position` | object, optional | Position hint within source text | | `extensions` | ExtensionMap, required | | ## AssemblePacket Wire-only context-assembly payload. Required: `schema_version`, `packet_id`, `entries`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `packet_id` | string | Stable packet id | | `entries` | array | Slim context entries (default); full KnowledgeEntry embedding is op-specific | | `extensions` | ExtensionMap, required | | | `modules` | ModuleMap, optional | Capability-flagged `narrative-modules`; carries packet-level recipes (`modules.placement`, `modules.activation_trace`) | | `cognition` | CognitionProjection, optional | Derivative projection selected by `Scope.cognition` (`spoke-cognition`) | ## HostCapabilityManifest Host self-description for in-process collaboration. Required: `schema_version`, `host_id`, `roles`, `capabilities`, `namespaces`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `host_id` | string ≥ 1 char | Stable host identity, opaque to the protocol | | `roles` | string[], min 1, unique | Open vocabulary. Core list (documented, not enforced): `data-store`, `input-source`, `checker`, `assembler`, `computable-engine` | | `capabilities` | string[], min 1, unique | Open string capability flags. Core list (documented, not enforced): `spoke-baseline`, `l2-computable`, `l5-fork`, `spoke-cognition`, `narrative-modules`, `ke-extraction`, `ke-ownership`, `spoke-connect`, `spoke-memory`, `spoke-memory-reflection`, `spoke-memory-transfer`. A manifest that exposes tools also lists each `tools..` capability id | | `namespaces` | string[], min 1, unique; keys `^[a-z][a-z0-9_-]*$` | Extension namespace keys this host owns in a collaboration context | | `authority` | `{ scope_key }`, optional | Explicit single-writer authority scope; when absent with `data-store` in roles, implicit authority is this manifest's `host_id` | | `extensions` | ExtensionMap, required | Deployment metadata — distinct surface from KnowledgeEntry `extensions` | ## ToolDescriptor Closed descriptor of one remote-invokable tool, declared in `HostCapabilityManifest.tools[]`. Required: `schema_version`, `capability_id`, `op`, `description`, `input`, `output`. The descriptor has no `extensions` bag. | Field | Type | Notes | |-------|------|-------| | `capability_id` | string | Tool capability string `tools..` matching `^tools\.[a-z][a-z0-9_-]*\.[a-z0-9][a-z0-9_-]*$`; the namespace is owned by the declaring manifest (`namespaces[]` membership) | | `op` | string | Wire op for the tool; MUST equal `capability_id` | | `description` | string ≥ 1 char | Human-readable tool description | | `input` | object | Opaque JSON Schema draft-07 subschema for tool arguments; `{}` declares an unconstrained ABI | | `output` | object | Opaque JSON Schema draft-07 subschema for the success result; `{}` declares an unconstrained ABI | | `idempotent` | boolean, optional | Advisory metadata (default `false`) | Invoke and dispatch rules: [Connect reference — Tools](https://spoke.42ch.dev/reference/connect#tools-reverse-invokes). ## Rule Declarative constraint input to `check` — never checker output. Required: `schema_version`, `rule_id`, `canonical_name`, `kind`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `rule_id` | string | Stable rule id, opaque to the protocol | | `canonical_name` | string ≥ 1 char | Human-stable name | | `kind` | open string | Core list (documented, not enforced): `rule`, `prohibition`, `style` | | `statement` | string, optional | Declarative constraint text (human- or machine-readable; products choose the grammar) | | `target_entry_types` | string[], optional | Ontology filter matching KnowledgeEntry `entry_type` vocabulary | | `severity_hint` | open string, optional | Core list (documented, not enforced): `info`, `warning`, `error` | | `status` | open string, optional | Core list (documented, not enforced): `draft`, `active`, `deprecated` | | `source_anchor` | SourceAnchor, optional | When the rule is anchored to a manuscript | | `extensions` | ExtensionMap, required | | ## TimelineEvent First-class when-axis temporal object (L5). Required: `schema_version`, `timeline_event_id`, `canonical_name`, `extensions`. | Field | Type | Notes | |-------|------|-------| | `timeline_event_id` | string | Stable id, opaque to the protocol | | `canonical_name` | string | Human-stable label | | `timeline_scale` | TimelineScale, optional | Projection tier: `brief`, `narrative`, `moment` | | `occurred_at` | string | RFC 3339 or opaque fuzzy label (e.g. "Third Age") | | `description` | string, optional | Longer narrative summary | | `participant_entry_ids` | string[], optional | Related KnowledgeEntry ids | | `source_anchor` | SourceAnchor, optional | | | `sort_key` | string, optional | Opaque ordering hint within a timeline | | `fork_id` / `parent_fork_id` | ForkId, optional | World-history branch metadata (`l5-fork`) | | `computable_logs` | ComputableLogEntry[], optional | Moment-scale computable change history (`l2-computable`) | | `modules` | ModuleMap, optional | Capability-flagged `narrative-modules`; `modules.observation` carries could-perceive observers and open access constraints (`spoke-cognition`) | | `extensions` | ExtensionMap, required | | `MindState` is the companion L5 temporal record for mental state on the same when-axis — see [MindState reference](https://spoke.42ch.dev/reference/mind-state). ## Cognition (`spoke-cognition`) Standard cognition is one optional family — the flag `spoke-cognition`, with no subflags. A host serves it through the closed `modules.cognition` module on the holder KnowledgeEntry: `records[]` holds completed holder-side records, `world_facts[]` holds narrated world facts, and the module carries its own `extensions`. Four concepts ride three record categories. Belief is a `BeliefRecord`. Epistemic assessment is one `EpistemicAssessment` whose `state` distinguishes Knowledge (`known`) from Ignorance (`ignorant`) and from assessor indeterminacy (`undetermined`). Perception is a `PerceptionRecord` describing the content a holder actually captured. Perception and Belief are separate records and may coexist for the same holder and target. ### Record shapes Every completed Belief, epistemic and Perception record carries a `q` whose `assertion` names the cognitive assertion it estimates. `record_id` is unique within its carrier. | Record (`kind`) | Required core | Optional | |-----------------|---------------|----------| | Belief (`belief`) | `record_id`, `holder`, `target`, `assessor`, `assessor_role`, `basis`, `assessment_context`, `effective_at`, `assessed_at`, `view`, `order`, `q`, `extensions` | `criterion`, `metrics`, `truth`, `access`, `representation`, `content_type`, `source`, `context` | | Epistemic (`epistemic`) | `record_id`, `holder`, `target`, `assessor`, `assessor_role`, `basis`, `assessment_context`, `effective_at`, `assessed_at`, `view`, `state` (`known` / `ignorant` / `undetermined`), `criterion`, `classification_policy` (`policy_id`, `policy_version`), `q`, `extensions` | `metrics` | | Perception (`perception`) | `record_id`, `holder`, `target`, `assessor`, `assessor_role`, `basis`, `assessment_context`, `effective_at`, `assessed_at`, `view`, `episode`, `content` (required key), `q`, `extensions` | `access`, `criterion`, `metrics` | | World fact (`world_facts[]`) | `fact_id`, `holder` (kind `world`), `target`, `order` = 0, `assessment_context`, `effective_at`, `recorded_at`, `extensions` | `truth`, `access`, `representation`, `content_type`, `source`, `context` | World facts live on the designated world-state KnowledgeEntry; their `holder` is the world authority and never an actor. Actor Belief, epistemic and Perception records live under `modules.cognition.records[]` on the holder's KnowledgeEntry. ### Identity, attribution and `q` | Element | Shape and rule | |---------|----------------| | Reference | `{ kind, id, namespace }`; `kind` is one of `knowledge_entry`, `timeline_event`, `memory_record`, `memory_subject`, `principal`, `world`, `external`. Identity is the complete `(kind, namespace, id)` tuple — never a bare id or equal text | | Target | `target.carrier` (reference), `target_id` (stable within carrier), `target.kind` (`proposition`, `question`, `perceived_content`, `episode`), optional `text` (representational content, never identity), optional `about` (higher-order target) | | Compound target identity | `(carrier, kind, target_id, assessment_context.context_id, assessment_context.fork_id)`. There is no target registry or lookup operation | | `assessment_context` | `context_id` required, `fork_id` optional; the filter's fork predicate selects `{eq}` one fork or `{absent:true}` the no-fork context | | `assessor` / `assessor_role` | A reference plus `holder_self_report`, `external_judgment` or `product_assessment`. Attribution states who assessed; it grants no authority over other assessments | | `basis` | `{ kind: "evidence", evidence: [reference...] }` with at least one reference, or `{ kind: "prior", description }` with a non-empty declared subjective/prior/model basis. Either may carry `acquisition`. A declared basis may remain uncalibrated | | `q` | `{ value (0–1), assertion, method, calibration, uncertainty? }`. `assertion` is `belief_attribution`, `actual_capture`, `knowledge_criterion`, `ignorance_criterion` or `assessor_indeterminacy`; `calibration` is `calibrated`, `uncalibrated` or `unknown`; `uncertainty` is `{ kind, lower, upper, level?, description? }` | | Estimand identity | Holder, target, assessor, basis, criterion where applicable, assessment context, `effective_at` / `assessed_at`, and `q.assertion` together identify the estimated probability | | Times | `effective_at` is when the assessed state applies; `assessed_at` is when the assessor made the assessment | | `view` | `current` for holder-side current authority; `recorded_at` for Memory content and explicitly supplied historical input | Belief `q` is the attribution confidence for that Belief record; a holder's own credence is the separate optional metric `b`. Perception `q` estimates actual capture; the same event's access metadata and optional correspondence metric `f` are separate. Epistemic `q` follows `state`: `knowledge_criterion` for `known`, `ignorance_criterion` for `ignorant`, `assessor_indeterminacy` for `undetermined`. ### Optional metrics Metrics are distinct estimates. A key is present only when that estimate exists, and each metric repeats the attribution identity (`assessor`, `assessor_role`, `basis`, `context`, `effective_at`, `assessed_at`, `method`, `calibration`) plus its `estimand`. | Key | `estimand` | Permitted category | |-----|------------|--------------------| | `b` | `holder_credence` | Belief | | `p` | `capture` | Perception | | `f` | `correspondence` | Perception | | `k` | `knowledge_criterion` | Epistemic | | `r` | `recording_fidelity` | Belief, epistemic, Perception | `criterion` is required for `knowledge_criterion` and `correspondence` estimates. Product-local measurements belong in `extensions.`. ### Belief vocabulary `order` is `0`–`3` (higher-order depth; current actor Belief records use `1`–`3`, and `order` `0` narrated facts use world facts). The seven closed dimensions are `order`, `truth` (`True` / `False` / `Unknown`), `access` (`Private` / `Shared` / `Public`), `representation` (`Explicit` / `Implicit`), `content_type` (`Location`, `Contents/Physical State`, `Identity/Relation`, `Epistemic`, `Desire/Intention`, `Emotion`, `Trait/Value`, `Action/Event`), `source` (`Narration`, `Perception`, `Memory`, `Testimony`, `Inference`, `Imagination`, `Unknown`) and `context` (`Deceptive`, `Temporal`, `Counterfactual`, `Neutral`). `truth: "Unknown"` states the record's truth value and does not mean holder ignorance; `access` is a distribution label and never an authorization grant; `source: "Perception"` is an acquisition label and not proof that actual perception occurred. ### Observation and actual perception `TimelineEvent.modules.observation` is a closed `ObservationModule`: `observers` is required and holds `CognitionReference` values of actors who could perceive the event, and `access` carries open perceptual-access constraints (`position`, `line_of_sight`, `hearing_range`, `modality`, plus unknown keys preserved on round-trip). Missing `modules.observation` means no observation metadata; `observers: []` states explicitly that no observer could perceive. A `PerceptionRecord` describes actual captured content — partial, misleading, illusory or disbelieved content included — and event access alone creates no Perception record and supplies no default probability. ### Selection and projection `Scope.cognition` carries a `CognitionSelection`. The `current` form is `{ view: "current", filter, selection_policy }` and requires the negotiated `spoke-cognition` flag; it applies to holder-side current authority only. The `recorded_at` form is `{ view: "recorded_at", filter }` and is valid for explicitly supplied historical input; KnowledgeEntry carriers serve the `current` view, and Memory serves `recorded_at`. Requests carrying `Scope.cognition` or `cognition_filter` require the negotiated flag. The filter has one closed grammar. Every supplied predicate must hold on the same record: | Predicate | Matches | |-----------|---------| | `holder` | The complete holder reference | | `target` | `carrier` (complete reference), `target_id`, `kind` | | `assessment_context` | `context_id`; `fork_id` as `{ eq }` or `{ absent: true }` | | `category` | `belief`, `epistemic` or `perception` | | `state` | Epistemic `state` | | `q` | Inclusive `min` / `max` over declared `q.value`; uncalibrated declared values participate identically | | `order` | Belief `order` | | `truth`, `access`, `representation`, `content_type`, `source`, `belief_context` | `{ eq }` for a present value or `{ absent: true }` for an explicitly absent optional field | Predicates never join across records or targets, and reference predicates compare the complete tuple. The grammar has no OR, text similarity, ranking, default cutoff, optional-metric filter or implicit source/calibration/criterion constraint. A schema-unknown predicate, an unimplemented dimension or metric filter, or an unavailable requested view rejects the whole request as `unsupported_cognition_requirement` with `details.field` naming the requirement. A `CognitionProjection` is `{ view, items[], extensions }`. Each item carries `holder`, `target`, `assessment_context`, `presence` (`assessed` or `no_record`) and `assessments[]` — every retained authorized matching record, with disagreements kept and never averaged or replaced. A `current` assessed item also carries `selection`: `{ policy: { policy_id, policy_version }, input_record_ids[], selected_record_id }`, where the selected id is one of the traced input ids and the product supplies the policy. A `recorded_at` projection has no current selection. `presence: "no_record"` is emitted only when the filter exactly addresses holder, complete target identity, `assessment_context.context_id` and an explicit fork predicate, and the authorized source holds no record for that compound key before optional predicates; an omitted fork is a wildcard and yields no `no_record` claim. An ordinary empty result is `items: []`. ### Memory cognition Memory `Content.cognition` is an optional array of complete standard records; every entry carries `view: "recorded_at"` and keeps its own attribution, basis and times. An absent member, an explicitly present empty array and a non-empty array are distinct representations: absence records that no standard cognition was recorded, `[]` is preserved as its own wire value and is never produced by omission, and only a non-empty array carries cognition substance — an explicit empty array never becomes absent and never counts as non-empty content. A non-empty array counts as cognition content for classification, while every explicitly present member, `[]` included, is preserved and measured against the existing content byte budget. Cognition is validated on capture and on record creation or update. Review defaults to the captured proposed content where applicable; a `promote` decision may supply a new `content_override` and a `fragment` decision carries new fragment `content`, each independently validated by the same recorded-at-time rule before any effects. Existing grants, placement and the family boundary stay unchanged, review creates no KnowledgeEntry, and pending review confers no authority. Wire shapes and the review inputs are in the [Memory reference](https://spoke.42ch.dev/reference/memory#cognition-content-and-filters-spoke-cognition). `cognition_filter` on `memory.recall` and `memory.records.list` is a hard filter applied after the authorized source set is resolved and before counts, ranking, pages and byte budgets: a record matches when at least one of its `Content.cognition` entries satisfies the complete conjunction. The filter binds cursor identity. `memory.reflections.state`, `memory.reflections.list` and `memory.reflections.generate` accept the same filter and pin it — together with the source id and revision set, profile/policy identity and cursor identity — into the reflection basis, so a changed filter or source set changes the basis and staleness. Export/import bundles and chunks include cognition in their canonical digests, and `(kind, namespace, id)` reference translation requires complete references and fails closed. Delete, scrub and tombstone clear cognition with the rest of the sensitive content; `memory.records.promote` changes placement and creates no KnowledgeEntry. ### Derivative MindState `MindState.cognition` carries a derivative `CognitionProjection` of the selected holder-side authority, and `MindState.snapshot` / `deltas[]` present the mental-state trajectory. Every `MindDelta.path` is an RFC 6901 JSON Pointer rooted at `/modules/mental/`, `/modules/cognition/records/...` or `/modules/cognition/world_facts/...`. The authoritative values remain on the holder KnowledgeEntry. ## Ownership governance (`ke-ownership`) The optional `ke-ownership` capability carries ownership facts on the KnowledgeEntry envelope and a reader selector on the shared `Scope`. `KnowledgeEntry.owner` names the holder KnowledgeEntry (`entry_id`) in the product's collaboration context; when `owner` is absent, ownership is unspecified. `KnowledgeEntry.disclosure` carries the governance disclosure vocabulary, whose sole core value is `owner-private`; when `disclosure` is absent, the entry is shared within the already selected KB context. A holder is an ordinary KnowledgeEntry that the product resolves in its own context. `Scope.viewpoint` is an optional non-empty string on the shared selector: the reader's holder-KnowledgeEntry `entry_id`. The core disclosure predicate admits an `owner-private` entry only when its `owner` equals the requesting `viewpoint` exactly — string equality on the ids as supplied; an entry without `disclosure` stays visible regardless of viewpoint. The three Scope-bearing remote 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 the request's Scope carries a non-empty `viewpoint`. See the [connect reference](https://spoke.42ch.dev/reference/connect#the-ownership-gate-ke-ownership). ## Open vocabulary `entry_type`, `relation_type`, statuses, severities, and `kind` values are **open strings with documented core lists** — the schema keeps them open, and the core lists serve as reference values. Products emit their own values; Domain Profiles document published vocabulary (for example the profile-only `entry_type: "beat"`); adapters round-trip unknown values verbatim. ## Distinct artifacts - Rule is declarative checker **input**; Finding is checker **output** — each keeps its own role. - `TimelineEvent` is the L5 when-axis object; `entry_type: "event"` is an ontology label — one local concept may map to both (dual-concern). - `MindState` is the L5 temporal mental-state record (`spoke-cognition`); `entry_type: "character"` / profile `mind` are ontology labels — the record is strictly derivative of the holder's `modules.cognition` / `modules.mental`, and the authoritative values stay on the holder KnowledgeEntry. - `CognitionRecord` is the standard record shape (`spoke-cognition`); `modules.mental` carries the open nine-field mental vocabulary and its `beliefs` member is derivative summary, count or reference data. - `modules.observation` states who could perceive an event; a `PerceptionRecord` states what a holder actually captured. - `HostCapabilityManifest` carries host metadata (roles, capabilities, namespaces) on its dedicated surface, separate from KnowledgeEntry `extensions`. ## Related - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — schema inventory, extensions contract, capability flags. - [Ops wire reference](https://spoke.42ch.dev/reference/ops) — `Scope` and the ops that read these objects. - [Concepts](https://spoke.42ch.dev/explanation/concepts) — the layers each object belongs to. - [Domain profiles](https://spoke.42ch.dev/explanation/domain-profiles) — open-string vocabulary published over these shapes. - [MindState reference](https://spoke.42ch.dev/reference/mind-state) — the derivative L5 temporal record (`spoke-cognition`). - [Cognition (`spoke-cognition`)](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — record shapes, `q` and metrics, filter grammar, projections and the Memory carrier. # MindState First-class temporal mental-state record (L5, optional `spoke-cognition`) — a standalone wire object on the when-axis that records how a holder's mental state changes over time. Required: `schema_version`, `mind_state_id`, `holder_entry_id`, `extensions`. ## When to use Use `MindState` when the temporal course of a mental state is itself the interchange fact: false-belief structures, dramatic-irony structures, or any "who knows, believes, perceives, wants, or feels what at time t" record. `MindState` is strictly **derivative** — the holder KnowledgeEntry's `modules.cognition` (standard cognition records) and `modules.mental` (the nine-field mental vocabulary: `identity`, `beliefs`, `attention`, `goals`, `intentions`, `emotions`, `dispositions`, `norms`, `constraints`) carry the authoritative values, and this record presents them over time. `modules.mental.beliefs` holds derivative summary, count or reference data. ## Minimal example ```json { "schema_version": 1, "mind_state_id": "ms_01HXYZ", "holder_entry_id": "kb_mira", "canonical_name": "Mira — relieved after the Treaty of Ashford", "occurred_at": "1421-06-03T12:00:00Z", "snapshot": { "emotions": [{ "emotion": "relief", "intensity": 0.7 }], "goals": [{ "goal": "secure the harbor charter", "status": "active" }] }, "deltas": [ { "path": "/modules/mental/emotions", "previous": [{ "emotion": "anxious", "intensity": 0.8 }], "next": [{ "emotion": "relief", "intensity": 0.7 }] } ], "extensions": {} } ``` ## Field table | Field | Type | Notes | |-------|------|-------| | `schema_version` | SchemaVersion | Required | | `mind_state_id` | string ≥ 1 char | Stable id, opaque to the protocol | | `holder_entry_id` | string ≥ 1 char | The mental actor's KnowledgeEntry `entry_id`; the derivative source | | `canonical_name` | string ≥ 1 char, optional | Human-stable label | | `occurred_at` | Timestamp, optional | When the mental state held | | `sort_key` | string, optional | Opaque ordering hint within a mental-state timeline | | `snapshot` | MentalFieldMap, optional | Derivative full-field snapshot of the holder's mental state at this point | | `deltas` | MindDelta[], optional | Derivative field-level change records since the prior mental state | | `cognition` | CognitionProjection, optional | Derivative projection of the selected holder-side cognition authority, with all retained matching assessments and the current-view trace | | `source_anchor` | SourceAnchor, optional | Manuscript or scene anchor | | `created_at` / `updated_at` | Timestamp, optional | | | `extensions` | ExtensionMap | Required | Every `MindDelta.path` is an RFC 6901 JSON Pointer rooted at `/modules/mental/` (any of the nine fields), `/modules/cognition/records/...` or `/modules/cognition/world_facts/...`. Deltas are presentation only. ## Capability: `spoke-cognition` `spoke-cognition` is one optional capability flag, with no subflags. On this surface a host implements the `MindState` temporal record, `modules.cognition` on the holder KnowledgeEntry, `modules.observation` on `TimelineEvent.modules`, and `Scope.cognition` selection with attribution-preserving projection. A host that also serves Memory implements recorded-at-time `Content.cognition` validation and the `cognition_filter` reads. Advertisement covers only the surfaces a host actually serves, and the manifest string alone is not conformance evidence; negotiation reuses the signed-hello intersection, and an unsupported requirement rejects the whole request with `details.reason: "unsupported_cognition_requirement"`. Mental-state engines (belief revision, ToM inference, observation rendering) are product-owned. See [Protocol reference — Cognition negotiation and rejection](https://spoke.42ch.dev/reference/protocol#cognition-negotiation-and-rejection). ## Derivative cognition projection `MindState.cognition` carries a `CognitionProjection` of the selected holder-side records. A `current` projection carries, per assessed item, the identified selection policy and the traced input/selected record ids; a `recorded_at` projection carries the historical records without a current selection. Every item keeps its own `assessment_context`, all retained matching assessments remain in `assessments`, and disagreements are preserved rather than averaged. An `assessed` item appears only for records the filter matched. A `no_record` item is not a matching record: it appears only when the filter exactly addresses holder, complete target identity, `assessment_context.context_id` and an explicit fork predicate, and the authorized source holds no record for that compound key in the selection's `view` before optional predicates — an omitted fork is a wildcard, and an ordinary empty result is `items: []` ([data model §Selection and projection](https://spoke.42ch.dev/reference/data-model#selection-and-projection)). Event `modules.observation` says who could perceive; a `PerceptionRecord` says what a holder actually captured. ## Field tables and handbook Field-level detail lives on the reference and spec surfaces: - [Data model reference — Cognition](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — record shapes, `q` and optional metrics, filter grammar and projection rules. - [Data-model field tables — §MindState](https://github.com/42ch-dev/spoke/blob/main/.agents/specs/protocol/spoke-data-model.md) — required / optional fields and shared `MentalFieldMap` / `MindDelta` definitions. - [Mental-state Domain Profile](https://github.com/42ch-dev/spoke/blob/main/.agents/specs/domain-profiles/domain-profile-mental-state.md) — `modules.mental` nine-field vocabulary, `modules.observation`, MindState record sketch. - [MindState schema](https://github.com/42ch-dev/spoke/blob/main/schemas/data/mind-state.schema.json) — the committed wire schema. ## Related - [Data model reference](https://spoke.42ch.dev/reference/data-model) — the durable objects, including TimelineEvent (when-axis), CognitionRecord and the MindState / ontology-label distinction. - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — capability flags, negotiation and rejection. - [Concepts](https://spoke.42ch.dev/explanation/concepts) — layers and dual-concern pairs. # Ops wire reference The ops layer defines transport-agnostic request/response envelopes for core KnowledgeEntry operations. Products carry these JSON payloads over any transport — in-process calls, message queues, or HTTP mappings inside adapters — and the wire stays transport-agnostic. Field tables below trace to the committed schemas in [`schemas/ops/`](https://github.com/42ch-dev/spoke/tree/main/schemas/ops). KnowledgeEntry status transitions and terminal states KnowledgeEntry status transitions and terminal states ## Baseline operations | Op | Request | Response | Semantics | |----|---------|----------|-----------| | `upsert` | `UpsertRequest` | `UpsertResponse` | Create or update KnowledgeEntries by stable id (1..n entries; optional idempotency key) | | `extract→promote` | `PromoteRequest` | `PromoteResponse` | Promote an extracted candidate to a durable KnowledgeEntry (optional merge target) | | `relate` | `RelateRequest` | `RelateResponse` | Create or update a Relation | | `check` | `CheckRequest` | `CheckResponse` | Run checker(s) over a `Scope`; returns `Finding[]` (rules via `rule_refs` and/or embedded `rules[]`) | | `assemble` | `AssembleRequest` | `AssembleResponse` | Return an `AssemblePacket` for a `Scope` (structure only) | Each operation has a paired request and response schema. Optional families add their own pairs: `project` / `compute` under `l2-computable`, `extract` under `ke-extraction`, and the 33-op `spoke-memory` family. ## One failure dialect Every response is a `oneOf` of the success payload or `{ "error": ErrorEnvelope }` — success and error branches are mutually exclusive: | ErrorEnvelope field | Type | Notes | |---------------------|------|-------| | `code` | string, required | Machine-readable error code (open vocabulary) | | `message` | string, required | Human-readable error message | | `details` | open object, optional | Structured error context | | `extensions` | ExtensionMap, required | Product namespace bag | Expected rejects from the operations library arrive as `SpokeResult` with stable `SpokeRejectCode` strings shared between TypeScript and Rust (`REVISION_CONFLICT`, `STORED_REVISION_STALE`, `CANDIDATE_NOT_PROVISIONAL`, `CANDIDATE_TERMINAL_STATUS`, `EMPTY_CANONICAL_NAME`, `RELATION_SELF_EDGE`, `RELATION_MISSING_ENDPOINT`, `CAPABILITY_PORT_MISSING`, `INTERNAL_ERROR`, …). ## Scope selector `check` and `assemble` share the `Scope` selector. Required `scope_id`; all refinements optional: | Field | Type | Notes | |-------|------|-------| | `scope_id` | string, required | Protocol-neutral opaque selector. Products map World / Book / chapter / manuscript ids via adapters or op extensions | | `entry_ids` | string[] | Narrow scope to explicit KnowledgeEntries | | `entry_types` | string[] | Filter by open `entry_type` vocabulary | | `timeline_event_ids` | string[] | Narrow scope to explicit L5 TimelineEvent ids | | `source_id` | string | Provenance or manuscript locator scope | | `timeline_scale` | TimelineScale | L5 tier filter (`brief`, `narrative`, `moment`) | | `fork_id` | ForkId | L5 branch filter — strict equality on `TimelineEvent.fork_id` (`l5-fork`) | | `viewpoint` | string | Reader context (`ke-ownership`) — the reader's holder KnowledgeEntry `entry_id`; absent names no subject and grants no private visibility | | `cognition` | CognitionSelection | Cognitive refinement (`spoke-cognition`) — requires the negotiated flag and serves the `current` view only; see [Cognition selection](https://spoke.42ch.dev/reference/ops#cognition-selection-spoke-cognition) | | `extensions` | ExtensionMap | Product-scoped query metadata; protocol matchers ignore it, adapters round-trip it | List refinements (`entry_ids`, `entry_types`, `timeline_event_ids`) match by presence: an omitted list is absent and matches everything, while an explicitly present list — including `[]` — restricts the result, so `[]` matches nothing. ## Cognition selection (`spoke-cognition`) `Scope.cognition` carries a `CognitionSelection` on `check`, `assemble` and the scoped KnowledgeEntry / TimelineEvent reads. This carrier serves the `current` view only: `{ view: "current", filter, selection_policy }`. The `recorded_at` form belongs to the Memory source view; supplying it here rejects the whole request instead of returning an empty projection. The selection is applied after authorization and before projection or any checker side effect: a KnowledgeEntry is kept only when at least one carried record satisfies the complete one-record filter conjunction, and entries carrying no matching record are excluded. `filter` is the closed grammar in [Data model reference §Selection and projection](https://spoke.42ch.dev/reference/data-model#selection-and-projection) — every supplied predicate is ANDed on the same record, and no predicate joins across records or targets. `selection_policy` is required in the `current` form and names the product policy as `{ policy_id, policy_version }`. The product supplies the algorithm; the library verifies only that `selected_record_id` belongs to the traced `input_record_ids`. The result is attribution-preserving: every item keeps its own `holder`, `target` and `assessment_context`, and retained disagreements are never averaged or replaced. An unknown predicate, an unimplemented dimension or an unavailable view rejects the entire request — no silent stripping, partial response or unfiltered fallback: ```json { "error": { "code": "invalid_input", "message": "Unsupported cognition requirement", "details": { "reason": "unsupported_cognition_requirement", "field": "scope.cognition.filter.q" } } } ``` Requests carrying `Scope.cognition` require the negotiated `spoke-cognition` flag; when the negotiation lacks it, the request takes the existing `capability_missing` branch with no handler side effect. See [Protocol reference §Cognition negotiation and rejection](https://spoke.42ch.dev/reference/protocol#cognition-negotiation-and-rejection). ## Envelope field tables ### UpsertRequest / UpsertResponse `UpsertRequest` required: `knowledge_entries`. Response: `{ knowledge_entries: [...], rejected? }` **or** `{ error }`. `rejected[]` carries per-entry `{ entry_id, code, message }` on the success branch. | Field | Notes | |-------|-------| | `knowledge_entries` | KnowledgeEntries to create or update | | `idempotency_key` | Opaque idempotency hint (no server semantics in protocol v0.1) | | `extensions` | Optional transport metadata | ### PromoteRequest / PromoteResponse `PromoteRequest` required: `candidate`. Response: `{ knowledge_entry, superseded_id? }` **or** `{ error }`. | Field | Notes | |-------|-------| | `candidate` | Candidate KnowledgeEntry (typically status `provisional`) | | `target_entry_id` | Optional merge target KnowledgeEntry id; the response then carries `superseded_id` | ### RelateRequest / RelateResponse `RelateRequest` required: `relation`. Response: `{ relation }` **or** `{ error }`. | Field | Notes | |-------|-------| | `relation` | Relation to create or update (OCC via `revision`) | ### CheckRequest / CheckResponse `CheckRequest` required: `scope`. Response: `{ findings: [...] }` **or** `{ error }`. | Field | Notes | |-------|-------| | `scope` | Checker scope selector | | `rule_refs` | Opaque rule ids or URIs; resolved by the receiver when not overridden by `rules[]` | | `rules` | Optional embedded Rule objects for portable interchange (override by `rule_id`) | | `checker_kinds` | Optional checker kind filters | | `extensions` | Optional transport metadata | ### AssembleRequest / AssembleResponse `AssembleRequest` required: `scope`. Response: `{ packet }` **or** `{ error }`. | Field | Notes | |-------|-------| | `scope` | Assembly scope selector | | `max_entries` | Optional entry limit hint (not enforced by the protocol) | | `extensions` | Optional transport metadata | When `scope.cognition` is present, `packet.cognition` is a `CognitionProjection` `{ view, items[], extensions }` selected over the authorized source set. Each item carries its own `holder`, `target`, `assessment_context`, `presence` (`assessed` or `no_record`) and `assessments[]`; a `current` assessed item also carries `selection: { policy, input_record_ids[], selected_record_id }`. The projection is computed before `max_entries` truncation — the limit narrows the slim packet entries, never which authorized cognition was requested — and is absent when the request carries no cognitive selection. See [Data model reference §Selection and projection](https://spoke.42ch.dev/reference/data-model#selection-and-projection). ## Optional op (`ke-extraction`) `extract` is an optional op family under the `ke-extraction` capability flag, provided by hosts in the existing `input-source` role; the five baseline op families stay unchanged. It proposes provisional `KnowledgeEntry` candidates from referenced source material. | Op | Intent | Request | Response | |----|--------|---------|----------| | `extract` | Propose provisional `KnowledgeEntry` candidates from referenced source material | `ExtractRequest` | `ExtractResponse` | ### ExtractRequest / ExtractResponse `ExtractRequest` required: `run_id`, `sources`. Input is reference-only: the request carries source anchors with their optional spans. | Field | Notes | |-------|-------| | `run_id` | Non-empty opaque correlation identity; echoed verbatim as `run.run_id` | | `sources` | Non-empty `SourceAnchor[]`; the extraction range is this list plus each anchor's optional span (an absent span means the referenced artifact as a whole) | | `entry_types` | Optional advisory hints naming the candidate types the caller expects; the serving host may use them or ignore them | | `extensions` | Optional transport metadata | `ExtractResponse` carries one branch: success `{ candidates, run }` — **or** failure `{ error }`. | Success field | Notes | |---------------|-------| | `candidates` | `KnowledgeEntry[]` — every returned candidate carries `status: "provisional"`; an empty array is a valid zero-result run | | `run` | `ExtractionRunMetadata` — required `run_id` (the exact request echo), optional non-empty `method`, optional opaque `coverage_hint` | | `extensions` | Optional transport metadata | | Failure field | Notes | |---------------|-------| | `error` | `ErrorEnvelope` — the response's failure branch for this run | | `extensions` | Optional transport metadata | **Provisional invariant.** The library admits only candidates whose `status` is `provisional`. When any candidate carries another status, the whole set is rejected and the error branch is returned — `CANDIDATE_TERMINAL_STATUS` for a `merged` / `deleted` entry, `CANDIDATE_NOT_PROVISIONAL` for any other status; every candidate that is admitted keeps the status the extractor produced, and a success response echoes the request's `run_id` verbatim. **`extract` vs `extract→promote`.** The baseline `extract→promote` row covers admission: `promote` admits one provisional candidate to durable storage. The optional `extract` op covers production: it proposes provisional candidates from referenced sources. Extraction output reaches durable storage through promote. ## Optional family (`spoke-memory`) Thirty-three ops across three capability flags for per-subject memory. Every op has a paired request/response schema named `memory--{request,response}.schema.json` — `memory.records.promote` becomes `memory-records-promote-request.schema.json` / `memory-records-promote-response.schema.json`. | Capability | Ops | |------------|-----| | `spoke-memory` | 25 core ops: `memory.describe`, `memory.spaces.list` / `.freeze` / `.unfreeze`, `memory.subjects.ensure` / `.get` / `.fence`, `memory.grants.list` / `.grant` / `.revoke`, `memory.capture`, `memory.candidates.list` / `.get` / `.dismiss` / `.review`, `memory.records.create` / `.get` / `.update` / `.delete` / `.list` / `.promote`, `memory.recall`, `memory.profile.get` / `.update`, `memory.receipts.get` | | `spoke-memory-reflection` | `memory.reflections.state`, `.list`, `.generate` | | `spoke-memory-transfer` | `memory.exports.create`, `.get`, `memory.imports.stage`, `.activate`, `.discard` | **Family rules.** Read ops return their payload; every mutating op returns a `MemoryReceipt` (`committed` / `pending` / `failed`) whose `result` carries IDs, revisions, counts, outcome enums, digests, and cost facts only. The failure branch is `{ error }` — the shared envelope shape with `details.retry_class` required (`retryable` / `terminal` / `uncertain`). Every mutating op except `memory.subjects.ensure` requires `idempotency_key`, and conditional writes require `expected_revision`. Per-op grant capability, request fields, and error codes are tabulated in the [Memory reference](https://spoke.42ch.dev/reference/memory) and the [family spec](https://github.com/42ch-dev/spoke/blob/main/.agents/specs/memory/spoke-memory.md). ## Shared rules - **Check ≠ Assemble** — `check` returns findings only; `assemble` returns a packet only. - **`$ref` composition** — ops schemas `$ref` data-layer types, with each type defined once. - **Purity** — the operations library is pure relative to host I/O: storage access, LLM calls, ranking, retrieval, and transport binding are supplied by products through injected adapter ports. The library runs the protocol gates; adapters own persistence. ## Related - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — the three columns and capability flags. - [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops) — calling each orchestrator with real signatures. - [Data model reference](https://spoke.42ch.dev/reference/data-model) — the objects these envelopes carry. - [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — record shapes, `q`, the closed filter grammar and projections. - [Connect reference](https://spoke.42ch.dev/reference/connect) — ops envelopes wrapped as opaque invoke payloads. - [Memory reference](https://spoke.42ch.dev/reference/memory) — the optional `spoke-memory` family's 33 ops, capability split, and provider guidance. # Memory (`spoke-memory`) Per-subject long-term memory as a wire family: spaces, subjects, grants, capture, candidate review, retained records, recall, author profiles, reflections, and versioned export/import. Memory is **subjective** — an impression the host captured for one subject — and it lives beside canonical KnowledgeEntry data as a separate family. Required on every durable memory object: `schema_version`, `extensions`. The family is optional. A host claims it by declaring `spoke-memory` in `HostCapabilityManifest.capabilities`, and the wire contract is the thirteen models plus 33 `memory.*` operations below. ## What the family carries | Concern | Wire artifacts | |---------|----------------| | Where memory lives | `MemorySpace` — one authoritative provider per space, `revision`, computed `pending_receipts` | | Who memory is about | `Subject` — stable opaque `subject_id` plus optional namespaced `kind`; flat, one subject per record | | Who may do what | `MemoryGrant` — capability set + optional subject scope + optional `context_ids` + `shared_access` + expiry | | Capture and review | `memory.capture` → `MemoryCandidate` (immutable `capture_scope`, advisory `classification`) → `memory.candidates.review` / `.dismiss` | | Retained memory | `RetainedRecord` — untrusted `content` (`text` + optional opaque `structured` + optional recorded-at-time `cognition`), placement `scope`, provenance, `revision`, optional `tombstone` | | Reading memory | `ReadSelection` (placement modes) + `ProvenanceFilter` (source-context projection) + optional `cognition_filter` + `memory.recall` budget/truncation | | Authored basis | `ProfileDocument` — trusted author-maintained content kinds (`personality`, `preferences`) | | Derived synthesis | `Reflection` — generated over a pinned basis, `stale` computed at read time | | Outcomes | `MemoryReceipt` — durable, payload-bound, content-free (`committed` / `pending` / `failed`) | | Provider replacement | `MemoryExportBundle` + `MemoryExportChunk`, plus the staged-import activation flow | ## Capability flags | Flag | Gates | Ops | |------|-------|-----| | `spoke-memory` | Family core | 25 core `memory.*` ops | | `spoke-memory-reflection` | Reflection generation, state, listing | `memory.reflections.state`, `.list`, `.generate` | | `spoke-memory-transfer` | Export/import | `memory.exports.create`, `.get`, `memory.imports.stage`, `.activate`, `.discard` | A host declaring a sub-flag also declares `spoke-memory`; a host serving only `spoke-memory` answers the sub-flag ops with `capability_missing` before any handler runs. Flags are negotiated per session over connect, and the dispatch gate evaluates the negotiated set only. ## Operations | Op | Intent | |----|--------| | `memory.describe` | Provider self-description: protocol version, served flags, limits, required profile kinds, engine policy version | | `memory.spaces.list` | Spaces visible to the caller, with `pending_receipts` (the drain-barrier read) | | `memory.spaces.freeze` | Freeze a space for maintenance or migration | | `memory.spaces.unfreeze` | Re-open a frozen space | | `memory.subjects.ensure` | Idempotent get-or-create of a subject | | `memory.subjects.get` | One subject record | | `memory.subjects.fence` | Fence, unfence, or archive a subject; fence or unfence one placement context | | `memory.grants.list` | Grants in a space (admin view or the caller's own) | | `memory.grants.grant` | Issue a grant within the issuer's own coverage | | `memory.grants.revoke` | Terminally revoke a grant | | `memory.capture` | Capture raw material into pending candidates | | `memory.candidates.list` | Pending and consumed candidates | | `memory.candidates.get` | One candidate | | `memory.candidates.dismiss` | Consume a candidate without a review decision | | `memory.candidates.review` | Promote, fragment, or drop — the only candidate → record path | | `memory.records.create` | Direct authored write | | `memory.records.get` | One retained record, re-authorized | | `memory.records.update` | Update content or kind (placement changes use `promote`) | | `memory.records.delete` | Remove content, keep a tombstone | | `memory.records.list` | Selection + filter listing with `total_authorized` | | `memory.records.promote` | Explicit placement change between contexts or to shared | | `memory.recall` | Authorized, ranked, budgeted execution-time read | | `memory.profile.get` | Authored profile documents (absent kinds are honest-empty) | | `memory.profile.update` | Create or update an authored profile document | | `memory.receipts.get` | Poll a receipt, resolve an uncertain outcome | | `memory.reflections.state` | Current basis digest, staleness, data sufficiency, missing required kinds | | `memory.reflections.list` | Reflections, newest first, staleness computed at read time | | `memory.reflections.generate` | Explicit cost-bearing generation over a pinned basis | | `memory.exports.create` | Produce a chunked logical export | | `memory.exports.get` | Fetch one export chunk | | `memory.imports.stage` | Stage chunks in strict ordinal order and verify on the final chunk | | `memory.imports.activate` | Atomic visibility flip; bumps `authority_generation` | | `memory.imports.discard` | Pre-activation rollback of staged data | Read ops return their payload; every mutating op returns a `MemoryReceipt`. Only reflection generation, export creation, the final staging chunk, and import activation may report `pending`, and completion is observed through `memory.receipts.get`. ## Cognition content and filters (`spoke-cognition`) `Content.cognition` is an optional array of complete, closed standard cognition records. Every entry carries `view: "recorded_at"` and keeps its own attribution, basis and times; absence records that no standard cognition was recorded — an absent member is never serialized as `[]`, while an explicitly present empty array is a valid value preserved as a distinct wire value, neither produced by omission nor interchangeable with a non-empty array — and tombstoned content stays `{ "text": "" }` with no structured or cognition members. Raw text, `structured` or candidate material is never promoted to completed standard cognition by placement alone. Recorded cognition is validated as content, not as an authorization grant: every present member must be a closed record with `view: "recorded_at"`. The check runs on `memory.capture` and on `memory.records.create` / `.update` before the byte admission check, and a malformed member rejects the request with the operations-layer `INVALID_INPUT` on `content.cognition` and `details.reason: "invalid_cognition_record"`, distinct from the dispatch-layer `invalid_input` wire code of an unsupported cognition requirement. A non-empty cognition array counts toward the capture classification, and the existing content byte budget measures every explicitly present member rather than dropping it — `[]` carries its own admission bytes. Candidate review defaults to the captured proposed content: a `promote` decision may supply a new `content_override` and a `fragment` decision carries new fragment `content`, each validated independently by the same recorded-at-time rule before any state changes, with the override taking precedence over the proposed content. Review still requires its existing `review` and `records_write` grants covering the candidate's `capture_scope`, inherits that scope verbatim, and pending review confers no authority. `cognition_filter` (a `CognitionFilter`) is accepted on exactly five requests — `memory.recall`, `memory.records.list`, `memory.reflections.state`, `memory.reflections.list` and `memory.reflections.generate` — and requires the negotiated `spoke-cognition` flag. It is a hard filter over recorded-at-time content: authorization runs first and existing grant coverage still gates the call; given the eligible set, a record matches only when at least one of its `Content.cognition` entries satisfies the complete conjunction. It composes with `selection` and the provenance `filter` by AND, binds cursor identity (any state or grant change invalidates the cursor with `CURSOR_INVALID`), and applies before counts, ranking, page limits and byte budgets. `memory.records.list` reports `total_authorized` over that surviving set, and `memory.recall` ranks and truncates only within it. The filter never widens or replaces an existing grant and adds no OR, text similarity, ranking input or default cutoff. The three reflection requests accept the same filter and pin it. `memory.reflections.generate` writes it to `Reflection.cognition_filter` and into the reflection basis: the basis identity covers the filter together with the authorized record id and revision set, profile revisions and engine policy identity, so a changed filter or source set changes `state_digest` and the reflection reads stale. `memory.reflections.list` matches on the pinned filter, and `memory.reflections.state` includes it in the digest it returns. Absent means no pinned filter. An unknown predicate or unimplemented dimension rejects the whole request at the dispatch boundary with the `invalid_input` wire code and `details.reason: "unsupported_cognition_requirement"`, exactly as in [Cognition selection](https://spoke.42ch.dev/reference/ops#cognition-selection-spoke-cognition). Wire shapes are in [Data model reference §Memory cognition](https://spoke.42ch.dev/reference/data-model#memory-cognition). ## Data models | Model | Role | |-------|------| | `MemorySpace` | One authoritative store; unit of freeze, export, and import activation | | `Subject` | The entity memory is about; `active` / `fenced` / `archived` | | `Principal` | Authenticated caller identity, established by the admission context | | `MemoryGrant` | Provider-enforced authorization record | | `RetainedRecord` | Reviewed or directly authored durable memory | | `MemoryCandidate` | Provisional capture awaiting review | | `ProfileDocument` | Trusted authored personality/preferences basis | | `Reflection` | Derived synthesis over a pinned basis | | `MemoryReceipt` | Content-free operation receipt (the single async model) | | `ReadSelection` | Placement selection mode for reads | | `ProvenanceFilter` | Source-context projection filter, independent of placement | | `MemoryExportBundle` | Versioned, digested logical export | | `MemoryExportChunk` | Bounded wire unit of an export | ## Provider and adapter guidance `MemoryPort` is the standalone optional boundary — a TypeScript interface and a Rust trait, one typed method per `memory.*` op, with the authenticated principal passed explicitly. Illustrative shape (request/response types are the generated `memory-*` wire types): ```ts import type { MemoryPort } from "@42ch/spoke-operations"; import type { Principal } from "@42ch/spoke-schemas"; const port: MemoryPort = { describe(principal: Principal, request: MemoryDescribeRequest) { /* … */ }, capture(principal: Principal, request: MemoryCaptureRequest) { /* … */ }, // … 33 typed methods, one per memory.* op }; ``` - **Availability probe.** `MemoryPorts.asMemory()` (TS) / `MemoryPorts::as_memory()` (Rust) reports whether an adapter serves the family. `MemoryPort` stands alongside the baseline port families as an independent optional interface: products that serve memory implement it, and all others leave the probe unimplemented. - **Provider duties stay in the provider.** Grants and non-amplification, `revision` CAS, durable idempotency keyed on `(space_id, principal, op, idempotency_key)`, receipts and dedup tombstones, space freeze and subject/context fencing, and atomic candidate review all live in the provider's own transaction layer. - **Serving over connect.** The serving host exposes the connect-owned `RemoteMemoryService` seam; `MemoryPortService` implements it over a provider `MemoryPort` plus an admission context that maps the authenticated peer to a `Principal`. One callable serves the whole family, with the 33 typed operations as the decode surface. - **Algorithm policy is separable.** The `spoke-fixture-memory-engine` Rust crate at `fixtures/memory-engine/rust` is an unpublished in-repo fixture holding deterministic, I/O-free policy (candidate classification, recall ranking and budget, reflection assembly and staleness, transfer partitioning/digests/translation). Products do not depend on it: providers implement `MemoryPort` directly and may run this fixture engine's policy, their own policy, or none, and stay wire-compatible. - **Profiles are authored, records are captured.** Profile content is trusted author material and grants no authorization by itself; record and candidate content is untrusted data. Reflection generation is admitted only when the provider's `required_profile_kinds` are present, and an absent non-required kind is an honest-empty result. ## Field tables and spec Wire-level detail lives in the spec corpus (SSOT); this page links to it rather than re-stating it: - [Memory family spec](https://github.com/42ch-dev/spoke/blob/main/.agents/specs/memory/spoke-memory.md) — capability flags, invariants `MEM-R001`–`MEM-R027`, data-model field tables, per-op contracts, errors and retry classes, wire mapping, ProfileDocument substance. - [Memory capability ADR](https://github.com/42ch-dev/spoke/blob/main/.agents/adr/implemented/feature/2026-10-29-spoke-memory-family.md) — optional-family decision, single-authority model, receipt async model, engine/provider split. - [Memory data schemas](https://github.com/42ch-dev/spoke/tree/main/schemas/data) and [memory op schemas](https://github.com/42ch-dev/spoke/tree/main/schemas/ops) — the committed wire truth. - [Connect reference](https://spoke.42ch.dev/reference/connect) — the envelopes that carry `memory.*` payloads. ## Related - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — the three columns, capability flags, and schema inventory. - [Ops wire reference](https://spoke.42ch.dev/reference/ops) — baseline and optional op families, `ErrorEnvelope`, shared rules. - [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — the standard records carried in `Content.cognition`, the filter grammar and reflection basis. - [Concepts](https://spoke.42ch.dev/explanation/concepts) — how memory sits beside canonical knowledge. - [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — the port families per capability, including the optional memory provider. # 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/`](https://github.com/42ch-dev/spoke/tree/main/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)](https://spoke.42ch.dev/reference/connect#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`. The committed schema also lists `signature` as required because it encodes the protocol_version 2 wire shape; protocol_version 1 peers predate that field. | 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)](https://spoke.42ch.dev/reference/connect#envelope-authentication-protocol-version-2) | | `extensions` | Product namespace bag | ### ConnectInvokeRequest / ConnectInvokeResponse — remote op calls `ConnectInvokeRequest` required: `session_id`, `sequence`, `request_id`, `op`, `payload`, `extensions`. The committed schema also lists `signature` as required because it encodes the protocol_version 2 wire shape. | 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)](https://spoke.42ch.dev/reference/connect#port-method-ops-remoteadapter); the `extract` core op is documented in [Knowledge extraction and ownership (RemoteAdapter)](https://spoke.42ch.dev/reference/connect#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)](https://spoke.42ch.dev/reference/connect#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`) 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](https://www.rfc-editor.org/rfc/rfc8785)). 4. The bytes are signed with the Ed25519 keypair; the raw signature is encoded base64url without padding ([RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648)). 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 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 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 | 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_id`s; `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)](https://spoke.42ch.dev/reference/connect#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](https://spoke.42ch.dev/reference/connect#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`: 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` 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`, 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`](https://github.com/42ch-dev/spoke/blob/main/schemas/data/tool-descriptor.schema.json)): | Field | Type | Notes | |-------|------|-------| | `schema_version` | number | The shared `SchemaVersion` | | `capability_id` | string | The tool capability string `tools..` 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: `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`. 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": }`; the success payload is `{ "result": }` — `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](https://spoke.42ch.dev/how-to/connect-cpp-binding)) | | **Rust reference** | The published `spoke-connect` crate: session-core reference, uniffi binding source, and a rust-libp2p transport stack ([crate README](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/README.md)) | 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](https://spoke.42ch.dev/tutorials/first-connect-session) — the flow end to end. - [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) — the language-native client surface. - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — dial a remote peer over a consumer `Transport` and call its `BaselinePorts` surface. - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — the router recipe over N registered adapters. - [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) — advertise, register, discover, and reverse-invoke tools over a session. - [Connect from native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) — the FFI bindings with install pins. - [Connect architecture](https://spoke.42ch.dev/explanation/connect) — session lifecycle, envelope authentication, and capability routing. - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — the `spoke-connect` capability flag. # Concepts SPOKE defines its vocabulary in **wire terms** — every concept below is a concrete JSON shape or open-string vocabulary on the protocol surface. This page is the key-statement tour: what the nine layers are, what capability flags claim, and which pairs the protocol keeps deliberately separate. Field-level detail lives on the [Reference](https://spoke.42ch.dev/reference/protocol) pages. The nine SPOKE protocol layers with their primary wire artifacts The nine SPOKE protocol layers with their primary wire artifacts ## The nine layers (L0–L8) | Layer | What it carries | |-------|-----------------| | **L0 Envelope** | Identity + `schema_version` on all durable objects | | **L1 Ontology** | Open `entry_type` strings + Domain Profiles | | **L2 Body** | Closed `body`: summary, tags, scalar `attributes` (optional `state` / `computable` under `l2-computable`) | | **L3 Provenance** | SourceAnchor pointers | | **L4 Graph** | Typed directed Relations (with optional `revision` OCC) | | **L5 Temporal** | TimelineEvent when-axis + projection tiers `brief` / `narrative` / `moment` (optional Fork under `l5-fork`; optional `MindState` mental-state records under `spoke-cognition`) | | **L6 Constraint** | Rule declarative input to `check` | | **L7 Finding** | Checker output + status lifecycle | | **L8 Context** | AssemblePacket wire shape (assembly itself stays product-local) | ## Capability levels A product claims compliance at a declared capability level. **`spoke-baseline`** covers L0–L8 semantics via the five ops wire families, `HostCapabilityManifest` + baseline `HostManifestPort`, and the shared `Scope` / `error-envelope` definitions. Optional flags are additive — baseline compliance stands alone: - **`l2-computable`** — computable knowledge state and its history, with the `project` / `compute` ops. - **`l5-fork`** — world-history branch metadata on the timeline. - **`spoke-cognition`** — one family for standard, attributable cognition: `modules.cognition` records (Belief, epistemic knowledge/ignorance/undetermined, Perception) with mandatory `q`, `Scope.cognition` filters and attribution-preserving projections, `modules.observation` on the timeline, Memory `Content.cognition`, and the derivative `MindState` temporal view — see the [MindState reference](https://spoke.42ch.dev/reference/mind-state) and [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition). - **`narrative-modules`** — the optional `modules` bag for cross-product functional dialects. - **`ke-extraction`** — the optional `extract` op: referenced source material in, provisional KnowledgeEntry candidates out. - **`ke-ownership`** — ownership and disclosure on the KnowledgeEntry envelope, with the `Scope.viewpoint` reader selector. - **`spoke-connect`** — the opt-in cross-process interaction envelope family. - **`spoke-memory`** — per-subject memory: thirteen models and 33 `memory.*` ops behind the standalone `MemoryPort`, with optional `spoke-memory-reflection` and `spoke-memory-transfer` sub-flags — see the [Memory reference](https://spoke.42ch.dev/reference/memory). The connect family's session lifecycle, envelope authentication, and capability routing are explained in [Connect architecture](https://spoke.42ch.dev/explanation/connect). ## Dual-concern pairs The protocol keeps three pairs deliberately separate: - An ontology `entry_type: "event"` label on a KnowledgeEntry versus the L5 `TimelineEvent` when-axis object — one local concept may map to one or both wire shapes. - An ontology `entry_type: "rule"` label versus the L6 `Rule` checker input — Rule is declarative input, Finding is checker output. - An ontology `entry_type: "character"` / profile `mind` label versus the L5 `MindState` temporal record — the label classifies a knowledge node; `MindState` presents a derivative snapshot / delta of the holder's mental state, while the holder's `modules.cognition` records and `modules.mental` fields carry the authoritative values. The names stay separate so `check` / `assemble` selectors remain unambiguous. ## Memory beside knowledge Canonical knowledge and subjective memory are two different jobs, so the protocol keeps them in two families. A KnowledgeEntry is a shared, reviewed node other hosts can check and assemble; retained memory carries two admission paths: captured material becomes a `MemoryCandidate` and counts only after `memory.candidates.review`, while `memory.records.create` writes a retained record directly as an authored management write. Reads follow the caller's grants. | Aspect | KnowledgeEntry | Memory record | |--------|----------------|---------------| | Role | Canonical knowledge node on the shared surface | Per-subject impression captured by a host | | Subject | Not subject-scoped | Exactly one `subject_id` (flat subjects) | | Admission | `upsert`, `promote` | Captured: `memory.capture` → `MemoryCandidate` → `memory.candidates.review`. Authored management write: `memory.records.create` | | Content trust | Envelope carries `ke-ownership` disclosure fields | Record and candidate content is untrusted data; profile documents are trusted author material | | Forgetting | Status lifecycle (`deprecated`, `merged`, `deleted`) | Explicit operations only: drop/dismiss, tombstone delete, subject archive, import discard | | Read axis | `check` / `assemble` over shared `Scope` | `ReadSelection` placement modes plus `ProvenanceFilter` | | Authorization | `ke-ownership` disclosure predicate over `Scope.viewpoint` | Self-contained `MemoryGrant` | Three consequences follow from that split. Emotion travels as an opaque convention inside `content.structured` (for example `{"affect":{"valence":0.6}}`), so mood taxonomies stay product policy. Author profiles (`ProfileDocument`) carry the refined authored basis, records carry concrete experiences, and reflections are the derived synthesis across records — three layers with distinct roles. And provider replacement is a wire procedure: a versioned, digested export/import with an operator-timed cutover and a single authority throughout. Standard cognition crosses the two families in a defined way: the holder KnowledgeEntry carries current authority in `modules.cognition`, and a retained record keeps its recorded-at-time view in `Content.cognition`, filtered through `cognition_filter`. Each placement preserves its own attribution and times. ## Open vocabulary and Domain Profiles Core objects keep closed envelopes (`additionalProperties: false`), while core vocabulary stays **open**: `entry_type`, `relation_type`, and statuses are open strings with documented core lists, not closed enums. A Domain Profile publishes ontology vocabulary over those open strings — beat mapping, lore activation, knowledge packs — and products express profile-specific types with open strings and `extensions.`. See [Domain profiles](https://spoke.42ch.dev/explanation/domain-profiles). ## Selectors and extension points - **Scope** — the shared ops selector for `check` / `assemble`: required opaque `scope_id` plus optional refinements (`entry_ids`, `entry_types`, `timeline_scale`, `fork_id`, `viewpoint`, `cognition`, …); `viewpoint` is the `ke-ownership` reader selector, and `cognition` is the `spoke-cognition` selection/projection carrier. - **Extensions** — `extensions.` product bag on every durable object; adapters round-trip unknown namespaces verbatim. - **Modules** — the optional `modules.*` bag (capability-flagged `narrative-modules`) for cross-product functional dialects on KnowledgeEntry, AssemblePacket, and TimelineEvent. ## Pure-library posture The operations library is a pure behavior layer over the generated wire types: lifecycle helpers, capability-sliced adapter ports, and injection orchestration — no I/O of its own. Storage access, LLM calls, ranking, retrieval, and transport binding are supplied by products through injected ports; the reference adapters in `fixtures/toy-world/` demonstrate the pattern. ## Related - [Protocol reference](https://spoke.42ch.dev/reference/protocol) — schema inventory, extensions contract, capability flags. - [Data model reference](https://spoke.42ch.dev/reference/data-model) — field tables per layer. - [Domain profiles](https://spoke.42ch.dev/explanation/domain-profiles) — the published open-string vocabularies. - [MindState reference](https://spoke.42ch.dev/reference/mind-state) — the derivative L5 temporal view (`spoke-cognition`). - [Cognition records](https://spoke.42ch.dev/reference/data-model#cognition-spoke-cognition) — `q`, metrics, filter grammar and projection rules. - [Memory reference](https://spoke.42ch.dev/reference/memory) — the optional `spoke-memory` family and how it sits beside canonical knowledge. # Connect architecture **Connect** is the opt-in interaction envelope family for cross-process SPOKE hosts — one integrator journey: install → a language-native client session → a RemoteAdapter over a consumer `Transport` → multi-peer routing → native bindings → a loopback smoke. This page explains the concepts behind that journey; the [tutorial](https://spoke.42ch.dev/tutorials/first-connect-session) walks it, the [how-to guides](https://spoke.42ch.dev/how-to/connect-remote-adapter) are the recipes, and the [wire reference](https://spoke.42ch.dev/reference/connect) is the dictionary. A connect session: signed hello, negotiated session, ordered invoke A connect session: signed hello, negotiated session, ordered invoke ## The three embedding surfaces | Surface | What ships | When to choose it | |---------|------------|-------------------| | **Language-native client** | The wire contract and session-core rules implemented in the host language — the TypeScript `@42ch/spoke-connect` client, paired with the platform WebSocket | No Rust runtime in the host; browser or Node consumers | | **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](https://spoke.42ch.dev/how-to/connect-cpp-binding)); the C and C++ surface pairs the C ABI carrier with a C++17 header-only convenience layer | Host languages with an FFI story that want the core implemented once, with transport in the host | | **Rust reference** | The published `spoke-connect` crate: the session-core reference, the binding source, and a rust-libp2p transport stack | Rust consumers, and the reference for byte-level parity everywhere | All three surfaces share the same session-core rules — `peer_id` derivation, hello crypto, allowlist, nonce, sequence, correlation, dispatch gate — locked by golden vectors. ## Session lifecycle A connect session passes through four states: `Disconnected` → `Handshaking` → `Established` → `Closed`. The handshake is a signed hello exchange. Each side signs a canonicalized object — `{protocol_version, peer_id, nonce, host}` (initiator) or the same plus `peer_nonce` (responder) — with its Ed25519 peer identity. The responder's `peer_nonce` echoes the initiator's nonce, binding the dial: a captured responder hello cannot be replayed into a fresh dial. Admission is fail-closed on an allowlist, and each accepted `(peer_id, nonce)` pair is single-use. Once both hellos are accepted, the session snapshot (`ConnectSession`) records the session id, the bound peer ids, the negotiated capabilities, and the starting sequence. From `Established` on, each peer maintains its own per-direction `sequence` counter starting at 0, and every invoke carries a caller-generated `request_id`; a response must echo `session_id`, `sequence`, and `request_id` or the correlation check fails. Sequence exhaustion closes the session rather than wrapping — a fresh session starts clean counters instead of reusing sequence space. ## Envelope authentication Envelope authenticity is a **protocol-level property above the transport**: every post-hello trust-affecting envelope — the session snapshot, invoke requests, and invoke responses — carries an Ed25519 signature over the RFC 8785 JCS-canonicalized signed object, verified inside the session core. The construction is the same as the hello, extended to three algorithm ids (`spoke-connect-session-jcs-v1`, `spoke-connect-invoke-request-jcs-v1`, `spoke-connect-invoke-response-jcs-v1`). The post-hello envelopes carry the protocol_version 2 signature fields. The shipped session core advertises `protocol_version: 1` in the hello, whose `spoke-connect-hello-jcs-v1` construction is unchanged. This is why the adapter works over any ordered, reliable carrier — TCP, WebSocket, yamux, or Noise — without trusting the transport for authenticity: the envelope itself is authenticated, and a transport that supplies an authenticated peer identity does not relax the rules. The signed object covers the trust-affecting fields; `extensions` stays outside the signature, so it never influences authorization. Mixed-version peers fail closed: a hello carrying any `protocol_version` other than 1 is rejected before signature verification, and there is no compatibility shim. ## Capability routing A **RemoteAdapter** implements the async `BaselinePorts` adapter contract by proxying each port call as a reserved `port.*` op over an established session — the remote host's port surface appears local. A **multi-peer router** composes N registered adapters behind the same `BaselinePorts` surface, so `orchestrateUpsert(router, req)` reaches a capable peer without naming one. Two capability flags extend that surface beyond `port.*`: the optional `extract` op dispatches once the session's negotiated capabilities carry `ke-extraction`, and a Scope carrying a non-empty `viewpoint` requires `ke-ownership` in addition to the op's row capability. [Knowledge extraction and ownership](https://spoke.42ch.dev/reference/connect#knowledge-extraction-and-ownership-remoteadapter) in the wire reference holds the full contract. The router's selection is a pure function of the registered peers and the request: hard gates on the peer's declared capabilities (the op's required capability), namespaces, and `authority.scope_key`; a soft preference for the op's preferred role; and a deterministic lowest-`peer_id` tie-break. When no registered peer passes the hard gates, the call rejects with `no_capable_peer` — the consumer registers a satisfying peer and re-invokes with a fresh `request_id`. Retry is consumer-owned: a call may have been applied before a transport failure, so the consumer decides whether re-running the operation is safe. Capabilities have two sources: the session's `negotiated_capabilities` (the agreed subset of both hosts' lists) and capability tokens (short-lived, capability-scoped grants from a trusted issuer). A token grant authorizes membership for the ops it covers, but it does not replace the negotiated set — both must allow an op when the token gate is active. ## Bidirectional capability flow A session carries capability traffic in both directions, and the two directions have distinct shapes: | Direction | Provider | Consumer | Surface | |-----------|----------|----------|---------| | **Ports** | The host serves its local `BaselinePorts` | The dialer consumes them as a drop-in async surface | Reserved `port.*` ops over the D4 catalogue | | **Tools** | The dialer registers handlers for its declared tools | The host discovers them from the authenticated manifest and reverse-invokes them mid-orchestration | `tools.*` ops whose op string IS the tool capability id | The port direction is host-to-client consumption: the host's `connectResponder` serves `port.*` invokes against the injected `BaselinePorts`, and the dialer's `RemoteAdapter` proxies each port method into an invoke, so the remote port surface appears local. The tool direction is client-to-host provision: the dialer's manifest declares `tools[]`, the dialer registers handlers on its `RemoteAdapter`, and the host lists those tools from the authenticated manifest and invokes them with the responder's `invokeTool` face. Both directions negotiate through the same mechanism — a capability string must be in `negotiated_capabilities` for its op to dispatch. A tool is therefore negotiated exactly like any other capability: both peers list `tools.toy_world.roll_dice`, the pair intersects, and a `tools.toy_world.roll_dice` invoke dispatches on either side of the session. Discovery for tools is a property of the authenticated session: the host reads the dialer's manifest it already verified at the handshake, so there is no separate advertisement round-trip. The deny path is shared: an op that is not negotiated, or a tool with no registered handler, answers the wire code `op_unsupported`, mapped to a `CAPABILITY_PORT_MISSING` reject on the invoker — the orchestration observes the denial rather than a silent success. See [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) for the full recipe, and the [wire reference](https://spoke.42ch.dev/reference/connect#tools-reverse-invokes) for the field table and dispatch rule. ## Where transport lives Transport is a consumer-implemented seam. The connect packages define a message-oriented `Transport` — one connect envelope per `send` / `recv` call, blocking `recv` until an envelope arrives or the connection closes, idempotent `close` — and ship an in-memory loopback pair for tests. WebSocket and other carriers are consumer-side implementations of the same three methods; byte-stream carriers apply length-prefix (or equivalent) delimiting. A C++ host supplies the same three methods through the C ABI carrier's callback bridge — [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding). The loopback pair is test-only — the smoke that dials through it is a verification flow, not a production carrier. ## Related - [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) — the learning path, step by step. - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — dial a remote peer over a consumer `Transport`. - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — one router over N registered adapters. - [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) — the tool direction of the bidirectional flow, end to end. - [Connect from native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) — the shared session core over FFI. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables and the v2 envelope-authentication rules. # Domain profiles A **Domain Profile** publishes ontology vocabulary over the core wire shapes: profile-specific `entry_type` labels, `relation_type` values, body-attribute traits, and `modules.*` dialects. The core schemas stay unchanged — the vocabulary rides open strings and the optional `modules` bag. Four profiles and one assemble recipe set are documented on this page; each section lists the published open-string vocabulary integrators can emit and consume. ## Narrative structure **Beat-assisted narrative outlining** over existing SPOKE wire shapes: ordered story pivots, scene atoms, and structural roles. | Published vocabulary | Wire home | Values | |----------------------|-----------|--------| | Profile entry type | `KnowledgeEntry.entry_type` | `beat` (profile-only label, valid open string) | | Structural roles | `KnowledgeEntry.body.attributes[].trait_type` | `structural_role` with slots such as `midpoint`, `catalyst`, `finale` | | Beat ordering | `Relation.relation_type` | `precedes` (or `follows`) between dual KnowledgeEntry ids | | Moment tier | `TimelineEvent.timeline_scale` / `Scope.timeline_scale` | `moment` — atomic / scene beats are TimelineEvents at this tier | | Beat–entry link | `TimelineEvent.extensions.spoke` | `timeline_entry_id` pointing at the dual KnowledgeEntry | | Parenthetical pauses | `SourceAnchor.span` | screenplay `(beat)` pauses on dialogue text | Key statements: an atomic / scene beat is a `TimelineEvent` with `timeline_scale: "moment"`, optionally paired with a KnowledgeEntry (dual-concern); a structural beat is a `BodyAttribute` with `trait_type: "structural_role"`; ordering runs through `precedes` Relations; selection uses `Scope` filters with `timeline_scale: "moment"`, `timeline_event_ids`, or `entry_types` including `beat`. The operations library exports moment-scale filters and beat-sheet ordering helpers (`filterTimelineEventsByMomentScale`, `orderTimelineEventsByIds`, `orderTimelineEventsByPrecedes`) — pure functions over caller-supplied arrays. ## Lore activation **Lore activation** defines the fire conditions under which a KnowledgeEntry prefers to surface into assembled context. It lives as the `modules.activation` inner dialect on the optional `modules` bag (capability-flagged `narrative-modules`); matching, scanning, and ranking stay product-local. | Published vocabulary | Wire home | Values | |----------------------|-----------|--------| | Trigger keys | `modules.activation.keys` | primary activation triggers (aliases, names, phrases) | | Selective combination | `modules.activation.secondary_keys` + `.logic` | `and_any`, `and_all`, `not_any`, `not_all` | | Always-on seed | `modules.activation.constant` | `true` marks an always-on seed candidate (valid with empty `keys`) | | Insertion hints | `modules.activation.order` / `.priority` | ordering and tie-break hints | | Placement hints | `modules.activation.position_hint` / `.outlet` | `before_defs`, `after_defs`, `depth`, `outlet` | | Match flavor | `modules.activation.match` | literal, regex, or whole-word key matching (flavor defined by the product scanner) | Key statements: `body.summary` and `AssemblePacket` entry `snippet` read as complete lore facts without the trigger keys (standalone snippet); `constant: true` entries feed the always-on seed set while keyed entries feed the activation pool; lore adjacency expands through `Relation` edges. ## Knowledge pack A **Narrative Knowledge Pack** is a portable lore bundle: an ordered set of KnowledgeEntries, Relations, and optional SourceAnchors that travel between narrative hosts. Packs use existing wire atoms plus a product transport envelope for catalog metadata. | Published vocabulary | Wire home | Values | |----------------------|-----------|--------| | Pack atoms | existing wire objects | KnowledgeEntry (lore nodes), Relation (graph edges), SourceAnchor (optional provenance) | | Catalog metadata | product transport envelope | `title`, `version`, `creator`, optional `description` — not `modules.*` on KE or AssemblePacket | | Per-entry fire conditions | `modules.activation` per KnowledgeEntry | travels with each entry when the pack carries activation | | Round-trip | `extensions` / `modules` / open strings | importers preserve unknown namespaces, unknown module keys, and open-string vocabulary verbatim | Key statements: pack ≠ AssemblePacket (durable library interchange vs ephemeral assemble output); narrative hosts stack multiple packs product-side (world pack, character pack, session pack) — import atoms preserving export order, merge Relation graphs by id, union seed and pool sets, then run activation / scope / budget after the merge; stack policy (priority, override, soft-delete) is product-local. ## Assemble recipes Two **packet-level** companion dialects under the optional `AssemblePacket.modules` bag: where each assembled entry prefers to inject, and why it was activated. | Published vocabulary | Wire home | Values | |----------------------|-----------|--------| | Placement rows | `AssemblePacket.modules.placement[]` | `entry_id` + `position_hint` (`before_defs`, `after_defs`, `depth`, `outlet`), optional `depth` / `outlet` | | Activation trace rows | `AssemblePacket.modules.activation_trace[]` | `entry_id` + `reason` (`constant`, `key`) with optional matched-key detail | Key statements: array order is the interchange order — hosts apply their own layout after reading the hints, and an entry may omit a placement row (hosts then use a local default); join `placement[]` / `activation_trace[]` to `entries[]` by `entry_id`; entry-level `modules.activation` is the durable authoring home for preferences, while packet-level `placement[]` is the assembled snapshot for this packet; baseline `AssemblePacket` stays wire-only slim entries — `modules` is opt-in via `narrative-modules`. ## Mental state **Mental state** defines how narrative hosts exchange cognition and mental-state data without a shared engine. It carries four concepts on three record categories under one capability flag (`spoke-cognition`), together with the open `modules.mental` vocabulary and the derivative `MindState` record. | Published vocabulary | Wire home | Values | |----------------------|-----------|--------| | Mental fields | `KnowledgeEntry.modules.mental` | nine-field vocabulary: `identity`, `beliefs`, `attention`, `goals`, `intentions`, `emotions`, `dispositions`, `norms`, `constraints`; `beliefs` carries derivative summary, count or reference data | | Standard cognition | `KnowledgeEntry.modules.cognition` | `records[]` of Belief, epistemic (knowledge/ignorance/undetermined) and Perception records, plus `world_facts[]`; every completed Belief, epistemic or Perception record carries a declared `q` and may carry distinct metrics | | Belief dimensions | Belief record fields | `order` (0–3), `truth` (`True`/`False`/`Unknown`), `access` (`Private`/`Shared`/`Public`), `representation` (`Explicit`/`Implicit`), `content_type` (eight labels), `source` (seven acquisition labels), `context` (`Deceptive`/`Temporal`/`Counterfactual`/`Neutral`) | | Event observation | `TimelineEvent.modules.observation` | `observers: CognitionReference[]` (required; `[]` states that no observer could perceive), optional open `access` constraints | | Temporal record | `MindState` wire object | `mind_state_id`, `holder_entry_id`, `snapshot`, `deltas`, optional `cognition` projection — strictly derivative | Key statements: the holder KnowledgeEntry carries the authoritative values — standard records under `modules.cognition` and open mental fields under `modules.mental` — while `MindState` presents the temporal trajectory. A false belief is an actor Belief record with `truth: "False"` alongside the world fact it contradicts; a Perception record describes actual capture and keeps partial, misleading, illusory or disbelieved content. Event access metadata describes who could perceive and makes no actual-capture claim. Filtering uses `Scope.cognition` on KnowledgeEntry surfaces and `cognition_filter` on Memory surfaces; both apply a one-record conjunction and reject unsupported requirements explicitly. ## Related - [Data model reference](https://spoke.42ch.dev/reference/data-model) — the open-string fields these vocabularies ride. - [Concepts](https://spoke.42ch.dev/explanation/concepts) — open-vocabulary posture and capability flags. - [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) — profile `entry_type: "beat"` in the committed fixture graph. # Implement an adapter Your product speaks SPOKE by implementing the **port families your claimed capabilities require** on one adapter type, then calling the matching `orchestrate*` entrypoints from the operations library. The adapter is the only place your product's storage and I/O touch the protocol — every read and write flows through it, and every protocol rule runs in the library before persistence. orchestratePromote loads state, runs gates, and persists through adapter ports orchestratePromote loads state, runs gates, and persists through adapter ports ## 1. Choose your capability level The adapter port types are sliced by capability. Pick the alias that matches the capabilities your host claims: | Capability flags | Ports to implement | Composed alias | |------------------|--------------------|----------------| | `spoke-baseline` | `KnowledgeEntryPort`, `RelationPort`, `ScopeQueryPort`, `FindingPort`, `RuleQueryPort`, `HostManifestPort` | `BaselineAdapter` | | `spoke-baseline` + `l2-computable` | baseline + `ComputablePort` | `ComputableAdapter` | | `spoke-baseline` + `l5-fork` | baseline + `ForkTimelineQueryPort` | `ForkAdapter` | | `spoke-baseline` + `ke-extraction` | standalone `ExtractionPort` | none — the port is passed to `orchestrateExtract` directly | | `spoke-baseline` + `spoke-memory` | standalone `MemoryPort` (+ `MemoryPorts` probe) | none — the port is your provider boundary and the connect serving seam entry point | | `spoke-baseline` + `l2-computable` + `l5-fork` | full composition | `FullAdapter` | The aliases name the same port intersections as `BaselinePorts` / `ComputablePorts` / `ForkPorts` / `FullPorts`. Import them from the operations package: ```ts import type { BaselineAdapter, ComputableAdapter, ForkAdapter, FullAdapter, KnowledgeEntryPort, RelationPort, ScopeQueryPort, FindingPort, RuleQueryPort, HostManifestPort, ComputablePort, ForkTimelineQueryPort, ExtractionPort, MemoryPort, MemoryPorts, } from "@42ch/spoke-operations"; ``` ## 2. Implement the ports Port methods are async on the normative surface: TypeScript methods return `Promise>` and Rust traits declare `async fn …(&self, …) -> SpokeResult` — the orchestrators `await` every call. ### KnowledgeEntryPort — entry persistence ```ts getKnowledgeEntry(entryId: string): Promise>; putKnowledgeEntry( entry: KnowledgeEntry, expectedBaseRevision: number | null, ): Promise>; ``` `putKnowledgeEntry` is optimistic-concurrency controlled: `expectedBaseRevision: null` means the entry must be absent (create); a non-null value means the store's current revision must equal it (update). Reject with `STORED_REVISION_STALE` or `REVISION_CONFLICT` otherwise. For real concurrency safety, implement an atomic compare-and-put (CAS) in the adapter — the library stays I/O-free. ### RelationPort — relation persistence ```ts getRelation(relationId: string): Promise>; putRelation( relation: Relation, expectedBaseRevision: number | null, ): Promise>; ``` Revision assignment is adapter-owned: on create (`expectedBaseRevision: null`) seed `revision = 1`; on an accepted update persist `revision = stored + 1`. The returned Relation carries the assigned revision — callers do not set it. ### ScopeQueryPort — scoped reads for check/assemble ```ts listKnowledgeEntries(scope: Scope): Promise>; listTimelineEvents(scope: Scope): Promise>; ``` The orchestrators load scoped data through these ports, then apply the scope-filtering helpers (`filterKnowledgeEntriesByScope`, `filterTimelineEventsByScope`) to narrow to the request's `entry_ids` / `entry_types` / `timeline_scale` refinements. ### FindingPort — checker output persistence ```ts putFindings(findings: Finding[]): Promise>; ``` Findings are checker output, persisted through this port after `orchestrateCheck` runs your checker callback. ### RuleQueryPort — rule resolution ```ts listRules(ruleRefs: string[]): Promise>; ``` Resolves `check` rule references. Embedded `rules[]` in the request override by `rule_id`; unresolved refs reject the check. ### HostManifestPort — collaboration metadata ```ts getHostCapabilityManifest(): Promise>; listPeerHostCapabilityManifests(): Promise>; ``` The self manifest and product-known peer manifests. This port is baseline-required — it is the in-process collaboration surface (host roles, capability flags, owned namespaces). ### ComputablePort — optional `l2-computable` ```ts project(request: ProjectRequest): Promise>; compute(request: ComputeRequest): Promise>; ``` Session-scoped computable I/O. `orchestrateProject` / `orchestrateCompute` validate the request, then delegate to these methods; absent methods surface `CAPABILITY_PORT_MISSING` at dynamic boundaries. ### ForkTimelineQueryPort — optional `l5-fork` ```ts listForkTimelineEvents( scope: Scope & { fork_id: ForkId }, ): Promise>; ``` Fork-scoped timeline reads. One object may satisfy both `ScopeQueryPort` and this port. ### ExtractionPort — optional `ke-extraction` ```ts loadExtractionInput(request: ExtractRequest): Promise>; ``` Host-local source loading for the optional `extract` op: the port reads the referenced material and returns one in-process opaque value that stays local to the host. `ExtractionPort` is a standalone optional family, passed directly to `orchestrateExtract` together with your own async extractor callback. At a dynamic boundary, a missing port surfaces `CAPABILITY_PORT_MISSING` with `details.capability = "ke-extraction"`. ### MemoryPort — optional `spoke-memory` ```ts describe(principal: Principal, request: MemoryDescribeRequest): Promise>; capture(principal: Principal, request: MemoryCaptureRequest): Promise>; // … 33 typed methods, one per memory.* op ``` `MemoryPort` is the standalone optional boundary for per-subject memory: 33 typed methods (25 core, 3 reflection, 5 transfer), each carrying the authenticated `Principal` your admission context established. It stands alongside the baseline port families the way `ExtractionPort` does — an optional interface beside the adapter aliases. When you serve memory, implement `MemoryPort` and expose the `MemoryPorts` probe (`asMemory()` / `MemoryPorts::as_memory()`); otherwise leave the probe unimplemented and dynamic boundaries read its absence directly. Provider duties stay in your adapter's transaction layer: grant coverage and non-amplification, `revision` compare-and-set, durable idempotency keyed on `(space_id, principal, op, idempotency_key)`, receipts and dedup tombstones, space freeze and subject/context fencing, and atomic candidate review. The library supplies the deterministic helpers — request validators (`validateMemory*`), grant coverage (`grantCovers`), the freeze allowlist (`isFreezeAllowlisted`), and JCS/SHA-256 digests (`jcsSha256Hex`). Serving the family over connect means exposing the connect-owned `RemoteMemoryService`. RemoteAdapter consumers proxy memory ops through that service; the Rust `spoke-connect` crate also ships `MemoryPortService`, which adapts a `MemoryPort` to it with the same admission context. Declaring `spoke-memory-reflection` or `spoke-memory-transfer` requires `spoke-memory`; a host that declares only `spoke-memory` answers the reflection and transfer ops with `capability_missing` before any handler runs. See the [Memory reference](https://spoke.42ch.dev/reference/memory) for the op table and the [family spec](https://github.com/42ch-dev/spoke/blob/main/.agents/specs/memory/spoke-memory.md) for per-op grants, fields, and errors. ## 3. Keep the adapter I/O-bound The operations library is pure relative to host I/O: storage access, LLM calls, ranking, retrieval, and transport binding are supplied by your product through these injected ports. The adapter implements the ports; the library runs the gates. The reference `ToyWorldAdapter` in [`fixtures/toy-world/`](https://github.com/42ch-dev/spoke/tree/main/fixtures/toy-world) demonstrates the pattern — see [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) for the walkthrough. ## 4. Integrator notes - **Transaction boundaries are adapter-owned.** Multi-entry upsert and other multi-write sequences span several `put*` calls; your adapter decides where the atomic boundary lies. - **Active-uniqueness helpers take caller-supplied peer sets.** Orchestration supplies batch-local peers; pass a store-wide snapshot when uniqueness must span the whole store. - **Missing optional ports surface `CAPABILITY_PORT_MISSING`** when an orchestrator needs them at a dynamic boundary. `HostManifestPort` is baseline-required and never gated behind that code. ## Next steps - [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops) — the `orchestrate*` calls your adapter enables. - [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) — a complete `FullAdapter` in TypeScript and Rust, with a conformance harness. - [Data model reference](https://spoke.42ch.dev/reference/data-model) — the wire objects your ports persist. - [Ops wire reference](https://spoke.42ch.dev/reference/ops) — request/response envelope shapes. - [Memory reference](https://spoke.42ch.dev/reference/memory) — the optional `spoke-memory` family, its op table, and provider guidance. # Orchestrate operations The operations library exposes one **orchestrator per op family**. The persistence-bearing baseline orchestrators take your adapter (the port implementation from [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter)) plus the wire request, run the protocol gates, load and persist data through your ports, and return a `SpokeResult` — never a throw for expected rejects. Every orchestrator is an async entrypoint: call it with `await` (TypeScript) or `.await` inside an `async fn` (Rust). The baseline calls run unchanged against a [RemoteAdapter over a consumer Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) or a [multi-peer router](https://spoke.42ch.dev/how-to/multi-peer-routing) — either drops in as the `BaselinePorts` implementation. The optional extraction path takes its own standalone `ExtractionPort` plus the async extractor callback, loads the referenced input through that port, runs the extractor once, and returns provisional candidates for later admission through `promote`; extraction over connect is [delegated as a whole-operation remote op](https://spoke.42ch.dev/reference/connect#remote-extraction-ke-extraction). Source material to KnowledgeEntry, check output and assembled context Source material to KnowledgeEntry, check output and assembled context ## The orchestrators | Orchestrator | Request | Response | What it runs | |--------------|---------|----------|--------------| | `orchestrateUpsert(ports, request)` | `UpsertRequest` | `UpsertResponse` | validate → status gate → batch uniqueness → OCC put | | `orchestratePromote(ports, request)` | `PromoteRequest` | `PromoteResponse` | promote acceptance gates → merge target → OCC put | | `orchestrateRelate(ports, request)` | `RelateRequest` | `RelateResponse` | validate → create/update OCC put | | `orchestrateCheck(ports, request, runChecker)` | `CheckRequest` | `CheckResponse` | resolve rules → load scope → run checker → persist findings | | `orchestrateAssemble(ports, request)` | `AssembleRequest` | `AssembleResponse` | load scope → filter → build `AssemblePacket` | | `orchestrateProject(ports, request)` — `l2-computable` | `ProjectRequest` | `ProjectResponse` | validate → `ComputablePort.project` | | `orchestrateCompute(ports, request)` — `l2-computable` | `ComputeRequest` | `ComputeResponse` | validate → `ComputablePort.compute` | | `orchestrateForkCheck` / `orchestrateForkAssemble` — `l5-fork` | fork-scoped requests | same response shapes | require `scope.fork_id` → fork timeline reads | | `orchestrateExtract(ports, request, runExtractor)` — `ke-extraction` | `ExtractRequest` | `ExtractResponse` | validate → `ExtractionPort.loadExtractionInput` → `runExtractor` → provisional gate | ## Upsert — create or update entries ```ts import { orchestrateUpsert } from "@42ch/spoke-operations"; import type { UpsertRequest } from "@42ch/spoke-schemas"; async function runUpsert() { const result = await orchestrateUpsert(adapter, { knowledge_entries: [mira, harbor], }); if (result.ok) { console.log(result.value.knowledge_entries.map((e) => e.entry_id)); } } ``` `UpsertRequest` carries 1..n entries plus an optional `idempotency_key` (an opaque hint — wire semantics are product-side). The orchestrator validates each entry (`MISSING_REQUIRED_FIELD`, `EMPTY_CANONICAL_NAME`, …), gates status transitions when the entry already exists, checks active-uniqueness against the batch, and persists with the correct expected base revision. ## Promote — admit a candidate to durable storage ```ts import { orchestratePromote } from "@42ch/spoke-operations"; import type { PromoteRequest } from "@42ch/spoke-schemas"; async function runPromote() { const result = await orchestratePromote(adapter, { candidate: provisionalEntry, // typically status "provisional" target_entry_id: "kb_existing", // optional merge target }); } ``` Promote covers admission: it admits one candidate to durable storage, and extraction output reaches durability through this step. Promote runs the acceptance gates (`CANDIDATE_NOT_PROVISIONAL`, `CANDIDATE_TERMINAL_STATUS`, …) and the revision gate, applies the acceptance transition, and persists through `putKnowledgeEntry`. With a `target_entry_id`, the response carries `superseded_id` for the merged-away entry. ## Relate — typed directed edges ```ts import { orchestrateRelate } from "@42ch/spoke-operations"; import type { RelateRequest } from "@42ch/spoke-schemas"; async function runRelate() { const result = await orchestrateRelate(adapter, { relation: { schema_version: 1, relation_id: "rel_mira_harbor", relation_type: "located_in", from_id: "kb_mira", to_id: "kb_harbor", extensions: {}, }, }); } ``` Relation validation distinguishes create vs update (`RELATION_SELF_EDGE`, `RELATION_MISSING_ENDPOINT`, …), and the OCC-aware put handles revision assignment in your adapter. ## Check — run a checker over a scope `orchestrateCheck` loads the scoped rules and data first, then hands you a `CheckRunInput` — your checker callback returns `Finding[]`, and the orchestrator persists them: ```ts import { orchestrateCheck, spokeOk, type CheckRunInput } from "@42ch/spoke-operations"; import type { CheckRequest } from "@42ch/spoke-schemas"; async function runCheck() { const result = await orchestrateCheck(adapter, checkRequest, (input: CheckRunInput) => { // input: { request, entries, events, rules } const findings = myChecker(input.entries, input.rules); return spokeOk(findings); // or spokeReject(SpokeRejectCode.INVALID_INPUT, "...") }); } ``` Rules resolve from `rule_refs` via `RuleQueryPort`, with embedded `rules[]` overriding by `rule_id`. `check` returns findings only — use `assemble` for context packets. ## Assemble — build a context packet ```ts import { orchestrateAssemble } from "@42ch/spoke-operations"; import type { AssembleRequest } from "@42ch/spoke-schemas"; async function runAssemble() { const result = await orchestrateAssemble(adapter, { scope: { scope_id: "book-harbor", entry_types: ["character"] }, max_entries: 20, // optional entry limit hint }); } ``` The orchestrator loads the scope, applies scope filters, and builds a wire-only `AssemblePacket` with order-preserving truncation. Assembly itself — ranking, retrieval, token budgets — is product-side. ## Extract — propose provisional candidates `orchestrateExtract(ports, request, runExtractor)` is the optional `ke-extraction` path. `ExtractionPort` is a standalone optional family passed directly to `orchestrateExtract`, together with your own async extractor callback: ```ts import { orchestrateExtract, spokeOk, type ExtractionPort, type RunExtractor } from "@42ch/spoke-operations"; import type { ExtractRequest } from "@42ch/spoke-schemas"; const extractionPort: ExtractionPort = { async loadExtractionInput(request: ExtractRequest) { // host-local source loading: the returned value stays in-process return spokeOk(await readSources(request.sources)); }, }; const runExtractor: RunExtractor = async ({ request, input }) => { const candidates = await myExtractionService.propose(input); return spokeOk({ candidates, method: "llm-v1" }); }; async function runExtract() { const result = await orchestrateExtract(extractionPort, extractRequest, runExtractor); if (result.ok) { // result.value.candidates — each entry carries status "provisional" } } ``` The orchestrator validates `run_id` and `sources`, loads the referenced material through the port, then invokes your extractor exactly once with `{ request, input }`. The loaded value stays in-process and opaque; the wire carries the request's references and the response's candidates. The run returns candidates plus `run` metadata — provisional proposals whose durable admission happens later through `promote`. A missing port at a dynamic boundary rejects as `CAPABILITY_PORT_MISSING` with `details.capability = "ke-extraction"`. Rust spells the same entrypoint `orchestrate_extract`, with `ExtractRunInput` / `ExtractionResult` structs and the callback as a generic `F: FnOnce(ExtractRunInput) -> Fut`. ## Handle rejects Every orchestrator returns `SpokeResult`: ```ts import { SpokeRejectCode } from "@42ch/spoke-operations"; if (!result.ok) { switch (result.code) { case SpokeRejectCode.REVISION_CONFLICT: case SpokeRejectCode.STORED_REVISION_STALE: // reload and retry with the fresh revision break; case SpokeRejectCode.CAPABILITY_PORT_MISSING: // the adapter does not implement the optional port this op needs break; default: // validation / state rejects — surface to the caller } } ``` The wire responses follow the same one-failure dialect as the request/response envelopes: a response is either the success payload or `{ "error": ErrorEnvelope }` — never both. The library's rejects map to `ErrorEnvelope` shapes your transport carries. ## The purity boundary The orchestrators run the protocol gates and drive your ports — they do not touch storage, LLM calls, ranking, retrieval, or transport directly. All of that is supplied by your product through the injected adapter. Finding and promote lifecycles (status transitions, acceptance gates) are pure, pre-persist rules in the library; persistence happens through your ports. ## Next steps - [Ops wire reference](https://spoke.42ch.dev/reference/ops) — request/response envelope field tables and `Scope`. - [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — the port contract behind every orchestrator. - [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) — orchestrator usage in the committed fixture graph. # Connect from the TypeScript client The **language-native client** (`@42ch/spoke-connect`) implements the connect wire contract and session-core rules in TypeScript: `peer_id` derivation, Ed25519 hello signing, RFC 8785 JCS canonicalization, one-JSON-per-message WebSocket framing, and the pure session-core rules (allowlist, nonce, sequence, correlation, dispatch gate, capability tokens). It pairs with the platform WebSocket — no Rust runtime involved. ```bash pnpm add @42ch/spoke-connect@X.Y.Z ``` Entry points: - **`.`** — the isomorphic core: identity, crypto, JCS, and session core. Works in browsers and Node. - **`./node`** — the Node `connectClient` (depends on `ws`), which dials a WebSocket and completes the handshake. - **`./noise`** — the opt-in Noise XX mesh transport subpath for direct libp2p-noise interoperability (see [Noise transport subpath](https://spoke.42ch.dev/how-to/connect-ts-client#noise-transport-subpath)). Its dependencies load only when the subpath is imported, so the default `.` and `./node` bundles stay thin. - **`./remote`** — the opt-in RemoteAdapter module: `connectRemoteAdapter` (dial a remote peer over a consumer `Transport`), `connectMultiPeerRouter`, and the message-oriented `Transport` interface with the in-repo loopback pair (see [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) and [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing)). ## Identity ```ts import { derivePeerIdFromEd25519Pubkey, ed25519PubkeyFromPeerId, getPublicKeyEd25519 } from "@42ch/spoke-connect"; const publicKey = getPublicKeyEd25519(seed); // 32-byte Ed25519 public key const peerId = derivePeerIdFromEd25519Pubkey(publicKey); // wire peer_id (base58btc) const roundTrip = ed25519PubkeyFromPeerId(peerId); // reverse derivation ``` The derivation formula is the protocol identity binding: protobuf `PublicKey` → identity multihash `0x00` → base58btc. Byte parity with the Rust reference and all native bindings is locked by shared golden vectors. ## Crypto and JCS ```ts import { base64UrlEncode, signEd25519, verifyEd25519, webcryptoEd25519Available } from "@42ch/spoke-connect"; const bytes = new TextEncoder().encode("..."); const signature = await signEd25519(seed, bytes); const ok = await verifyEd25519(publicKey, bytes, signature); ``` Ed25519 uses WebCrypto where available with an `@noble/ed25519` fallback on the same code path (`webcryptoEd25519Available()` reports which path is active). Signatures are encoded base64url without padding ([RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648)). `canonicalHelloBytes(peerId, nonce, host)` produces the RFC 8785 JCS bytes ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) of the signed hello object `{protocol_version, peer_id, nonce, host}` — absent optional members are omitted from the canonical object. ## Signed hello and replay protection ```ts import { generateNonce, signHelloEd25519, verifyHelloEd25519, NonceStore } from "@42ch/spoke-connect"; const nonce = generateNonce(); // 16 CSPRNG bytes, base64url const hello = await signHelloEd25519(seed, nonce, manifest); const store = new NonceStore(); store.checkAndRecord(remotePeerId, hello.nonce); // false when the (peer_id, nonce) pair was already accepted await verifyHelloEd25519(remotePubkey, remotePeerId, hello); ``` The nonce floor is 16 characters; `signHelloEd25519` enforces it (`invalid_nonce`). The `NonceStore` records only accepted hellos, so a hello rejected by an earlier gate stays retry-safe. ## Allowlist and dispatch ```ts import { isAllowlisted, dispatchAllowed, requiredCapability, CAPABILITY_SPOKE_BASELINE } from "@42ch/spoke-connect"; isAllowlisted(["12D3KooW..."], peerId); // fail-closed: empty allowlist rejects all dispatchAllowed("check", ["spoke-baseline"]); // core-op capability ⊆ negotiated capabilities requiredCapability("check"); // "spoke-baseline" for core ops; null for product ops ``` The dispatch gate maps core ops to required capabilities (`upsert`, `promote`, `relate`, `check`, `assemble`, `project`, `compute`) and fails closed on unknown ops. ## Capability tokens ```ts import { issueCapabilityToken, verifyCapabilityToken, TOKEN_VERSION, CLOCK_SKEW_SECONDS } from "@42ch/spoke-connect"; const proof = await issueCapabilityToken(issuerSeed, { iss: issuerPeerId, // derived from issuerSeed's public key sub: subjectPeerId, // who may present the token aud: verifierPeerId, // the verifying node's peer_id capabilities: ["spoke-baseline"], exp: Math.floor(Date.now() / 1000) + 60, iat: Math.floor(Date.now() / 1000), }); const granted = await verifyCapabilityToken( proof, [issuerPeerId], // trusted issuers (fail-closed) thisPeerId, // this node's peer_id (aud check) sessionPeerId, // the authenticated session peer Math.floor(Date.now() / 1000), ); // granted = the validated capability list for the dispatch gate ``` Capability tokens are offline-validated, capability-scoped grants: a trusted issuer signs a short claim set (`iss` / `sub` / `aud` / `capabilities` / `exp`, optional `iat` / `jti`) over JCS with Ed25519, and the proof rides the auth challenge/response exchange or per-invoke `auth`. Verification enforces issuer trust, subject/audience binding, expiry, and clock skew (`CLOCK_SKEW_SECONDS`). ## Session state ```ts import { Session, negotiatedCapabilities, OutboundSequence, InboundSequence, checkResponseCorrelation } from "@42ch/spoke-connect"; const session = new Session({ session_id: "sess_1", initiator_peer_id: localPeerId, responder_peer_id: remotePeerId, negotiated_capabilities: negotiatedCapabilities(localCaps, remoteCaps), }); session.allocateOutboundSequence(); // 0, 1, 2, … — no wrap past 2^53−1 ``` Per-session, per-direction `sequence` counters start at 0; exhaustion closes the session and opens a new one. Responses echo `session_id` / `sequence` / `request_id` — `checkResponseCorrelation` enforces the match. `negotiatedCapabilities` computes the agreed subset of both hosts' capability lists. ## End-to-end with `connectClient` The Node client performs the full flow — dial, signed hello exchange, session snapshot validation, correlated invokes: ```ts import { derivePeerIdFromEd25519Pubkey } from "@42ch/spoke-connect"; import { connectClient } from "@42ch/spoke-connect/node"; const client = await connectClient({ url: "ws://127.0.0.1:8080", identity: { seed }, manifest: { schema_version: 1, host_id: "host_primary", roles: ["data-store"], capabilities: ["spoke-baseline"], namespaces: ["toy_world"], extensions: {}, }, remotePubkey, allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)], }); const response = await client.invoke("check", { scope: { scope_id: "book-harbor" } }); client.close(); ``` The client rejects before dialing when the remote peer id is missing from the allowlist, and every handshake and invoke await is bounded by `timeoutMs` (default 5000). ## Browsers vs Node The core imports (`@42ch/spoke-connect`) are browser-swappable — the Node client and its `ws` dependency stay behind the `./node` subpath. Browser consumers import the core only and pair it with the native WebSocket. ## Noise transport subpath For direct libp2p-mesh secure transport, the language-native client offers an opt-in Noise subpath: ```ts import { NoiseXX, NoiseTransport, createNoiseStaticKeypair } from "@42ch/spoke-connect/noise"; ``` `@42ch/spoke-connect/noise` is a pure-TS Noise XX stack — `Noise_XX_25519_ChaChaPoly_SHA256` (X25519 + ChaCha20-Poly1305 + HKDF-SHA256) — wire-compatible with the rust-libp2p Noise reference. The static key is a real X25519 key, and handshake flights 2–3 carry a `NoiseHandshakePayload` that binds the static key to the SPOKE peer's long-term Ed25519 identity (signature over `"noise-libp2p-static-key:" || static_public`), matching what rust-libp2p peers expect. The subpath loads its own dependencies (`@noble/ciphers`, `@noble/curves`); importing the core from `.` or `./node` keeps the default bundle thin. The SPOKE connect hello and session-core rules run above the Noise transport. ## RemoteAdapter and routing The `./remote` subpath turns a remote connect peer into a drop-in async `BaselinePorts` surface: `connectRemoteAdapter` dials through a consumer-implemented `Transport` (the package ships the interface and a test-only loopback pair), and `connectMultiPeerRouter` composes N registered adapters behind the same port surface with capability-based selection. See [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) and [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing). ## Peer-side interoperability The TypeScript client speaks the same session-core rules as the Rust reference (`spoke-connect` on crates.io) and every native binding — see [Connect wire reference](https://spoke.42ch.dev/reference/connect) for the shared contract. For a Rust-side peer, use `cargo add spoke-connect` and follow the two-node example in the [crate README](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/README.md). ## Next steps - [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) — the concepts behind each helper, step by step. - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — dial a remote peer over a consumer `Transport` and call its `BaselinePorts` surface. - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — one router over N registered adapters, with capability-based selection. - [Connect from native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) — the same session core from C#, Kotlin, Swift, Go, or Python. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables and identity binding. # 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](https://spoke.42ch.dev/explanation/connect#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. | Method | Contract | |--------|----------| | `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/spoke-connect@X.Y.Z ``` `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 }); ``` | Option | Meaning | |--------|---------| | `transport` | Your `Transport` implementation; the adapter sends and receives envelopes through it | | `localIdentity.seed` | 32-byte Ed25519 seed for the local connect identity | | `localManifest` | Your `HostCapabilityManifest`, advertised in the signed hello | | `remotePubkey` | The remote peer's 32-byte Ed25519 public key; the remote `peer_id` is derived from it and must be on the allowlist (fail-closed) | | `allowlist` | Peer ids this adapter accepts; the remote `peer_id` must be listed | | `invokeTimeoutMs` | Optional 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, 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`: ```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` }).await?; ``` Session info comes back as `Option`s (populated once the session establishes); `close()` is synchronous: ```rust adapter.state(); // RemoteAdapterState::Established adapter.session_id(); // Option adapter.remote_peer_id(); // Option adapter.remote_manifest(); // Option 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](https://spoke.42ch.dev/reference/connect#port-method-ops-remoteadapter) 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](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#symbol-map-across-the-bindings)). 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](https://spoke.42ch.dev/reference/connect#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](https://spoke.42ch.dev/reference/connect#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: | Failure | Surface | |---------|---------| | Transport I/O | `INTERNAL_ERROR` reject with `details.kind = "transport"` | | Session closed / connection loss | `INTERNAL_ERROR` reject with `details.kind = "session_closed"`; the adapter transitions to `Closed` and every pending invoke fails | | Invoke timeout | `INTERNAL_ERROR` reject with `details.kind = "timeout"` (that waiter only) | | Correlation mismatch | `INTERNAL_ERROR` reject with `details.kind = "correlation_mismatch"` | | Sequence exhaustion | `INTERNAL_ERROR` reject with `details.kind = "sequence_exhausted"`; the session closes — open a new one | | Envelope-auth rejection | `INTERNAL_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 codes | `INVALID_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](https://spoke.42ch.dev/explanation/connect#envelope-authentication) explains the property; the [wire reference](https://spoke.42ch.dev/reference/connect#envelope-authentication-protocol-version-2) 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`](https://github.com/42ch-dev/spoke/blob/main/packages/spoke-connect-ts/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 "/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](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) drives the identical handshake over FFI with a foreign-callback `Transport`. ## Next steps - [Integrate a RemoteAdapter against a live host](https://spoke.42ch.dev/tutorials/integrate-remote-adapter) — the step-by-step learning path through the same contract against the demo mock inference host. - [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) — advertise tools in the manifest, register handlers on the dial, and let the host discover and reverse-invoke them. - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — a multi-peer router composes N registered adapters behind the same `BaselinePorts` surface. - [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) — the same adapter lifecycle as a synchronous FFI surface from C#, Go, Kotlin, Python, or Swift. - [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) — the language-native client surface, including the `./remote` entry point. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables, envelope authentication, and the port-method ops catalogue. - [Connect architecture](https://spoke.42ch.dev/explanation/connect) — session lifecycle, envelope authentication, and capability routing. # Expose and invoke remote tools A connect session carries capability traffic in two directions: the host serves its `BaselinePorts` surface for the dialer to consume (the `port.*` direction), and the dialer can provide **tools** the host discovers from the authenticated manifest and reverse-invokes mid-orchestration. This guide covers the tool direction end to end — advertise, register, discover, invoke, feed — with snippets from the runnable demo (`examples/connect-demo/`, TypeScript) and the reference provider (`fixtures/toy-world/`, TypeScript + Rust) that they byte-match. The story in one line: a client's manifest advertises tools in `tools[]`; the host lists them from the authenticated manifest, reverse-invokes one during an orchestration step, and feeds the result into a `BaselinePorts` call. The demo's tools are `tools.toy_world.roll_dice` (deterministic dice) and `tools.toy_world.lore_lookup` (read-only lore lookup) — the same frozen ids across the demo and the reference provider. ## 1. Advertise tools in the manifest Three manifest fields describe the tools a dialer advertises for the host to discover and reverse-invoke over an established session: | Field | Role | |-------|------| | `capabilities[]` | Lists the tool capability strings (e.g. `tools.toy_world.roll_dice`) so the pair negotiates them like any other capability | | `namespaces[]` | Declares the namespaces the manifest owns; every declared tool's namespace must be listed | | `tools[]` | Carries the full `ToolDescriptor` for each tool — capability id, wire op, description, argument/result subschemas, idempotency | The demo client's manifest shows the shape: ```ts export const DEMO_CLIENT_MANIFEST: HostCapabilityManifest = { schema_version: 1, host_id: "demo-third-party-app", roles: ["input-source"], capabilities: [ "spoke-baseline", TOY_WORLD_ROLL_DICE_ID, TOY_WORLD_LORE_LOOKUP_ID, "l2-computable", "l5-fork", "ke-extraction", "ke-ownership", ], namespaces: [DEMO_SCOPE_ID, "toy_world"], tools: [ROLL_DICE_DESCRIPTOR, LORE_LOOKUP_DESCRIPTOR], extensions: {}, }; ``` A tool id follows the grammar `tools..` (`^tools\.[a-z][a-z0-9_-]*\.[a-z0-9][a-z0-9_-]*$`): `tools.toy_world.roll_dice` names namespace `toy_world` and tool id `roll_dice`. The wire `op` of a tool equals its `capability_id` — the capability string is the op string. `validateManifestTools` (from `@42ch/spoke-operations`) checks the manifest before it is used for discovery: each descriptor is well-formed, its `capability_id` appears in `capabilities[]`, its namespace is owned by `namespaces[]`, and tool ids are unique. The host runs the same check on the dialer's manifest at discovery time. See the [manifest `tools[]` field table](https://spoke.42ch.dev/reference/connect#manifest-tools-field-table) for the full descriptor fields. ## 2. Register handlers on a RemoteAdapter A `RemoteAdapter` serves reverse invokes through registered handlers. Registration happens on the established adapter, right after the dial: ```ts adapter.registerToolHandler(TOY_WORLD_ROLL_DICE_ID, rollDice); adapter.registerToolHandler( TOY_WORLD_LORE_LOOKUP_ID, loreLookup(loreStore), ); ``` A handler receives the tool arguments object and resolves with a `SpokeResult`: ```ts type ToolHandler = ( args: Record, ) => Promise>; ``` The demo's `rollDice` handler is deterministic — the seed is derived from the arguments, so the same arguments always produce the same rolls: ```ts export function rollDice(args: Record): Promise { const count = args["count"]; const sides = args["sides"] ?? 6; if (!isPositiveInteger(count)) { return Promise.resolve( reject("INVALID_INPUT", "roll_dice count must be a positive integer", { field: "count", }), ); } if (!isPositiveInteger(sides) || sides < 2) { return Promise.resolve( reject("INVALID_INPUT", "roll_dice sides must be an integer >= 2", { field: "sides", }), ); } const random = mulberry32(fnv1a(`${count}:${sides}`)); const rolls: number[] = []; for (let index = 0; index < count; index += 1) { rolls.push(1 + Math.floor(random() * sides)); } const total = rolls.reduce((sum, roll) => sum + roll, 0); return Promise.resolve({ ok: true, value: { rolls, total } }); } ``` The tool's `ToolDescriptor` describes that ABI — the argument subschema, the result subschema, and advisory metadata: ```ts export const ROLL_DICE_DESCRIPTOR: ToolDescriptor = { schema_version: 1, capability_id: TOY_WORLD_ROLL_DICE_ID, op: TOY_WORLD_ROLL_DICE_ID, description: "Roll `count` dice with `sides` faces each. Deterministic: the same arguments always produce the same rolls (seeded from the arguments).", input: { type: "object", properties: { count: { type: "integer", minimum: 1 }, sides: { type: "integer", minimum: 2 }, }, required: ["count"], }, output: { type: "object", properties: { rolls: { type: "array", items: { type: "integer" }, }, total: { type: "integer" }, }, required: ["rolls", "total"], }, idempotent: true, }; ``` Registration never mutates the manifest — descriptor truth for discovery stays in `tools[]` (sent through the hello). Registration is runtime state the validator cannot see: `validateManifestTools` checks the manifest's internal consistency only (descriptor well-formedness, capability membership, namespace ownership, id uniqueness), never the handler registry. A handler for a tool the manifest does not declare is a provider bug that surfaces at invoke time — the tool is never discoverable or negotiable, so the reverse invoke is denied (`op_unsupported` → `CAPABILITY_PORT_MISSING`), never a silent success. Registering a handler for a non-`tools.` id is a grammar error that throws. Duplicate registration for the same id overwrites the previous handler (last-wins). The Rust provider mirrors the same surface with `register_tool_handler`, whose handler type is `Arc BoxFuture<'static, SpokeResult> + Send + Sync>`: ```rust use std::sync::Arc; use serde_json::Value; adapter.register_tool_handler( TOY_WORLD_ROLL_DICE_ID, Arc::new(|args: Value| Box::pin(async move { roll_dice(&args) })), ); ``` The reference provider's [`default_tool_handlers`](https://github.com/42ch-dev/spoke/blob/main/fixtures/toy-world/rust/src/toy_world_tools.rs) builds both handlers from this pattern — `roll_dice` and a `lore_lookup` bound to the adapter's store. ## 3. Discover tools from the authenticated manifest The host learns what tools the dialer can serve from the authenticated manifest — the `host` embedded in the verified hello, cached on the responder as `remoteManifest`: ```ts const manifest: HostCapabilityManifest = responder.remoteManifest; const validated = validateManifestTools(manifest); const discovered = listTools(manifest).map( (descriptor) => descriptor.capability_id, ); ``` `validateManifestTools` returns a `SpokeResult`; `listTools` returns the descriptors in declaration order. For the demo client this lists both tools: ```text tools.toy_world.roll_dice tools.toy_world.lore_lookup ``` Discovery is a property of the authenticated session, not of a separate advertisement step — the host reads the manifest it already verified at the handshake. ## 4. Reverse-invoke a tool mid-orchestration The host reverse-invokes a discovered tool with `invokeTool` — a normal signed connect invoke in the reverse direction, carrying the tool arguments: ```ts const result = await responder.invokeTool( TOY_WORLD_ROLL_DICE_ID, { ...ORCHESTRATION_ROLL_ARGS }, // { count: 2, sides: 6 } ); ``` The demo host runs this step inside its `putKnowledgeEntry` orchestration, right after the client's compass submission lands. On success the `result` is the handler's returned value — for 2d6 with the seeded algorithm it is always `{ rolls: [1, 2], total: 3 }`. The host then feeds that value into the engine as `demo-harbor/artifact/dice-roll`, a `BaselinePorts` step the client sees on its next `listKnowledgeEntries`. A reverse invoke answers through the same deny vocabulary as any other op: invoking a tool the dialer did not list (and so did not negotiate) answers the wire code `op_unsupported`, which the library maps to a `CAPABILITY_PORT_MISSING` reject with `details.wire_code = "op_unsupported"`. The demo host records the deny and feeds nothing — the orchestration surfaces the denial instead of succeeding silently. The client's manifest must list the tool, and both sides must negotiate it: `tools.*` ops are dispatched only when the capability string itself is in the session's `negotiated_capabilities`. See [Reverse-invoke semantics](https://spoke.42ch.dev/reference/connect#reverse-invoke-semantics) in the wire reference. The demo server wires the responder with its own manifest, the allowlist, and the orchestrator as `ports`: ```ts void connectResponder({ transport, identity: { seed: DEMO_SERVER_SEED }, manifest: DEMO_SERVER_MANIFEST, allowlist: [DEMO_CLIENT_PEER_ID], peerKeys: { [DEMO_CLIENT_PEER_ID]: DEMO_CLIENT_PUBKEY, }, ports: orchestrator, }).then((responder) => { orchestrator.setResponder(responder); }); ``` `connectResponder` takes the same per-invoke timeout knob as the adapter: `invokeTimeoutMs` (optional; default 5000). On elapse only that reverse invoke fails — the session stays usable. The server's manifest declares the same tool ids the client serves, so both directions negotiate on the same session. The same `ports` seam also serves the optional `l2-computable` / `l5-fork` families: the demo orchestrator implements the composed `FullPorts` contract, and the responder probes the provider for the family methods after the capability gate. See [Optional port families](https://spoke.42ch.dev/reference/connect#optional-port-families) for the declare / serve / drive / deny contract. ## 5. Run the demo The demo runs over a real WebSocket in two terminals (build set in [the demo README](https://github.com/42ch-dev/spoke/blob/main/examples/connect-demo/README.md)): ```bash node examples/connect-demo/server/dist/main.js --port 8787 ``` ```bash node examples/connect-demo/client/dist/main.js --url ws://127.0.0.1:8787 ``` The client prints each story step — dial, registered tools, the put during which the host reverse-invokes `roll_dice`, the resulting entries including `demo-harbor/artifact/dice-roll`, the findings round-trip, and the optional `l2-computable` / `l5-fork` round-trips (project → compute settle → fork timeline). The e2e gate boots the host on an ephemeral port and asserts the whole path, including the deterministic roll value, the capability-deny path, and the undeclared-capability deny for the optional families: ```bash pnpm -F @42ch/spoke-demo-client test ``` ## Next steps - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — the manifest `tools[]` field table, the `tools.*` dispatch rule, and reverse-invoke semantics. - [Connect architecture](https://spoke.42ch.dev/explanation/connect) — the bidirectional capability flow behind tools and ports. - [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) — the copyable provider in TypeScript and Rust. - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — the dial and the `port.*` direction this guide builds on. - [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) — the same tool contract over the FFI surface (C#, Go, Kotlin, Python, Swift). # Route across multiple peers A **multi-peer router** (`connectMultiPeerRouter` in TypeScript, `connect_multi_peer_router` in Rust) sits between your per-peer connect sessions and the `orchestrate*` calls. You register the peers your node has dialed, and every call — `orchestrateUpsert(router, req)` in TypeScript, `orchestrate_upsert(&router, req)` in Rust — is routed to exactly one registered peer: the request payload carries the operation only, and the router selects the peer. The `orchestrate*` entrypoints keep the same `BaselinePorts` surface as a single-peer adapter, so the router plugs into the same orchestration calls. ## 1. Dial and register peers The router starts with zero peers. You dial each peer's `RemoteAdapter` yourself (signed-hello handshake, allowlist, session establishment — the full dial contract is in [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter)), then register the established adapter. The router stores the adapter and caches the peer's `HostCapabilityManifest` — the `host` field from the authenticated hello — at registration time. ```ts import { connectMultiPeerRouter } from "@42ch/spoke-connect/remote"; // The router starts with zero peers; register the adapters you dialed. const router = connectMultiPeerRouter({ hostId: "storefront-router" }); const north = await connectRemoteAdapter({ /* dial options */ }); const south = await connectRemoteAdapter({ /* dial options */ }); const northId = router.registerPeer(north); // returns the remote peer_id router.registerPeer(south); // idempotent on peer_id router.listPeers(); // ["12D3KooW…", "12D3KooW…"] — registration order router.unregisterPeer(northId); // removes it from selection; the adapter stays open ``` ```rust use spoke_connect::remote::{ connect_multi_peer_router, connect_remote_adapter, MultiPeerRouterOptions, }; let router = connect_multi_peer_router(MultiPeerRouterOptions { host_id: Some("storefront-router".into()), }); let north = connect_remote_adapter(/* dial options */).await?; let south = connect_remote_adapter(/* dial options */).await?; let north_id = router.register_peer(north)?; // Ok(peer_id) router.register_peer(south)?; // idempotent on peer_id router.list_peers(); // registration order router.unregister_peer(&north_id); // selection drops it; the adapter stays open ``` | Operation | Behavior | |-----------|----------| | `registerPeer(adapter)` / `register_peer(adapter)` | Accepts an established adapter, returns its `peer_id`, caches its manifest. Re-registering the same `peer_id` replaces the stored adapter (idempotent). The adapter must have an established session — dial first. | | `unregisterPeer(peerId)` / `unregister_peer(&peer_id)` | Removes the peer from selection. Unregistering an unknown `peer_id` leaves the registry unchanged. The adapter's lifecycle stays with the consumer — the router leaves the adapter open. | | `listPeers()` / `list_peers()` | Registered `peer_id`s in registration order. | A registered peer whose session transitions out of `Established` (for example to `Closed`, `Disconnected`, or `Handshaking`) is excluded from selection on the next call. The registry keeps the peer until you unregister it; exclusion is reactive, based on the session state the adapter reports. ## 2. Selection inputs Selection reads each registered peer's cached `HostCapabilityManifest` and matches it against the operation. Four manifest fields drive the choice, split into hard filters and a soft preference: | Input | Source | Filter kind | Role in selection | |-------|--------|-------------|-------------------| | `capabilities` | the peer's `capabilities[]` | **hard gate** | the peer must declare the operation's required capability | | `namespaces` | the peer's `namespaces[]` | **hard gate** | the request's namespace (from the payload `Scope`) must match exactly | | `roles` | the peer's `roles[]` | **soft preference** | peers with the operation's preferred role are preferred; capable peers lacking that role remain eligible | | `authority.scope_key` | the peer's `authority.scope_key` | **hard gate when both sides declare** | exact match between the peer's scope key and the request's scope key | Each operation family maps to a required capability — see the [Capability vocabulary](https://spoke.42ch.dev/reference/connect#capability-vocabulary) in the wire reference for the full table, including the two `ke-*` rows: the optional `extract` op requires `ke-extraction`, and a Scope carrying a non-empty `viewpoint` requires `ke-ownership` in addition to its row capability. The request's namespace derives from the payload `Scope` when the operation carries one (for example `upsert-request.scope` or `check-request.scope`). Namespace matching is exact: a peer declaring `namespaces: ["*"]` declares the literal string `"*"`. When the request carries a scope key and the peer's manifest declares one, the two must match exactly; when only one side declares, that gate passes. After the hard filters, peers with the operation's preferred role (for example `checker` for `check`, `assembler` for `assemble`, `l2-computable` for `project` / `compute`) sort ahead of equally capable peers. When several peers survive, the router selects the lowest `peer_id` in lexicographic UTF-8 byte order — a pure function of the candidate set, so the same peers and the same request always select the same peer. ## 3. Failure outcomes Every routing outcome is deterministic for a given peer set, and the router returns each outcome to the consumer as the call's result — all further activity starts with the consumer's next invocation. ### Terminal reject: `no_capable_peer` The router rejects with the locked terminal reject when the hard filters exclude every registered peer: | Field | Value | |-------|-------| | `SpokeResult` reject code | `CAPABILITY_PORT_MISSING` | | `details.wire_code` | `no_capable_peer` | | `details.kind` | `no_capable_peer` | The reject is terminal for that request: the router returns it and re-selects only when the consumer invokes again. Register a peer that satisfies the filters, then re-invoke with a fresh `request_id`. ### The selected peer fails mid-operation When the selected peer's session fails during a delegated call, the router returns the underlying `SpokeResult` reject exactly as the peer's adapter produced it — `INTERNAL_ERROR` with `details.kind` preserved (for example `transport` or `session_closed`). The failure stays attributable to the peer that served the call. Retry is consumer-owned. The consumer re-invokes with a fresh `request_id`; the router re-selects against the current peer set, and a peer whose session left `Established` drops out of the candidate set on the next selection. The consumer owns retry because the consumer knows each operation's idempotency semantics: a call may have been applied on the peer before the transport failed, and the consumer decides whether re-running the operation is safe. The protocol layer provides session-level correlation — `request_id`, `session_id`, and sequence — and per-operation idempotency decisions live with the consumer. ## 4. Envelope authenticity is protocol-level Every connect envelope — hello, invoke, and response — is signed with the session peer's Ed25519 key over the RFC 8785 JCS-canonicalized object and verified inside the session core before dispatch, so authenticity lives in the envelope itself, above any transport. The router selects a peer after envelope authentication completes on each per-peer session; selection is a routing decision that operates within the session core's authenticated envelope flow. The full picture — why the envelopes are signed, what protocol version 2 covers, and how mixed-version peers behave — is in [Connect architecture](https://spoke.42ch.dev/explanation/connect#envelope-authentication). ## 5. Inspect what the router can reach The router exposes the `HostManifestPort` over the composed peer set, with two distinct aggregation views: ```ts // Composed view — the union of every connected peer's manifest: const composed = await router.getHostCapabilityManifest(); // composed.value.host_id → the router's own host_id // composed.value.capabilities → set-union, deduplicated // composed.value.roles → set-union, deduplicated // composed.value.namespaces → set-union, deduplicated // composed.value.extensions.router.peers → contributing peer_ids, UTF-8 byte order // Per-peer view — each peer's own cached hello manifest: const perPeer = await router.listPeerHostCapabilityManifests(); // perPeer.value[i].host_id → one peer's manifest per entry, sorted by peer_id ``` ```rust use spoke_operations::SpokeResult; let composed = match router.get_host_capability_manifest().await { SpokeResult::Ok(manifest) => manifest, SpokeResult::Reject(reject) => return Err(reject), // your error path }; // composed.host_id → the router's own host_id // composed.capabilities → set-union, deduplicated // composed.roles → set-union, deduplicated // composed.namespaces → set-union, deduplicated // composed.extensions["router"]["peers"] → contributing peer_ids, UTF-8 byte order let per_peer = match router.list_peer_host_capability_manifests().await { SpokeResult::Ok(manifests) => manifests, SpokeResult::Reject(reject) => return Err(reject), // your error path }; // per_peer[i].host_id → one peer's manifest per entry, sorted by peer_id ``` | View | Shape | Use | |------|-------|-----| | `getHostCapabilityManifest` | One synthesized manifest: the router's own `host_id`, set-union of `capabilities` / `roles` / `namespaces` across connected peers, and `extensions.router.peers` listing contributing `peer_id`s in lexicographic UTF-8 byte order. The composed view surfaces capability, role, and namespace unions; consumers needing a peer's `authority.scope_key` read the per-peer view. | Introspection: "what can this node reach?" | | `listPeerHostCapabilityManifests` | One manifest per connected peer (each peer's own cached hello manifest), ordered by `peer_id` in lexicographic UTF-8 byte order. A router with an empty registry returns `[]`. | Per-peer authority and manifest detail | Routing reads each peer's own cached manifest; the composed view is an introspection surface and stays separate from selection. ## Next steps - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — the dial contract for each registered peer's adapter. - [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — the per-peer port surface the router delegates to. - [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops) — the `orchestrate*` calls that route through the router. - [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) — dialing each peer's session. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope signing, identity binding, and session-core rules. # Connect from native bindings **Native bindings** embed the shared connect **session core** into host languages through FFI: the pure session rules — `peer_id` derivation, hello sign/verify, allowlist, nonce single-use, sequence allocation, correlation, dispatch gate — live in one core, while transport stays in each host language. Native bindings use registry-backed distribution for C# (GitHub Packages NuGet), Kotlin (GitHub Packages Maven), and Python (PyPI), and git-based distribution for Swift (Swift Package Manager), Go (Go modules), and C/C++ (committed headers and platform carriers). Registry packages carry the lockstep release version; git-based consumers resolve the matching repository tag `vX.Y.Z`. NuGet and Maven share the GitHub Packages registry family. | Language | Channel | Package | |----------|---------|---------| | C# | GitHub Packages NuGet | `42ch.Spoke.Connect` | | Kotlin | GitHub Packages Maven | `dev.42ch:spoke-connect` | | Swift | Swift Package Manager (git + tags) | Product `SpokeConnect` | | Go | Go modules (git + tags) | `github.com/42ch-dev/spoke/crates/spoke-connect/bindings/go` | | Python | PyPI | `spoke-connect` | | C / C++ | git (committed headers + platform carriers) | [`spoke_connect.h` + `spoke_connect.hpp` + `native//`](https://spoke.42ch.dev/how-to/connect-cpp-binding) | Every binding exposes the same synchronous core surface; golden-parity smokes assert byte-identical behavior from each host side. Every native library is built from the production feature pair `ffi,remote-adapter` — regenerated bindings reference `remote-adapter` symbols (`RemoteAdapterFFI`, `MultiPeerRouterFFI`, the callback `Transport`) at load time, so the release build always carries both features. ## C# — GitHub Packages NuGet ```xml ``` Authenticate to GitHub Packages with a token that has `read:packages` scope. Native `libspoke_connect` ships under NuGet `runtimes//native/` (`win-x64`, `linux-x64`, `osx-arm64`). ```csharp using uniffi.spoke_connect; var peerId = SpokeConnectMethods.DerivePeerIdFromEd25519Pubkey(pubkey); var version = SpokeConnectMethods.ProtocolVersion(); // 1 ``` Package detail: [`bindings/csharp/PACKAGE.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/csharp/PACKAGE.md). ## Kotlin — GitHub Packages Maven ```kotlin // settings.gradle.kts or build.gradle.kts repository block maven { url = uri("https://maven.pkg.github.com/42ch-dev/spoke") credentials { username = providers.gradleProperty("gpr.user").get() password = providers.gradleProperty("gpr.key").get() } } dependencies { implementation("dev.42ch:spoke-connect:X.Y.Z") // JNA is a transitive dependency of the published artifact } ``` Set `gpr.user` and `gpr.key` in `gradle.properties` or `~/.gradle/gradle.properties` (GitHub username and a token with `read:packages`). JNA loads the platform natives from the jar (`darwin-aarch64`, `linux-x86-64`, `win32-x86-64`). ```kotlin import uniffi.spoke_connect.derivePeerIdFromEd25519Pubkey import uniffi.spoke_connect.protocolVersion val peerId = derivePeerIdFromEd25519Pubkey(pubkey) val version = protocolVersion() // 1 ``` Binding README: [`bindings/kotlin/README.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/kotlin/README.md). ## Swift — Swift Package Manager ```swift // Package.swift dependencies: [ .package(url: "https://github.com/42ch-dev/spoke.git", from: "X.Y.Z"), ], targets: [ .target( name: "MyApp", dependencies: [ .product(name: "SpokeConnect", package: "spoke"), ] ), ] ``` At tag `vX.Y.Z`, SPM resolves the repo-root `Package.swift` for library product `SpokeConnect` with generated Swift and a `spoke_connectFFI` xcframework. ```swift import SpokeConnect let peerId = try derivePeerIdFromEd25519Pubkey(pubkey: goldenPubkey) let version = protocolVersion() // 1 ``` Binding README: [`bindings/swift/README.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/swift/README.md). ## Go — Go modules ```bash go get github.com/42ch-dev/spoke/crates/spoke-connect/bindings/go@vX.Y.Z ``` At tag `vX.Y.Z`, the repo-root `go.mod` (`module github.com/42ch-dev/spoke`) versions the module; the import path is the subdirectory package. cgo links the shared libraries under `native/_/` in the module tree; consumers need a C toolchain and `CGO_ENABLED=1` (never a Rust toolchain). ```go import spokeconnect "github.com/42ch-dev/spoke/crates/spoke-connect/bindings/go" peerID, err := spokeconnect.DerivePeerIdFromEd25519Pubkey(pubkey) version := spokeconnect.ProtocolVersion() // 1 ``` Binding README: [`bindings/go/README.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/go/README.md). ## Python — PyPI ```bash pip install spoke-connect==X.Y.Z ``` Platform wheels (`manylinux`, `macosx_11_0_arm64`, `win_amd64`) publish to the PyPI project **`spoke-connect`** via Trusted Publishing on the release workflow. ```python import spoke_connect peer_id = spoke_connect.derive_peer_id_from_ed25519_pubkey(pubkey) version = spoke_connect.protocol_version() # 1 ``` Binding README: [`bindings/python/README.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/python/README.md). ## C and C++ — git ```bash git clone --branch vX.Y.Z --depth 1 https://github.com/42ch-dev/spoke.git git lfs install # once per machine git lfs pull # fetch the carrier libraries; a fresh clone smudges them automatically ``` - `crates/spoke-connect/bindings/cpp/include/spoke_connect.h` — C99 ABI header - `crates/spoke-connect/bindings/cpp/include/spoke_connect.hpp` — C++17 convenience header - `crates/spoke-connect/bindings/cpp/native/osx-arm64/libspoke_connect_capi.dylib` — macOS arm64 carrier - `crates/spoke-connect/bindings/cpp/native/win-x64/spoke_connect_capi.dll` — Windows x64 carrier The committed C/C++ carriers target macOS arm64 (`osx-arm64`) and Windows x64 (`win-x64`). Take `spoke_connect.h`, `spoke_connect.hpp`, and the native files for your target from the same repository tag `vX.Y.Z`. The two carrier libraries are Git LFS objects (`.gitattributes`); the headers, the Windows import library and `provenance.json` are ordinary Git objects. Per RID, the provenance records hashes for both headers and the native artifacts. The C++17 header is header-only and includes the C header. `spoke::connect` wraps the same session core in move-only RAII handles, an explicit `Result` error channel, borrowed text views over returned buffers, and host callback bridges for the transport, ports and tool surfaces. The C99 header stays the ABI contract for C hosts — status values plus the raw record and callback-table layout. Full walkthrough — acquire, compile, open a session, make one call: [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding). ## The shared session core Every binding exposes the same synchronous core surface: `peer_id` derivation, hello sign/verify, allowlist, nonce store, sequence allocation, response correlation, dispatch gate, and protocol version. Keys cross the FFI boundary as raw bytes (validated to exactly 32 bytes), peer ids as strings, and manifests / hello envelopes as JSON strings — transport adapters stay in the host language against the wire contract. The TypeScript **language-native client** ([Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client)) implements the same session-core rules directly in TypeScript — it is the sibling path, not a binding row. The **Rust reference** (`spoke-connect` on crates.io) is the session-core reference and the binding source; see the [connect wire reference](https://spoke.42ch.dev/reference/connect) for the shared contract. The RemoteAdapter contract ships over the same FFI surface as synchronous objects (`RemoteAdapterFFI`, `MultiPeerRouterFFI`, the callback `Transport`) — see [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding). The same surface carries the tool contract: `invoke_tool` on the adapter, router, and responder; `register_tool_handler` on the adapter and responder, with the foreign `ToolHandler` callback for tool serving; and the accept-side `ConnectResponderFFI` / `connect_responder_ffi`. The same surface carries the optional port families: `RemoteAdapterFFI` exposes `project` / `compute` / `list_fork_timeline_events` (JSON in / JSON out), and the responder's `ports` argument accepts an optional foreign `PortsHandler` serving the baseline and optional `port.*` families — see [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) and [Optional port families](https://spoke.42ch.dev/reference/connect#optional-port-families). The same `PortsHandler` callback surface carries the optional extraction service face (`PortsHandler.extract`) and the ownership gate — see [Remote extraction and the ownership gate](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#_4-remote-extraction-and-the-ownership-gate). ## Next steps - [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) — the handshake flow every binding implements. - [Use RemoteAdapter from a native binding](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) — dial a `Transport`, call port methods, and route across peers over FFI. - [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) — advertise, discover, and reverse-invoke tools from a native host. - [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding) — compile against the hand-written C header and link the committed carrier. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables and identity binding. # Connect from C and C++ The C/C++ channel ships two hand-written headers and one committed carrier per platform. `spoke_connect.h` is the C contract — C99, with status values, value types, callback tables and every exported function, and ABI revision `1` reported through `spoke_connect_abi_version`. `spoke_connect.hpp` is a header-only C++17 convenience layer over that contract: namespace `spoke::connect`, move-only ownership for every owned buffer and handle, borrowed text views with explicit copies, one structured `Result` error channel, and host callback bridges. A C++17 host includes `spoke_connect.hpp` — which includes the C header — and links the native carrier. The committed C/C++ carriers target macOS arm64 (`osx-arm64`) and Windows x64 (`win-x64`). Take `spoke_connect.h`, `spoke_connect.hpp`, and the native files for your target from the same repository tag `vX.Y.Z`. The consumer default builds with exceptions and RTTI disabled: clang `-std=c++17 -fno-exceptions -fno-rtti`, MSVC `/std:c++17 /EHs-c- /GR- /MD /D_HAS_EXCEPTIONS=0`. Exception-enabled builds use the same error API — clang `-std=c++17 -fexceptions -fno-rtti`, MSVC `/std:c++17 /EHsc /GR- /MD` — and every translation unit that includes the convenience header in one linked image must use a consistent exception and standard-library configuration. The carrier wraps the same public Rust facade the generated **native bindings** expose — the session core (`peer_id` derivation, hello sign/verify, allowlist, nonce store, sequence counters, response correlation, dispatch gate), `RemoteAdapter`, `MultiPeerRouter` and `ConnectResponder` with the `PortsHandler` and `ToolHandler` callbacks, and the in-memory loopback helpers. The parity table [`bindings/cpp/parity.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/cpp/parity.md) maps every production facade member, callback and error variant to its C declaration and records the C++ counterpart that consumes it. The `PortsHandler` callback bridges the port catalogue and the optional `extract` service face; [Serve (responder)](https://spoke.42ch.dev/reference/connect#serve-responder) in the connect reference lists its methods. ## 1. Take the headers and the native | Path | Contents | |------|----------| | `crates/spoke-connect/bindings/cpp/include/spoke_connect.h` | The C contract: status values, value types, callback tables and every exported function | | `crates/spoke-connect/bindings/cpp/include/spoke_connect.hpp` | The C++17 convenience layer: `Buffer`, `Error`, `Result`, the handle classes, the three callback records and their factories | | `crates/spoke-connect/bindings/cpp/native/osx-arm64/libspoke_connect_capi.dylib` | macOS arm64 carrier, install name `@rpath/libspoke_connect_capi.dylib` | | `crates/spoke-connect/bindings/cpp/native/win-x64/spoke_connect_capi.dll` | Windows x64 carrier | | `crates/spoke-connect/bindings/cpp/native/win-x64/spoke_connect_capi.dll.lib` | The Rust-produced import library the Windows link step consumes | | `crates/spoke-connect/bindings/cpp/native/provenance.json` | Per RID: source revision, target, `rustc -Vv`, compiler version, build flags, C and C++ header and artifact hashes | | `crates/spoke-connect/bindings/cpp/README.md` | The binding README with the full calling contract | The two carrier libraries (`*.dylib`, `*.dll`) are stored in Git LFS — a plain clone leaves pointer files behind, so fetch the real bytes before compiling: ```bash git lfs install # once per machine git lfs pull # in an existing clone; a fresh clone smudges them automatically ``` The headers, the import library (`spoke_connect_capi.dll.lib`) and `provenance.json` are ordinary Git objects and arrive with any clone. The C header compiles as C99 (fixed-width integers from ``, lengths from ``) and uses `extern "C"` inclusion for C++. Windows functions and callback pointers use `__cdecl`; macOS uses the platform default C calling convention. Every public symbol is prefixed `spoke_connect_`. The convenience header needs only the C++17 standard library and defines no configuration macro of its own. ## 2. Compile and link macOS arm64 links the dylib and points the loader at its directory: ```sh clang++ -std=c++17 -fno-exceptions -fno-rtti \ -I crates/spoke-connect/bindings/cpp/include \ host.cpp crates/spoke-connect/bindings/cpp/native/osx-arm64/libspoke_connect_capi.dylib \ -Wl,-rpath, \ -o host ``` Windows x64 links the import library, pairs the release C++ CRT (`/MD`) with the carrier's Rust dynamic CRT, and loads the DLL from the executable's directory: ```bat cl.exe /nologo /std:c++17 /EHs-c- /GR- /MD /D_HAS_EXCEPTIONS=0 /W4 /WX ^ /I ^ host.cpp /Fe:host.exe copy /Y . host.exe ``` The validated Windows pairing is `/MD`: the release C++ CRT matches the carrier's Rust dynamic CRT. `/D_HAS_EXCEPTIONS=0` is the standard-library half of the no-exception configuration; an exception-enabled build replaces `/EHs-c- /D_HAS_EXCEPTIONS=0` with `/EHsc`, and the macOS flags with `-fexceptions -fno-rtti`. ## 3. Open a session The three blocks below compose one complete `host.cpp`: a host-owned queue transport, the demo identity with a served echo tool, and the dial with one real invoke. Append them in order — every include and helper they use is shown. A failure before a serving handle exists ends the process with the underlying status; every failure after `ConnectResponder::serve` has returned is reported, closed in order, and returned as the process's exit status. ### Block 1 — the host transport ```cpp // ── host.cpp — append part 1 of 3 ─────────────────────────────────────────── // // The host transport: the includes, the error-printing and construction // helpers, and the queue pair whose callbacks carry every envelope this session // sends and receives. #include "spoke_connect.hpp" #include #include #include #include #include #include #include #include #include #include #include #include namespace connect = spoke::connect; using connect::Buffer; using connect::Error; using connect::Result; /** * Prints one failure and returns the status the example exits with — the first * status reported, so a later failure never masks an earlier one. */ int report(int previous, const char* step, const Error& error) { std::cerr << step << ": status " << error.status << " — " << error.message; if (error.code.has_value()) std::cerr << " (code " << *error.code << ")"; std::cerr << "\n"; return previous == SPOKE_CONNECT_OK ? error.status : previous; } /** * Returns the value of a construction step that runs before a serving handle * exists; a failure prints the error and ends the process with its status. * Every step that runs after `ConnectResponder::serve` has returned propagates * its failure instead, so the session this example acquires always leaves * through the ordered close path. */ template T unwrap(Result&& result, const char* step) { if (result.has_value()) return std::move(result).value(); std::cerr << step << ": status " << result.error().status << " — " << result.error().message << "\n"; std::exit(result.error().status); } /** One direction of the host queue pair: an unbounded FIFO with a close flag. */ class EnvelopeQueue { public: /** * Enqueues one envelope. A push into a closed queue reports failure, so a * send that can no longer be delivered is never dropped silently. */ bool push(std::string envelope) { std::lock_guard guard(mutex_); if (closed_) return false; envelopes_.push_back(std::move(envelope)); signal_.notify_all(); return true; } /** Blocks the calling thread until an envelope arrives or the queue closes. */ bool pop(std::string* out) { std::unique_lock lock(mutex_); for (;;) { if (!envelopes_.empty()) { *out = std::move(envelopes_.front()); envelopes_.pop_front(); return true; } if (closed_) return false; signal_.wait(lock); } } /** Closes the queue and wakes every waiter, so a blocked `recv` returns. */ void close() { std::lock_guard guard(mutex_); closed_ = true; signal_.notify_all(); } private: std::mutex mutex_; std::condition_variable signal_; std::deque envelopes_; bool closed_ = false; }; /** One end's view of the pair: what it sends on and what it receives from. */ struct HostQueues { std::shared_ptr outbound; std::shared_ptr inbound; /** Closes both directions; `close` on either direction is idempotent. */ void close() const { outbound->close(); inbound->close(); } }; /** The transport-closed failure a closed queue reports. */ Error transport_closed(std::string_view message) { Error error; error.status = SPOKE_CONNECT_TRANSPORT_CLOSED; error.message = std::string(message); return error; } /** * Builds one end's transport record over its queue pair. Every callback * captures the shared state by value, so the queues outlive the record, and no * callback calls back into an operational C or C++ API — the exported loopback * helpers included. Callbacks run on the carrier's blocking pool and may run * concurrently, which the queue's own lock covers. */ std::unique_ptr make_transport(HostQueues host) { auto callbacks = std::make_unique(); callbacks->send = [host](std::string_view envelope) -> Result { if (!host.outbound->push(std::string(envelope))) { return Result::failure(transport_closed("the host queue is closed")); } return Result::success(); }; callbacks->recv = [host]() -> Result { std::string envelope; if (!host.inbound->pop(&envelope)) { return Result::failure(transport_closed("the host queue is closed")); } return Result::success(std::move(envelope)); }; callbacks->close = [host]() -> Result { host.close(); return Result::success(); }; return callbacks; } ``` ### Block 2 — identity and server ```cpp // ── host.cpp — append part 2 of 3 ─────────────────────────────────────────── // // The demo identity, the capability manifest both ends advertise, and the // serving end: both transports, `ConnectResponder::serve`, the echo tool, and // the ordered cleanup every exit path shares. /** The tool this example serves, and the arguments the dialer invokes it with. */ constexpr std::string_view kEchoToolId = "tools.example.echo"; constexpr std::string_view kEchoArguments = R"({"message":"hello"})"; /** * Local demo data: the 32-byte Ed25519 seed and its public key from the * repository's shared golden hello vector * (`crates/spoke-connect/tests/fixtures/golden-hello.json`). This example runs * both ends in one process and gives them the same identity, so the signed * hello is self-contained; a production host generates and stores its own * identity and takes the remote public key from the peer it dials. */ const std::array kDemoSeed = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20}; const std::array kDemoPubkey = { 0x79, 0xb5, 0x56, 0x2e, 0x8f, 0xe6, 0x54, 0xf9, 0x40, 0x78, 0xb1, 0x12, 0xe8, 0xa9, 0x8b, 0xa7, 0x90, 0x1f, 0x85, 0x3a, 0xe6, 0x95, 0xbe, 0xd7, 0xe0, 0xe3, 0x91, 0x0b, 0xad, 0x04, 0x96, 0x64}; /** * The capability manifest both ends advertise. `capabilities` carries the * connect capability, the baseline capability and the echo tool's own * capability string — a `tools..` op requires exactly that string * to be negotiated, and a capability enters the negotiated set only when both * hellos list it — while `namespaces` owns the `example` namespace the tool * descriptor declares. */ const char* const kManifest = R"json({ "capabilities": ["spoke-connect", "spoke-baseline", "tools.example.echo"], "extensions": {}, "host_id": "cpp-example-host", "namespaces": ["example"], "roles": ["data-store"], "schema_version": 1, "tools": [ { "capability_id": "tools.example.echo", "description": "Echo the arguments", "input": { "type": "object" }, "op": "tools.example.echo", "output": { "type": "object" }, "schema_version": 1 } ] })json"; /** The echo tool record: the handler answers with an owned copy of the arguments. */ std::unique_ptr make_echo_tool() { auto callbacks = std::make_unique(); callbacks->handle = [](std::string_view arguments) -> Result { return Result::success(std::string(arguments)); }; return callbacks; } /** Everything the process keeps alive across the session. */ struct Endpoints { connect::Transport server_transport; connect::Transport dialer_transport; connect::ConnectResponder responder; connect::ToolHandler echo; std::string peer_id; }; /** * Ends the session in the documented order — the dialing session when one * exists, then the serving session, then both host queues so a blocked `recv` * returns — and returns the status the example exits with. Every path that has * acquired a session leaves through this one cleanup, so no exit path ends the * process while a session is live. A null session is one that was never * established or is already closed; the handles themselves release through RAII * when their scope ends. */ int close_ordered(int previous, const connect::RemoteAdapter* dialer, const connect::ConnectResponder* responder, const HostQueues& server_queues, const HostQueues& dialer_queues) { int status = previous; if (dialer != nullptr) { Result closed = dialer->close(); if (!closed.has_value()) status = report(status, "RemoteAdapter::close", closed.error()); } if (responder != nullptr) { Result closed = responder->close(); if (!closed.has_value()) status = report(status, "ConnectResponder::close", closed.error()); } server_queues.close(); dialer_queues.close(); return status; } /** * Creates both ends over the cross-wired queue pair, starts serving and * registers the echo tool. `ports` is absent — the service is offered no ports * callbacks at all, which is different from a provider that declines a method. * * A failure that follows `ConnectResponder::serve` is reported, closed in order * and returned to `main`: the serving handle this function acquired is never * left to the process to reclaim. */ Result start_endpoints(const HostQueues& server_queues, const HostQueues& dialer_queues) { const SpokeConnectSlice seed = connect::bytes(kDemoSeed.data(), kDemoSeed.size()); const SpokeConnectSlice pubkey = connect::bytes(kDemoPubkey.data(), kDemoPubkey.size()); const std::string manifest = kManifest; // The peer id is derived from the public key; the allowlist and the // peer-key table below are keyed by it. Buffer derived = unwrap(connect::derive_peer_id_from_ed25519_pubkey(pubkey), "derive_peer_id_from_ed25519_pubkey"); std::string peer_id_text = derived.str(); // an owned copy: view() is borrowed const SpokeConnectSlice peer_id = connect::slice(peer_id_text); const SpokeConnectSlice allowlist[] = {peer_id}; const SpokeConnectPeerKey peer_keys[] = {SpokeConnectPeerKey{peer_id, pubkey}}; const std::optional invoke_timeout = uint64_t{5000}; std::unique_ptr server_callbacks = make_transport(server_queues); connect::Transport server_transport = unwrap(connect::Transport::create(server_callbacks), "Transport::create (server)"); connect::ConnectResponder responder = unwrap( connect::ConnectResponder::serve(server_transport, seed, manifest, allowlist, 1, peer_keys, 1, nullptr, invoke_timeout), "ConnectResponder::serve"); // A serving handle exists from here on: a later failure reports its step, // reaches the ordered close, and returns the failure to `main`. auto failed = [&](const char* step, Error error) -> Result { error.status = close_ordered(report(SPOKE_CONNECT_OK, step, error), nullptr, &responder, server_queues, dialer_queues); return Result::failure(std::move(error)); }; std::unique_ptr echo_callbacks = make_echo_tool(); Result echo = connect::ToolHandler::create(echo_callbacks); if (!echo.has_value()) return failed("ToolHandler::create", std::move(echo).error()); Result registered = responder.register_tool_handler(kEchoToolId, echo.value()); if (!registered.has_value()) { return failed("ConnectResponder::register_tool_handler", std::move(registered).error()); } std::unique_ptr dialer_callbacks = make_transport(dialer_queues); Result dialer = connect::Transport::create(dialer_callbacks); if (!dialer.has_value()) return failed("Transport::create (dialer)", std::move(dialer).error()); return Result::success( Endpoints{std::move(server_transport), std::move(dialer).value(), std::move(responder), std::move(echo).value(), std::move(peer_id_text)}); } ``` ### Block 3 — dial, one call, close ```cpp // ── host.cpp — append part 3 of 3 ─────────────────────────────────────────── // // The dialing end: connect (the carrier signs and verifies the hello), one real // tool invoke, and the ordered close with RAII release. int main() { // The host side: one queue per direction, cross-wired so each end's // outbound queue is the other end's inbound queue. const auto to_server = std::make_shared(); const auto to_dialer = std::make_shared(); const HostQueues server_queues{to_server, to_dialer}; const HostQueues dialer_queues{to_dialer, to_server}; // A failed startup step has already run the ordered close for everything it // acquired, so only its status is left to return. Result started = start_endpoints(server_queues, dialer_queues); if (!started.has_value()) return started.error().status; Endpoints endpoints = std::move(started).value(); const SpokeConnectSlice seed = connect::bytes(kDemoSeed.data(), kDemoSeed.size()); const SpokeConnectSlice pubkey = connect::bytes(kDemoPubkey.data(), kDemoPubkey.size()); const SpokeConnectSlice allowlist[] = {connect::slice(endpoints.peer_id)}; const std::string manifest = kManifest; // `connect` performs the signed hello and the handshake itself — no second // hello is sent by hand. Both ends share this example's identity, so the // dialed peer is this host. A failed dial is reported and leaves through the // same ordered close as every other path; no dialing session exists yet, so // the serving session and the host queues are what closes. Result dialed = connect::RemoteAdapter::connect(endpoints.dialer_transport, seed, manifest, pubkey, allowlist, 1, uint64_t{5000}); if (!dialed.has_value()) { return close_ordered(report(SPOKE_CONNECT_OK, "RemoteAdapter::connect", dialed.error()), nullptr, &endpoints.responder, server_queues, dialer_queues); } connect::RemoteAdapter adapter = std::move(dialed).value(); int status = SPOKE_CONNECT_OK; // A present session id is what an established session carries; the state // names the transition the dialer reached. Result state = adapter.state(); if (!state.has_value()) { return close_ordered(report(status, "RemoteAdapter::state", state.error()), &adapter, &endpoints.responder, server_queues, dialer_queues); } Result> session = adapter.session_id(); const bool established = state.value().view() == "Established" && session.has_value() && session.value().has_value() && !session.value()->empty(); if (established) { std::cout << "state: " << state.value().view() << ", session id: " << session.value()->view() << "\n"; } else { std::cerr << "the session did not establish (state \"" << state.value().view() << "\")\n"; status = SPOKE_CONNECT_HANDSHAKE_FAILED; } // One real call: the serving end's registered echo tool answers with the // arguments JSON, and the returned `Buffer` owns the result. if (status == SPOKE_CONNECT_OK) { Result echo = adapter.invoke_tool(kEchoToolId, kEchoArguments); if (echo.has_value()) { std::cout << kEchoToolId << " -> " << echo.value().view() << "\n"; } else { status = report(status, "RemoteAdapter::invoke_tool", echo.error()); } } // The close every path above shares — the success run and the failures // alike: end both sessions explicitly, then close the host queues so a // blocked `recv` returns, then let this scope release the RAII handles and // every buffer it still holds. return close_ordered(status, &adapter, &endpoints.responder, server_queues, dialer_queues); } ``` A green run of this program prints: ```text state: Established, session id: connect-responder-session-12D3KooWJ1TsijH7H5F74hfAD5XishQz3sxrmAtVY37GtNd9CqYf tools.example.echo -> {"message":"hello"} ``` ### What the values guarantee | Concern | Contract | |---------|----------| | Borrowed text | `Buffer::view()` borrows the owned record — it stays valid while that `Buffer` is alive, it keeps the binary length (embedded NUL bytes are content), and it is deleted on a temporary buffer, whose bytes die with it. An empty buffer reads as an empty view. | | Owned text | `Buffer::str()` materializes an owned `std::string`; use it when the value must outlive the buffer, as the peer id above does. | | Results | Every fallible call returns `[[nodiscard]] Result` or `Result` carrying the original `SPOKE_CONNECT_*` status and the full structured `Error` (`message`, optional `code` / `kind` / `wire_code`, `expected`, `actual`). A non-zero C status always returns by value. | | Bare values | Handles and buffers are move-only: a destructor releases, a moved-from object is empty, and `get()` / `release()` / `adopt()` exist for raw-C interop. | | Session end | Destructors free only — they never close. `RemoteAdapter::close()` and `ConnectResponder::close()` end a session explicitly, and the host's own transport close releases the queues; close first, then let the scope release the handles. | | Exceptions | The layer contains an escaping host-callback exception in exception-enabled builds: a transport callback becomes `SPOKE_CONNECT_TRANSPORT_IO`, a ports or tool callback becomes `SPOKE_CONNECT_FFI_REJECTED` with `code="INTERNAL_ERROR"`. With exceptions disabled there is no `try`, `catch` or `throw` in the header at all, and a callback reports failure through its `Result`. | | Real networks | The three transport callbacks are the only transport seam: replace the queue pair with the host's network transport — one envelope per send, a blocking receive per `recv`, and a `close` that unblocks a pending receive — and everything above stays unchanged. The exported loopback helpers are a separate in-process demonstration and are never wrapped as a callback transport. | ## 4. Call the raw C ABI The convenience layer is optional: `spoke_connect.h` is the contract, and a C host uses it directly. | Concern | Contract | |---------|----------| | Status | Every call returns `int32_t`: `SPOKE_CONNECT_OK` (0) on success, or a `SPOKE_CONNECT_*` value with the detail in the caller's error record. Failed calls preserve caller ownership of the out values. | | Out parameters | Zero-initialize each out value and the `SpokeConnectError` record before the call; the library fills the fields the result carries. | | Owned results | Release `SpokeConnectBuffer` with `spoke_connect_buffer_free`, `SpokeConnectOptionalBuffer` with `spoke_connect_optional_buffer_free`, and `SpokeConnectError` with `spoke_connect_error_free`. | | Borrowed inputs | `SpokeConnectSlice` borrows bytes for the duration of the call; the length is authoritative, text is validated UTF-8, and keys are 32 raw bytes. | | Handles | Each handle owns one carrier object and releases with `_free` (releasing a null handle is idempotent); `close` ends a session and leaves the release to `_free`. Constructors and the router borrow the handles they are given. | | Callback tables | `spoke_connect_transport_new`, `spoke_connect_ports_handler_new` and `spoke_connect_tool_handler_new` copy the table and take ownership of `user_data` on success, running its `destroy` once after the last reference and in-flight callback. | | Returned buffers | A callback hands ownership of a populated `SpokeConnectForeignBuffer` to the carrier, which copies the bytes and calls the buffer's `release` exactly once. | | Threading | Calls block the calling host thread; callbacks run on the carrier's blocking pool and may run concurrently, so host contexts are thread-safe. | | Lifetime | Load the carrier for the process lifetime, and close sessions and release handles before host shutdown. | Status values group the failure vocabulary: | Range | Meaning | |-------|---------| | 0 | `SPOKE_CONNECT_OK` | | 1 / 2 | `SPOKE_CONNECT_INVALID_ARGUMENT` (C-boundary validation) / `SPOKE_CONNECT_PANIC` (a contained panic) | | 100–107 | Core errors: invalid hello signature, nonce replay, handshake failed, invalid nonce, crypto, JCS, token invalid, protocol version mismatch | | 200–202 | Core invoke errors: sequence exhausted, inbound sequence mismatch, correlation mismatch | | 300 / 301 | Dial error / rejected (application and dispatch refusals, with `code`, `kind` and `wire_code` preserved where the mapping defines them) | | 400 / 401 | Transport closed / transport I/O | ## 5. Run the golden-parity smoke `tooling/connect/cpp-smoke.mjs` compiles the committed `Smoke/main.cpp` (raw C ABI) and `Smoke/convenience.cpp` (convenience layer) translation units, links the staged native for the requested RID, and runs them against the shared golden vector `crates/spoke-connect/tests/fixtures/golden-hello.json`: ```sh node tooling/connect/cpp-smoke.mjs --rid osx-arm64 node tooling/connect/cpp-smoke.mjs --rid win-x64 ``` Within one RID the runner builds two configurations — exceptions disabled (the consumer default) and exceptions enabled — and requires each configuration's banner lines to equal its expected list exactly: same count, same order, same text. A green run prints, in order: ```text golden peer-id: PASS golden hello signature: PASS protocol version 1: PASS loopback ports: PASS rejection/ownership: PASS C++ convenience values/core: PASS C++ convenience callbacks/session: PASS C++ convenience router: PASS C++ smoke: PASS ``` The exceptions-enabled configuration additionally prints `C++ callback exception containment: PASS` before the final line, because only that build injects a throwing host callback and observes the containment row. The runner ends with `C++ smoke runner: PASS (rid )`, and a missing, extra, repeated or reordered banner fails the run. The Windows lane [`.github/workflows/cpp-connect.yml`](https://github.com/42ch-dev/spoke/blob/main/.github/workflows/cpp-connect.yml) compiles, links and runs the same smoke with MSVC on every pull request and main push that touches the carrier, its header, the smoke or the build/check/smoke scripts. ## 6. Consume the carrier from Unreal Engine The Unreal Engine module reference ([`bindings/cpp/ue/SpokeConnect.Build.cs`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/cpp/ue/SpokeConnect.Build.cs) and its [README](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/bindings/cpp/ue/README.md)) declares an external module that puts the header directory on the include path and links and stages the committed carrier for the target platform. UnrealBuildTool discovers modules in a project's or plugin's `Source/` tree, so vendor the `cpp/` tree as a unit — for example `MyProject/Source/SpokeConnect/`, containing `ue/SpokeConnect.Build.cs` beside `include/` and `native/` — and add the module to the consuming module's dependencies: ```csharp PublicDependencyModuleNames.AddRange(new string[] { "SpokeConnect" }); ``` | Target | Link | Runtime staging | |--------|------|-----------------| | `Win64` (x86_64) | `native/win-x64/spoke_connect_capi.dll.lib` through `PublicAdditionalLibraries` | `RuntimeDependencies.Add("$(TargetOutputDir)/spoke_connect_capi.dll", …)` copies the DLL beside the executable | | `Mac` (arm64) | `native/osx-arm64/libspoke_connect_capi.dylib` through `PublicAdditionalLibraries` | `RuntimeDependencies.Add(…, StagedFileType.NonUFS)` keeps the dylib loose beside the executable | The module accepts `Win64` with `x86_64` and `Mac` with `arm64`, reads the target architecture through the `UnrealArch` API (UE 5.2 onward), and contributes include paths plus link/staging wiring — the consuming module calls the C ABI functions it needs, owns the transport implementation, and decides which engine thread consumes callback results. A carrier call blocks the calling OS thread, so call the ABI from worker threads; callbacks arrive on the carrier's blocking pool and may run concurrently. Keep the carrier loaded for the process lifetime, close sessions, then release handles before host shutdown. Validation status: the executed evidence is the standalone C++17 smoke for `osx-arm64` and `win-x64` (step 5); the module README lists the engine-side checks — editor build and load, packaged loose-library load, one connected session, orderly shutdown, target settings — as the maintainer checklist a machine with an engine environment runs. ## 7. Keep the headers and the exports in step `tooling/connect/cpp-symbol-check.mjs` is the executable drift gate: it parses the header's declaration block, compares it against the carrier's exported `spoke_connect_*` symbols in both directions, checks the header's `typedef struct` block against the carrier's reported record layouts, compiles a C99 probe holding a typed function pointer to every declaration, and compiles a C++17 inclusion probe that includes `spoke_connect.h` and `spoke_connect.hpp` — twice, covering repeated inclusion — and instantiates the convenience layer's values, its full handle set and its three callback factories with exceptions and RTTI disabled. Because the record-layout report comes from the carrier's own test target, the gate needs `cargo` on `PATH`; the compile-and-assert half of the layout pass is that target's scoped run, `cargo test -p spoke-connect-capi --lib abi_layout`, which the gate invokes for you. ```sh node tooling/connect/cpp-symbol-check.mjs \ --header crates/spoke-connect/bindings/cpp/include/spoke_connect.h \ --library crates/spoke-connect/bindings/cpp/native/osx-arm64/libspoke_connect_capi.dylib ``` ```text C ABI symbols: PASS (83 declarations, 83 exports, 0 missing, 0 extra) Record layout: 11 records, 66 assertions (sizeof/_Alignof/offsetof) match the carrier mirrors C probe (clang -std=c99 -Wall -Wextra -Werror): PASS C++17 inclusion (no exceptions, no RTTI; .h + .hpp): PASS ``` With `--self-test`, the same entry point runs the negative controls in temporary copies of the whole header directory: a header missing a real declaration, one declaring a symbol the carrier does not export, a mutated callback signature, and a `.hpp` mutation carrying exception syntax — the last proves the inclusion probe really compiles the convenience header instead of degrading to a C-only check. ## Next steps - [Bridge a native binding to RemoteAdapter](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) — the adapter, router, responder, ports and tool surface the C ABI carries. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables and identity binding. - [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) — the handshake flow every binding implements. # Use RemoteAdapter from a native binding **Native bindings** expose the remote Adapter contract as a synchronous FFI surface: your host code implements a message-oriented `Transport`; the adapter dials and exchanges envelopes through it, and you then call the same `BaselinePorts` methods the Rust reference and the TypeScript language-native client call. The shared library owns a process-wide tokio runtime; every exported call is a synchronous block-on-async call over that runtime, and the session core stays encapsulated on the Rust side — hello sign/verify, allowlist, nonce single-use, sequence, correlation, and envelope authentication all run inside the binding, while your host code supplies the `Transport` and invokes the port methods. The exported objects are `RemoteAdapterFFI` (single peer), `MultiPeerRouterFFI` (multi-peer routing), and `ConnectResponderFFI` (the accept side), plus the `ToolHandler` callback for tool serving. This page walks the full flow with the Python binding; the same surface exists in C#, Go, Kotlin, and Swift with language-idiomatic names (see the [symbol map](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#symbol-map-across-the-bindings)). The generic RemoteAdapter contract — the message-oriented `Transport` seam, dial options, and error mapping shared with the TypeScript and Rust libraries — is in [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter); this page covers the FFI surface. ## 1. Implement the callback `Transport` Your host code implements the message-oriented `Transport` interface: | Method | Behavior | |--------|----------| | `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 | The crate exports `loopback_transport_pair()`, an in-memory client/server pair, so you can run the whole flow with no network. The reference loopback smokes ship one `LoopbackTransport` end into a test-only smoke host and drive the adapter from the other end through a callback transport: ```python import spoke_connect class LoopbackCallbackTransport: """Foreign-callback transport delegating to the client end of a loopback pair.""" def __init__(self, inner: spoke_connect.LoopbackTransport) -> None: self._inner = inner def send(self, envelope: bytes) -> None: self._inner.send(envelope) def recv(self) -> bytes: return self._inner.recv() def close(self) -> None: self._inner.close() ``` For a real deployment, implement the same three methods over your carrier — a socket, a WebSocket, or a message channel. The Transport delivers exactly one envelope per `send` / `recv` call; byte-stream carriers apply length-prefix (or equivalent) delimiting before handing envelopes to the adapter. ## 2. Dial and construct `RemoteAdapterFFI` `connect_remote_adapter_ffi` performs the dial and signed-hello handshake through your transport and returns an established adapter handle: ```python pair = spoke_connect.loopback_transport_pair() adapter = spoke_connect.connect_remote_adapter_ffi( LoopbackCallbackTransport(pair.client()), seed_client, # local Ed25519 identity seed, exactly 32 bytes client_manifest_json, # local HostCapabilityManifest as JSON pubkey_host, # remote peer's Ed25519 public key, exactly 32 bytes [peer_id_host], # allowlist: the remote peer_id None, # invoke timeout in ms; None uses the default ) ``` | Argument | Meaning | |----------|---------| | `transport` | Your `Transport` implementation; the adapter sends and receives envelopes through it | | `local_seed` | 32-byte Ed25519 seed for the local identity (raw bytes) | | `local_manifest_json` | Your `HostCapabilityManifest` as a JSON string | | `remote_pubkey` | The remote peer's 32-byte Ed25519 public key (raw bytes) | | `allowlist` | Peer ids this adapter accepts; the remote `peer_id` must be listed | | `invoke_timeout_ms` | Optional per-invoke timeout; on elapse only that call fails and the session stays usable | The constructor fails before an adapter exists when configuration, the handshake, a version mismatch, or the dial timeout fails — the error is `FfiError.Dial` with a `kind` of `config`, `handshake`, `protocol_version_mismatch`, or `timeout`. Your transport's own failures map to `TransportError` (`Closed` when the connection closes, `Io` for I/O errors). ## 3. Invoke a port method Port methods take JSON payloads and return JSON strings. A `put` / `get` round trip over the established session: ```python entry_json = json.dumps({ "schema_version": 1, "entry_id": entry_id, "entry_type": "character", "canonical_name": "Ada", "status": "provisional", "body": {"summary": "Upserted over the connect session"}, "extensions": {}, }) put_json = adapter.put_knowledge_entry(entry_json, None) get_json = adapter.get_knowledge_entry(entry_id) ``` Each `BaselinePorts` family maps to a method with the same JSON-in / JSON-out shape: `get_host_capability_manifest`, `get_relation` / `put_relation`, `list_knowledge_entries` / `list_timeline_events`, `put_findings`, `list_rules`, and `list_peer_host_capability_manifests`. Invoke-path failures surface as `FfiError.Rejected` with the `SpokeResult` code preserved, plus `kind` and `wire_code` where the mapping defines them (for example `INTERNAL_ERROR` with `kind = "transport"`, or `CAPABILITY_PORT_MISSING` with `wire_code = "no_capable_peer"`). The adapter also exposes the optional-port faces with the same JSON-in / JSON-out shape: `project(project_request_json)` and `compute(compute_request_json)` (the `l2-computable` family) and `list_fork_timeline_events(scope_json)` (the `l5-fork` family). The family must be in the session's negotiated capabilities — both manifests declare it; a peer that did not negotiate the family denies with `FfiError.Rejected` (`code: "CAPABILITY_PORT_MISSING"`, preserved `wire_code: "op_unsupported"`). The full catalogue is in [Optional port families](https://spoke.42ch.dev/reference/connect#optional-port-families). Concurrent calls on one adapter are allowed; responses demultiplex on `request_id` and may arrive out of order. ## 4. Remote extraction and the ownership gate Two more capability-gated surfaces ride the same established session as the port methods. Declare each flag in **both** peers' `HostCapabilityManifest` — the session's negotiated set is the both-hello intersection, so a flag both sides declared is negotiated and served. ### Remote extraction (`ke-extraction`) `extract` is a core op served by a host-local extract service on the same ports object as the port methods. The payload is the `ExtractRequest` itself as JSON, reference-only, and the serving host runs source loading and extraction through its own machinery, so the loader value stays host-local: ```python extract_json = adapter.extract( json.dumps( { "run_id": "run-harbor-1", "sources": [ { "schema_version": 1, "source_id": "harbor/source/log", "extensions": {}, } ], } ) ) ``` The returned JSON is the `ExtractResponse` success branch: `candidates` (each `provisional`) plus the correlated `run`, whose `run_id` echoes the request. Each binding exposes the method under its own casing — `Extract` in C# and Go, `extract` in Kotlin, Swift, and Python (see the [symbol map](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#symbol-map-across-the-bindings)). 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. Serving the op over FFI is the `PortsHandler.extract(extract_request_json)` callback, which runs the whole host-local extraction and answers the wire `ExtractResponse` JSON. ### The ownership gate (`ke-ownership`) A Scope-bearing port op requires `ke-ownership` in addition to its row capability when the scope carries a non-empty `viewpoint` string — the gate reads `payload.scope.viewpoint` as supplied and treats any non-empty string as ownership-bearing. Over FFI the witness is the existing `RemoteAdapterFFI.list_knowledge_entries(scope_json)` call, unchanged: ```python listed_json = adapter.list_knowledge_entries( json.dumps({"scope_id": "toy-scope-001", "viewpoint": "kb_tw_mira"}) ) ``` Each binding spells that method in its own casing — `ListKnowledgeEntries` in C# and Go, `listKnowledgeEntries` in Kotlin and Swift, `list_knowledge_entries` in Python (see the [symbol map](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#symbol-map-across-the-bindings)). Declare `ke-ownership` in both manifests for a viewpoint-bearing query; a Scope whose `viewpoint` is empty or unset keeps serving under the row capability alone. ### Extraction and ownership refusals Both surfaces settle through the existing `FfiError` rows, which carry the whole refusal vocabulary: | Refusal origin | `FfiError` row | |----------------|----------------| | The required capability sits outside the negotiated set, or the host serves no extract service | `Rejected` with `code: "CAPABILITY_PORT_MISSING"` and the preserved `wire_code: "op_unsupported"` | | The foreign extract callback declines the request itself | `Rejected` with the callback's own `code` preserved — when it declines extraction that `code` is `CAPABILITY_PORT_MISSING` and `wire_code` stays unset, so a refused extraction stays distinguishable from a missing capability | | Malformed `extract_request_json`, or callback output that departs from the contract payload | `Rejected` with `code: "INVALID_INPUT"` (zero wire traffic) / `code: "INTERNAL_ERROR"` (containment, the session survives) | Either hello omitting a flag leaves it out of the negotiated intersection, and the responder refuses before the foreign callback runs — a missing capability surfaces as that refusal. ## 5. Read session info The adapter exposes read-only session metadata: ```python adapter.state() # "Established", "Handshaking", "Closed", ... adapter.session_id() # the session id, once established adapter.remote_peer_id() # the authenticated remote peer_id adapter.remote_manifest() # the remote peer's HostCapabilityManifest as JSON ``` `session_id` / `remote_peer_id` / `remote_manifest` are populated once the session establishes; the session info comes from the authenticated hello and the session core, captured at establish time. ## 6. Route across multiple peers with `MultiPeerRouterFFI` `new_multi_peer_router_ffi()` returns an empty router. Dial each peer's `RemoteAdapterFFI` (step 2), register the established handles, and every port call routes to exactly one capable peer: ```python router = spoke_connect.new_multi_peer_router_ffi() north_id = router.register_peer(north_adapter) # returns the remote peer_id router.register_peer(south_adapter) # idempotent on peer_id router.list_peers() # registered peer_ids, registration order router.unregister_peer(north_id) # drops it from selection; the adapter stays open result_json = router.get_knowledge_entry(entry_id) # routed to a capable peer ``` Selection reads each registered peer's cached `HostCapabilityManifest` — hard gates on the operation's required capability and exact namespace, a soft role preference, and a deterministic lowest-`peer_id` tie-break. When no registered peer passes the hard gates, the call rejects with `CAPABILITY_PORT_MISSING` and `wire_code = "no_capable_peer"`; register a satisfying peer and re-invoke with a fresh `request_id`. The router also exposes the composed and per-peer `HostManifestPort` views (`get_host_capability_manifest` and `list_peer_host_capability_manifests`). The full selection contract is in [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing). ## 7. Serve and invoke tools over FFI The FFI surface carries the tool contract in both directions: the dialer invokes tools the responder serves through a foreign `ToolHandler` callback, and the responder reverse-invokes tools the dialer serves through handlers registered with `register_tool_handler`. `invoke_tool` exists on `RemoteAdapterFFI`, `MultiPeerRouterFFI`, and `ConnectResponderFFI`; `register_tool_handler` exists on `RemoteAdapterFFI` and `ConnectResponderFFI`. Discovery is a property of the authenticated session: `remote_manifest()` returns the peer's `HostCapabilityManifest` JSON, and its `tools[]` descriptors are the tools the peer can serve: ```python manifest = json.loads(adapter.remote_manifest()) tool_ids = [tool["capability_id"] for tool in manifest["tools"]] ``` A tool id follows the grammar `tools..`, and the manifest must list the tool in both `tools[]` and `capabilities[]` — a `tools.*` op dispatches only when the capability string is in the session's negotiated capabilities. See [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) for the library-side contract this surface mirrors. ### The `ToolHandler` callback A handler receives the tool arguments as a JSON string and returns the result as a JSON string. The reference shape ships in the Python loopback smoke (`bindings/python/Smoke/test_loopback_remote_adapter.py`): ```python class _SumToolHandler: """Foreign-callback tool handler: sums `a` + `b` (Rust `add_handler` parity) and records the invocation count.""" def __init__(self) -> None: self._calls = 0 def handle(self, arguments_json: str) -> str: self._calls += 1 arguments = json.loads(arguments_json) return json.dumps({"sum": arguments.get("a", 0) + arguments.get("b", 0)}) def calls(self) -> int: return self._calls ``` A `FfiError.Rejected` raised from `handle` passes through to the invoker verbatim as an application reject — `code` / `message` preserved, `kind` / `wire_code` preserved on the error fields. Any other outcome (a `Dial` error, a non-contract exception, or a panic) is contained to an `INTERNAL_ERROR` reject with no `kind` / `wire_code`, and the session survives. ### Register handlers on the dialer `RemoteAdapterFFI.register_tool_handler(capability_id, handler)` serves responder→dialer reverse invokes. Registration is last-wins for a repeated id and never mutates the manifest; a non-`tools.` id rejects `INVALID_INPUT` with the offending id in `message` and zero wire traffic. ### Accept side: `connect_responder_ffi` The FFI surface never builds a listener — the host product owns listen/accept in its own network stack. The accept flow is symmetric with dial: the host accepts a connection, wraps it as its callback `Transport`, and passes the connected transport to `connect_responder_ffi`: ```python responder = spoke_connect.connect_responder_ffi( LoopbackCallbackTransport(pair.server()), seed_host, _tool_manifest_json("test-responder"), # HostCapabilityManifest with tools[] [peer_id_client], # fail-closed dialer allowlist {peer_id_client: pubkey_client}, # peer_id -> 32-byte Ed25519 pubkey ports, # optional foreign PortsHandler; None keeps the deny branch None, # invoke timeout in ms; None uses the default ) ``` The constructor returns immediately — the responder is in `Handshaking` while the dialer hello settles. Poll `state()` (bounded) to `Established` before invoking; a handshake failure (allowlist deny, hello-verify deny) surfaces as `state() → "Closed"` with `session_id() → None`, never a thrown constructor error: ```python import time deadline = time.monotonic() + 5.0 while responder.state() != "Established": if time.monotonic() >= deadline: raise RuntimeError(f"handshake timeout (last: {responder.state()!r})") time.sleep(0.01) ``` The constructor's `Result` slot carries config-validation failures only — manifest JSON, seed length, or peer-key length → `Dial { kind: "config" }`. `ports` is an **optional** foreign-callback ports face: pass a `PortsHandler` to serve `port.*` invokes (baseline + optional families) and the core `extract` op through the callback bridge, or `None` to keep the documented absent-ports deny branch — every `port.*` invoke and every `extract` invoke then answers `CAPABILITY_PORT_MISSING` with `wire_code: "op_unsupported"` (the same fail-closed row as the library responder without `ports`). See [The `PortsHandler` callback](https://spoke.42ch.dev/how-to/remote-adapter-native-binding#the-portshandler-callback) below. ### The `PortsHandler` callback A `PortsHandler` is the responder's foreign-callback ports face: each method takes the request payload as a JSON string and returns the success payload as a JSON string — the same catalogue the library responder serves through its `ports` option, plus the `extract` service face. The interface has thirteen methods (nine baseline + three optional + `extract`): | 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` | `get_host_capability_manifest` is not in the catalogue — it is the session cache, never served through the ports handler. A method may raise `FfiError.Rejected` to deny an op it does not serve; the reject passes through to the invoker as an application reject, which is how an extract callback declines extraction. The optional families are served only when the pair negotiated the family (both manifests declare it) — the capability gate runs before the callback, and a host that declared a family but does not provide its method answers the same deny branch as absent `ports`. The reference shape ships in the Python loopback smoke (`bindings/python/Smoke/test_ports_loopback.py`); the optional methods show the JSON contract: ```python def project(self, project_request_json: str) -> str: request = json.loads(project_request_json) return json.dumps( { "session_id": request["session_id"], "entry_id": request["entry_id"], "computable": {"tide_level": 2.4, "cargo_tons": 38}, } ) def compute(self, compute_request_json: str) -> str: request = json.loads(compute_request_json) return json.dumps( { "session_id": request["session_id"], "entry_id": request["entry_id"], "computable": request["computable"], "state": request["computable"], } ) def list_fork_timeline_events(self, scope_json: str) -> str: scope = json.loads(scope_json) if scope["fork_id"] != "fork_tw_ffi_events": return "[]" return json.dumps( [ { "schema_version": 1, "timeline_event_id": "evt_tw_ffi_storm", "canonical_name": "FFI Fork Storm", "fork_id": "fork_tw_ffi_events", "extensions": {}, } ] ) ``` The same re-entrancy rule as tool handlers applies: never call back into the FFI surface from inside a ports callback — hand the work off to your own async machinery and return. ### The loopback pair end to end The bidirectional smoke drives both ends as FFI objects — the responder serves a foreign `ToolHandler`, the dialer serves reverse invokes, unregistered tools deny, and a handler-thrown reject passes through: ```python # Dialer FFI invoke_tool -> responder FFI foreign ToolHandler. responder_sum = _SumToolHandler() responder.register_tool_handler("tools.math.add", responder_sum) sum_json = dialer.invoke_tool("tools.math.add", '{"a": 1, "b": 2}') # sum_json == '{"sum": 3}' # Responder FFI invoke_tool -> dialer-side handler registered via # RemoteAdapterFfi.register_tool_handler. dialer_sum = _SumToolHandler() dialer.register_tool_handler("tools.math.add", dialer_sum) reverse_sum_json = responder.invoke_tool("tools.math.add", '{"a": 21, "b": 21}') # reverse_sum_json == '{"sum": 42}' # Negotiated but unregistered tool -> fail-closed op_unsupported. try: dialer.invoke_tool("tools.echo.boom", "{}") except spoke_connect.FfiError.Rejected as denied: assert denied.code == "CAPABILITY_PORT_MISSING" assert denied.wire_code == "op_unsupported" ``` A handler-thrown application reject passes through verbatim in the same shape: ```python class _ThrowingToolHandler: """Foreign-callback tool handler that always raises the given application reject.""" def __init__(self, reject: spoke_connect.FfiError.Rejected) -> None: self._reject = reject def handle(self, arguments_json: str) -> str: raise self._reject dialer.register_tool_handler( "tools.echo.boom", _ThrowingToolHandler( spoke_connect.FfiError.Rejected( "REVISION_CONFLICT", "foreign handler rejected", None, "op_unsupported" ) ), ) try: responder.invoke_tool("tools.echo.boom", "{}") except spoke_connect.FfiError.Rejected as passed: assert passed.code == "REVISION_CONFLICT" assert passed.message == "foreign handler rejected" assert passed.wire_code == "op_unsupported" ``` ### Tool-path errors | Failure | `FfiError` row | |---------|----------------| | Non-`tools.` `capability_id` on `invoke_tool` / `register_tool_handler` (either wrapper) | `Rejected` with `code: "INVALID_INPUT"`, zero wire traffic, offending id in `message` | | Malformed `arguments_json` on `invoke_tool` | `Rejected` with `code: "INVALID_INPUT"`, zero wire traffic, parse error in `message` | | Dispatch deny — tool not negotiated, or the peer has no registered handler (peer answers `op_unsupported` / `capability_missing`) | `Rejected` with `code: "CAPABILITY_PORT_MISSING"` and the preserved peer `wire_code` | | Router has no capable peer | `Rejected` with `code: "CAPABILITY_PORT_MISSING"`, `kind` = `wire_code` = `"no_capable_peer"`, capability id in `message` | | Handler-thrown `Rejected` | Passes through verbatim — `code` / `message` preserved, `kind` / `wire_code` preserved on the error fields | | Any other handler outcome (non-contract `Dial`, exception, panic) | `Rejected` with `code: "INTERNAL_ERROR"`, no `kind` / `wire_code` — containment, the session survives | | Per-waiter invoke timeout | `Rejected` with `code: "INTERNAL_ERROR"`, `kind: "timeout"` — that waiter only, the session stays usable | | Session closed / transport I/O during the invoke | `Rejected` with `code: "INTERNAL_ERROR"`, `kind: "session_closed"` / `"transport"` | ### Threading and capacity notes Handlers run on the FFI blocking pool: every `handle` call (like every callback `Transport` method) bridges through the shared runtime's `spawn_blocking` pool, so a blocking foreign call never monopolizes an async worker. A handler must **not** synchronously call back into the FFI faces — re-entrancy wedges the session. Hand the work off to your own async machinery and return from `handle` instead. Each established FFI session pins one blocking-pool thread per transport end (the receive loops block on the foreign `recv`). At the tokio default of 512 blocking threads, a host process sustains roughly 256 full-duplex sessions before new callback work queues — size long-lived connection counts against that ceiling. ## 8. Errors Every FFI call settles through the `FfiError` surface — dial failures before an adapter exists, invoke-path `SpokeResult` rejects, and the callback transport's own failures: ### `FfiError.Dial` — constructor / dial failures `{ kind, message }`, returned when the dial fails before an adapter exists: | `kind` | When | |--------|------| | `config` | Local seed or remote public key not exactly 32 bytes, invalid local manifest JSON, or the remote `peer_id` not on the allowlist (fail-closed) | | `handshake` | Hello signature failure, nonce single-use violation, dial-binding assert, or `ConnectSession` snapshot verification failure (version mismatches surface as `protocol_version_mismatch`) | | `protocol_version_mismatch` | A hello advertising a mixed or unknown `protocol_version` — the version gate is hello verification step 1, before signature verification, so the kind fires on any mismatched-version hello, valid signature or not | | `timeout` | The dial deadline elapsed (bounded-wait handshake) | ### `FfiError.Rejected` — invoke-path `SpokeResult` rejects `{ code, message, kind, wire_code }`, returned by port methods on the established surface: | Row | Shape | |-----|-------| | Application rejects | `code` / `message` preserved verbatim (for example `KNOWLEDGE_ENTRY_NOT_FOUND`), with `kind` / `wire_code` present only where the mapping defines them | | Payload JSON parse failure | `INVALID_INPUT`, no `kind` / `wire_code` | | `INTERNAL_ERROR` rows | `kind` ∈ {`transport`, `session_closed`, `timeout`, `panic`, `correlation_mismatch`, `sequence_exhausted`, `envelope_auth_missing`, `envelope_auth_invalid`, `envelope_auth_session_unbound`} | | Dispatch deny | `CAPABILITY_PORT_MISSING` with `wire_code` = `op_unsupported` / `capability_missing` | | Unknown wire codes | `INVALID_INPUT` with `wire_code` | | Router terminal reject | `CAPABILITY_PORT_MISSING` with `wire_code` = `kind` = `no_capable_peer` | `kind = "panic"` is the panic-containment row: a panic caught around an exported block-on-async call fails only that waiter and never unwinds across the FFI boundary — the message carries the raw panic payload. A foreign-callback panic inside the `spawn_blocking` pool surfaces as a transport `Io` failure instead. ### `TransportError` — callback transport failures | Variant | When | |---------|------| | `Closed` | The connection closed; a pending `recv` fails fast | | `Io` | Transport-level I/O failure, including a foreign-callback panic failing the blocking join | ## Symbol map across the bindings | Surface | C# | Go | Kotlin | Python | Swift | |---------|----|----|--------|--------|-------| | Dial + construct | `SpokeConnectMethods.ConnectRemoteAdapterFfi(...)` | `ConnectRemoteAdapterFfi(...)` | `connectRemoteAdapterFfi(...)` | `connect_remote_adapter_ffi(...)` | `connectRemoteAdapterFfi(...)` | | Adapter object | `RemoteAdapterFfi` | `RemoteAdapterFfi` | `RemoteAdapterFfi` | `RemoteAdapterFfi` | `RemoteAdapterFfi` | | Router constructor | `SpokeConnectMethods.NewMultiPeerRouterFfi()` | `spoke_connect.NewMultiPeerRouterFfi()` | `newMultiPeerRouterFfi()` | `new_multi_peer_router_ffi()` | `newMultiPeerRouterFfi()` | | Router object | `MultiPeerRouterFfi` | `spoke_connect.MultiPeerRouterFfi` | `MultiPeerRouterFfi` | `MultiPeerRouterFfi` | `MultiPeerRouterFfi` | | Port methods | PascalCase (`GetKnowledgeEntry`) | PascalCase (`GetKnowledgeEntry`) | camelCase (`getKnowledgeEntry`) | snake_case (`get_knowledge_entry`) | camelCase (`getKnowledgeEntry`) | | Optional port methods | `Project` / `Compute` / `ListForkTimelineEvents` | `Project` / `Compute` / `ListForkTimelineEvents` | `project` / `compute` / `listForkTimelineEvents` | `project` / `compute` / `list_fork_timeline_events` | `project` / `compute` / `listForkTimelineEvents` | | Remote extract (`ke-extraction`) | `Extract(extractRequestJson)` | `Extract(extractRequestJson)` | `extract(extractRequestJson)` | `extract(extract_request_json)` | `extract(extractRequestJson:)` | | Ownership Scope query (`ke-ownership`) | `ListKnowledgeEntries(scopeJson)` | `ListKnowledgeEntries(scopeJson)` | `listKnowledgeEntries(scopeJson)` | `list_knowledge_entries(scope_json)` | `listKnowledgeEntries(scopeJson:)` | | Tool invoke | `InvokeTool(...)` | `InvokeTool(...)` | `invokeTool(...)` | `invoke_tool(...)` | `invokeTool(capabilityId:argumentsJson:)` | | Tool serving registration | `RegisterToolHandler(...)` | `RegisterToolHandler(...)` | `registerToolHandler(...)` | `register_tool_handler(...)` | `registerToolHandler(capabilityId:handler:)` | | Tool handler callback | `ToolHandler` | `ToolHandler` | `ToolHandler` | `ToolHandler` | `ToolHandler` | | Ports callback | `PortsHandler` | `PortsHandler` | `PortsHandler` | `PortsHandler` | `PortsHandler` | | Responder constructor | `SpokeConnectMethods.ConnectResponderFfi(...)` | `spoke_connect.NewConnectResponderFfi(...)` | `connectResponderFfi(...)` | `connect_responder_ffi(...)` | `connectResponderFfi(...)` | | Responder object | `ConnectResponderFfi` | `spoke_connect.ConnectResponderFfi` | `ConnectResponderFfi` | `ConnectResponderFfi` | `ConnectResponderFfi` | Go integrators import `spokeconnect` for the re-exported transport and adapter types; router, responder, ports and tool symbols live in the `generated/spoke_connect` subpackage and are referenced with the `spoke_connect.` prefix. C# adapter and router constructors are static members of `SpokeConnectMethods`. Every binding ships the same surface: golden-parity smokes assert byte-identical session-core behavior, and loopback smokes drive the callback `Transport` + `RemoteAdapterFFI` flow from each host side. ## Next steps - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — the selection contract behind `MultiPeerRouterFFI`. - [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools) — the discovery and reverse-invoke contract the FFI tool faces mirror. - [Connect from native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) — install and authenticate each binding channel. - [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) — the language-native client that shares the same session-core rules. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope signing, identity binding, and session-core rules. # Walk the ToyWorld reference adapter [`fixtures/toy-world/`](https://github.com/42ch-dev/spoke/tree/main/fixtures/toy-world) is the protocol-owned worked example: a committed JSON graph ("Mira at Harbor"), reference `ToyWorldAdapter` implementations in TypeScript and Rust, and a conformance harness that validates every fixture against the committed schemas. It is the starting skeleton for your own adapter — the pattern to copy, not the product. ## The story behind the graph Cartographer Mira arrives at Harbor Town at dawn; a consistency rule flags an open finding; an `AssemblePacket` scopes context for the scene. The graph exercises the wire surface deliberately: - A **dual-concern pair** links ontology `entry_type: "event"` KnowledgeEntry `kb_tw_harbor_dawn_event` to TimelineEvent `evt_tw_harbor_dawn`. - Three moment-scale beats extend the Harbor mainline in order — market square inquiry, customs gate inspection (profile `entry_type: "beat"`), berth confirmation — linked by `precedes` Relations and `extensions.spoke.timeline_entry_id` on each TimelineEvent. - Harbor Town carries optional `l2-computable` `body.state` / `body.computable` (tide and cargo); the dawn moment records `computable_logs`. - Two `HostCapabilityManifest` hosts (`host_tw_primary` / `host_tw_peer`) declare pairwise-disjoint `namespaces[]` for in-process collaboration. - Six `conn_tw_*` fixtures demonstrate a two-host connect exchange under the optional `spoke-connect` capability: signed hellos in both directions, a session snapshot, an invoke request wrapping the real `op_tw_check_request` payload, plus success and error invoke responses. ## What the adapter implements `ToyWorldAdapter` implements **`FullAdapter`** — baseline ports plus `l2-computable` and `l5-fork`: | Port family | ToyWorldAdapter behavior | |-------------|--------------------------| | Baseline OCC families (five port families) | Runnable in-memory OCC; optional seed from committed fixture JSON | | `HostManifestPort` | Self manifest from `host_tw_primary.json`; peer list from in-memory `host_tw_peer.json` (exclude self, dedupe by `host_id`, ascending sort) | | `ComputablePort` | Wire-valid `ProjectResponse` / `ComputeResponse` synthesized from committed op fixtures (echo request `session_id` / `entry_id`) | | `ForkTimelineQueryPort` | Seeded timeline events filtered by `scope.fork_id` | ## TypeScript side Open `src/adapter/`: - `memory-store.ts` — `MemoryStore`: the in-memory OCC store (get/put with revision checks) that the adapter delegates to. This is the pattern for your own storage bridge. - `toy-world-adapter.ts` — `ToyWorldAdapter` + `asBaselineOnly()`: one type implements all ports; the baseline-only projection omits the optional methods so dynamic orchestrators surface `CAPABILITY_PORT_MISSING`. - `index.ts` — the barrel exporting both. ```ts import { ToyWorldAdapter, asBaselineOnly } from "@42ch/spoke-fixture-toy-world"; import { orchestrateUpsert, orchestrateCheck } from "@42ch/spoke-operations"; const adapter = ToyWorldAdapter.withCommittedFixtures(); // seeded kb/rel/evt/rule/fnd fixtures // or: new ToyWorldAdapter() for an empty store const baseline = asBaselineOnly(adapter); // baseline-only dynamic boundary ``` The conformance harness (`tests/`) drives the same adapter source: - `toy-world-conformance.test.ts` — validates every committed fixture against the schemas (AJV). - `toy-world-adapter.test.ts` — exercises port orchestration over the seeded graph (Vitest). - `toy-world-ops-exercise.test.ts` — walks the op families against the adapter. ## Rust side The crate `spoke-fixture-toy-world` under `rust/` mirrors the TypeScript adapter (`src/toy_world_adapter.rs`, `src/memory_store.rs`, `src/lib.rs`): ```rust use spoke_fixture_toy_world::{as_baseline_only, ToyWorldAdapter}; let adapter = ToyWorldAdapter::with_committed_fixtures(); // or: ToyWorldAdapter::default() for an empty store let baseline = as_baseline_only(adapter); ``` `rust/tests/toy_world_adapter.rs` runs the same port-orchestration demos in cargo. The crate is `publish = false` — a workspace-local reference, exactly like the TypeScript fixture package. ## Connect fixtures The `conn_tw_*` JSON shows a full two-host connect exchange between the toy-world hosts: `ConnectHello` in each direction (`peer_tw_primary` ↔ `peer_tw_peer`), a `ConnectSession` snapshot (`initial_sequence: 0`), a `ConnectInvokeRequest` wrapping the real check payload, and both invoke response branches (success with an embedded `fnd_tw_open`; error reusing the shared `error-envelope` with `INVALID_INPUT`). The hello `signature` fields are structural test vectors — JCS canonicalization and cryptographic verification belong to the reference stack. ## Copy the skeleton 1. Clone the adapter shape: one type implementing the port families you claim (start from `BaselineAdapter`, grow to `FullAdapter`). 2. Swap `MemoryStore` for your storage (same method surface: get/put with expected-base-revision checks). 3. Call the matching `orchestrate*` — see [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops). 4. Validate your fixtures against the committed schemas with the same AJV/Vitest harness pattern (`pnpm run test:fixtures` in the repo, or `cargo test -p spoke-fixture-toy-world` for the Rust side). ## Next steps - [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — the port contract behind `ToyWorldAdapter`. - [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops) — the `orchestrate*` calls the harness exercises. - [Data model reference](https://spoke.42ch.dev/reference/data-model) — the wire objects the fixtures validate against. # Install and create your first KnowledgeEntry This tutorial walks you through a complete first round-trip: install the wire-type and operations packages, build a minimal in-memory adapter, upsert a KnowledgeEntry through `orchestrateUpsert`, and read the persisted entry back. Everything runs against the **published packages** — npm and crates.io — no repository checkout required. The companion tutorial, [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session), adds identity derivation, allowlist, signed hello, and session correlation over a WebSocket. ## Before you start - **TypeScript path** — Node.js ≥ 22.12 with `pnpm`. - **Rust path** — a stable Rust toolchain with `cargo`. All SPOKE packages share one lockstep SemVer (`X.Y.Z`). Pin the same version across every package you install. ## 1. Install the packages TypeScript (npm): ```bash pnpm add @42ch/spoke-schemas@X.Y.Z @42ch/spoke-operations@X.Y.Z ``` Rust (crates.io): ```bash cargo add spoke-schemas@X.Y.Z spoke-operations@X.Y.Z ``` What you get: - **`@42ch/spoke-schemas` / `spoke-schemas`** — generated wire types (`KnowledgeEntry`, `UpsertRequest`, `UpsertResponse`, …) compiled from the JSON Schema source of truth in [`schemas/`](https://github.com/42ch-dev/spoke/tree/main/schemas). - **`@42ch/spoke-operations` / `spoke-operations`** — pure lifecycle helpers, capability-sliced adapter ports, and the `orchestrate*` entrypoints that run the protocol gates before persistence. ## 2. Create a KnowledgeEntry A KnowledgeEntry is the atomic knowledge-base unit: stable `entry_id`, open `entry_type` / `status` strings, a closed `body`, and the required `extensions` bag. TypeScript: ```ts import type { KnowledgeEntry } from "@42ch/spoke-schemas"; const mira: KnowledgeEntry = { schema_version: 1, entry_id: "kb_mira", entry_type: "character", canonical_name: "Mira Vale", status: "provisional", body: { summary: "Reluctant cartographer arriving at Harbor Town." }, extensions: { my_product: { world_id: "wld_harbor" } }, }; ``` Rust — the generated types provide typed builders, so construction is compiler-checked: ```rust use spoke_schemas::data::KnowledgeEntry; let mira = KnowledgeEntry::builder() .schema_version(1) .entry_id("kb_mira") .entry_type("character") .canonical_name("Mira Vale") .status("provisional") .body( spoke_schemas::data::KnowledgeEntryBody::builder() .summary("Reluctant cartographer arriving at Harbor Town.".to_string()) .build()?, ) .extensions(std::collections::HashMap::new()) .build()?; ``` ## 3. Implement a minimal adapter `orchestrateUpsert` runs the validation and status gates, then persists through your ports. The adapter is the bridge between the protocol and your storage — for this tutorial, an in-memory `Map` implements the baseline port families. TypeScript: ```ts import { SpokeRejectCode, spokeOk, spokeReject, type BaselinePorts, type SpokeResult } from "@42ch/spoke-operations"; import type { Finding, HostCapabilityManifest, KnowledgeEntry, Relation, Rule, Scope, TimelineEvent } from "@42ch/spoke-schemas"; class InMemoryAdapter implements BaselinePorts { private entries = new Map(); async getKnowledgeEntry(entryId: string): Promise> { const entry = this.entries.get(entryId); return entry === undefined ? spokeReject(SpokeRejectCode.KNOWLEDGE_ENTRY_NOT_FOUND, `no entry ${entryId}`) : spokeOk(entry); } async putKnowledgeEntry(entry: KnowledgeEntry, expectedBaseRevision: number | null): Promise> { const stored = this.entries.get(entry.entry_id); if ((stored?.revision ?? null) !== expectedBaseRevision) { return spokeReject(SpokeRejectCode.REVISION_CONFLICT, "revision mismatch"); } this.entries.set(entry.entry_id, entry); return spokeOk(entry); } // Relation / Scope / Finding / Rule / Host-manifest ports — return empty // defaults for this tutorial; production adapters wire real storage. async getRelation(): Promise> { return spokeReject(SpokeRejectCode.RELATION_NOT_FOUND, "no relations"); } async putRelation(): Promise> { return spokeReject(SpokeRejectCode.INVALID_INPUT, "no relations"); } async listKnowledgeEntries(_scope: Scope): Promise> { return spokeOk([...this.entries.values()]); } async listTimelineEvents(_scope: Scope): Promise> { return spokeOk([]); } async putFindings(findings: Finding[]): Promise> { return spokeOk(findings); } async listRules(): Promise> { return spokeOk([]); } async getHostCapabilityManifest(): Promise> { return spokeOk({ schema_version: 1, host_id: "host_tutorial", roles: ["data-store"], capabilities: ["spoke-baseline"], namespaces: ["tutorial"], extensions: {} }); } async listPeerHostCapabilityManifests(): Promise> { return spokeOk([]); } } ``` Rust — the fixture crate implements the identical port traits on a reference `ToyWorldAdapter`; see [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) for the full Rust port implementation. `spoke-operations` re-exports the wire types (`spoke_operations::spoke_schemas`), so one dependency gives you both. ## 4. Run the upsert round-trip TypeScript: ```ts import { orchestrateUpsert } from "@42ch/spoke-operations"; import type { UpsertRequest } from "@42ch/spoke-schemas"; const adapter = new InMemoryAdapter(); const request: UpsertRequest = { knowledge_entries: [mira] }; async function runUpsert() { const result = await orchestrateUpsert(adapter, request); if (result.ok) { const persisted = result.value.knowledge_entries[0]; console.log(persisted.entry_id, persisted.status); // kb_mira provisional } else { console.error(result.code, result.message); } } ``` Expected rejects return `SpokeResult` — a discriminated `{ ok: true }` / `{ ok: false, code, message }` union — instead of throwing, so you can branch on `SpokeRejectCode` (`CANDIDATE_NOT_PROVISIONAL`, `REVISION_CONFLICT`, …). Rust: ```rust use spoke_operations::{orchestrate_upsert, BaselinePorts, SpokeResult}; use spoke_operations::spoke_schemas::{UpsertRequest, UpsertResponse}; async fn upsert(adapter: &impl BaselinePorts, request: UpsertRequest) { match orchestrate_upsert(adapter, request).await { SpokeResult::Ok(response) => println!("persisted: {:?}", response.knowledge_entries), SpokeResult::Reject(reject) => println!("rejected: {} — {}", reject.code, reject.message), } } ``` ## 5. What the orchestrator did for you `orchestrateUpsert` runs, per entry: schema validation, status-transition gating (when the entry already exists), active-uniqueness checks against the batch, and an optimistic-concurrency `putKnowledgeEntry` with the expected base revision. Your adapter stays pure I/O — all protocol rules live in the library. ## Next steps - [Implement an adapter](https://spoke.42ch.dev/how-to/implement-adapter) — which port families your claimed capabilities require. - [Orchestrate operations](https://spoke.42ch.dev/how-to/orchestrate-ops) — promote, relate, check, and assemble through the same port surface. - [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) — talk to another SPOKE host. - [Walk the ToyWorld reference adapter](https://spoke.42ch.dev/how-to/walk-toy-world) — the committed "Mira at Harbor" graph and a full `FullAdapter` implementation in TypeScript and Rust. # Open your first connect session This tutorial establishes a SPOKE Connect session end-to-end: derive your `peer_id`, sign a hello, verify the remote's signed hello against an allowlist, and invoke an op with correlation. It uses the TypeScript **language-native client** (`@42ch/spoke-connect`) against a local peer, and points to the Rust reference crate for the peer side. Connect is the opt-in interaction envelope family (`spoke-connect` capability flag) for cross-process SPOKE hosts. You should have completed [Install and create your first KnowledgeEntry](https://spoke.42ch.dev/tutorials/install-and-first-entry) first — this tutorial builds identity and session concepts on top of the data/ops story. ## 1. Install the client ```bash pnpm add @42ch/spoke-connect@X.Y.Z ``` The package ships the isomorphic core, the Node `connectClient`, the Noise subpath, and the RemoteAdapter module; [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) documents each entry point and its helpers. ## 2. Derive your peer identity Every connect host has an Ed25519 keypair. The wire `peer_id` is derived from the 32-byte public key — the identity multihash of the libp2p `PublicKey` protobuf, base58btc-encoded. The derivation is byte-identical across the TypeScript client, the Rust reference, and all native bindings (locked by shared golden vectors). `peer_id` is the network trust root — distinct from the advisory `host_id` carried inside the manifest. Derive it with `derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(seed))`; the identity helpers are documented in [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client). ## 3. Sign and verify a hello The handshake is a signed `ConnectHello`: each side signs a JCS-canonicalized object — `{protocol_version, peer_id, nonce, host}` for the initiator, that object plus `peer_nonce` (the initiator's nonce, the dial binding) for the responder — with its Ed25519 key, carried as base64url without padding. ```ts import { generateNonce, signHelloEd25519, verifyHelloEd25519 } from "@42ch/spoke-connect"; import type { HostCapabilityManifest } from "@42ch/spoke-schemas"; const seed = new TextEncoder().encode("..."); // 32-byte Ed25519 seed const manifest: HostCapabilityManifest = { schema_version: 1, host_id: "host_tutorial", roles: ["input-source"], capabilities: ["spoke-baseline"], namespaces: ["tutorial"], extensions: {}, }; const nonce = generateNonce(); // 16 CSPRNG bytes, base64url — ≥ the 16-char wire floor const hello = await signHelloEd25519(seed, nonce, manifest); // On the receiving side: verify against the sender's public key AND its // derived peer id — a key that derives a different peer id cannot attest // that peer's identity. await verifyHelloEd25519(remotePubkey, remotePeerId, hello); ``` Nonces are single-use per sender: the receiver records each accepted `(peer_id, nonce)` pair and rejects replays. The responder signs the same object with its own nonce plus the initiator's nonce (`signHelloEd25519`'s fourth argument), and the initiator passes its own nonce into verification — so a captured responder hello cannot be replayed into a fresh dial. The full hello walkthrough — canonical bytes, the nonce floor, and the `NonceStore` replay guard — is in [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client). ## 4. Configure the allowlist Admission is fail-closed: an empty allowlist rejects every peer. The receiving host accepts a connection only when the authenticated remote `peer_id` is listed. ```ts import { isAllowlisted } from "@42ch/spoke-connect"; const allowlist = [remotePeerId]; if (!isAllowlisted(allowlist, remotePeerId)) { throw new Error(`peer ${remotePeerId} is not allowlisted`); } ``` ## 5. Sequence and correlation Each session maintains per-direction monotonic `sequence` counters starting at 0. Every invoke attaches a `request_id`; the response must echo `session_id`, `sequence`, and `request_id` or the correlation check fails. ```ts import { OutboundSequence, checkResponseCorrelation, correlationFromRequest, correlationFromResponse } from "@42ch/spoke-connect"; const outbound = new OutboundSequence(); const request = { session_id: "sess_1", sequence: outbound.allocate(), // first call → 0 request_id: crypto.randomUUID(), op: "check", payload: { scope: { scope_id: "book-harbor" } }, extensions: {}, }; // When the response arrives: checkResponseCorrelation(correlationFromRequest(request), correlationFromResponse(response)); ``` ## 6. Full session: `connectClient` `connectClient` (from the `./node` subpath) dials a WebSocket, performs the signed hello exchange, validates the session snapshot (peer binding, `initial_sequence` 0), and routes correlated invokes by `request_id`: ```ts import { derivePeerIdFromEd25519Pubkey } from "@42ch/spoke-connect"; import { connectClient } from "@42ch/spoke-connect/node"; const remotePubkey = /* the peer's 32-byte Ed25519 public key */; const client = await connectClient({ url: "ws://127.0.0.1:8080", identity: { seed }, manifest, remotePubkey, allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)], }); const response = await client.invoke("check", { scope: { scope_id: "book-harbor" } }); client.close(); ``` The client rejects before the handshake starts when the remote peer id is missing from the allowlist, and rejects the session when the snapshot's peer ids do not match the authenticated hellos. ## 7. The peer side `connectClient` connects to any SPOKE host that speaks the connect wire family over an ordered reliable stream. The **Rust reference crate** (`spoke-connect` on crates.io) is the reference host implementation — it maps the envelopes onto rust-libp2p (noise, yamux, request-response) and demonstrates a two-node session where one node dials the other, exchanges signed hellos, and invokes `check`: ```bash cargo add spoke-connect@X.Y.Z cargo run -p spoke-connect --example two_node_usage ``` The compiled example source ([`examples/two_node_usage.rs`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/examples/two_node_usage.rs)) shows both sides: `SpokeConnectNode::start` with `peer_allowlist` and a local manifest, then `connect(addr)` and `session.invoke("check", payload)` — the same session rules the TypeScript client implements. The crate README ([`crates/spoke-connect/README.md`](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-connect/README.md)) documents the full flow, including capability-token step-up auth. ## What you now know - `peer_id` derivation from an Ed25519 public key, and why it is the trust root. - The signed hello (`spoke-connect-hello-jcs-v1`): JCS over `{protocol_version, peer_id, nonce, host}` (initiator) / plus `peer_nonce` (responder), Ed25519 signature, base64url, and the dial binding that rejects replayed responder hellos. - Fail-closed allowlist admission and single-use nonce replay protection. - Per-session sequence and `request_id` correlation. ## Next steps - [Integrate a RemoteAdapter against a live host](https://spoke.42ch.dev/tutorials/integrate-remote-adapter) — implement a `Transport`, dial with `connectRemoteAdapter`, and call the `BaselinePorts` surface against the demo mock inference host. - [Connect from the TypeScript client](https://spoke.42ch.dev/how-to/connect-ts-client) — the full client surface, browser vs Node, and core helpers. - [Connect from native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) — the same session core from C#, Kotlin, Swift, Go, or Python. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables and identity binding rules. # Integrate a RemoteAdapter against a live host This tutorial connects a **RemoteAdapter** to a live SPOKE connect host over a real WebSocket: you run the demo mock inference host, implement the message-oriented `Transport` the adapter dials through, call the drop-in `BaselinePorts` surface, watch the host's inference engine derive artifacts from your data, and see how failures surface. The code you work with is the shipped demo in `examples/connect-demo/` — the same integration surface a third-party TypeScript app would use: the **language-native TypeScript client** `@42ch/spoke-connect` (its `./remote` subpath), the `@42ch/spoke-schemas` wire types, and a WebSocket library. Nothing else. You should have completed [Open your first connect session](https://spoke.42ch.dev/tutorials/first-connect-session) first — this tutorial uses identities, allowlists, signed hellos, and sessions through the library instead of re-teaching them. ## 1. Meet the demo host The demo is two packages under `examples/connect-demo/`: - `server/` (`@42ch/spoke-demo-server`) — a deterministic **mock inference host**: a `BaselinePorts` adapter backed by a pure rule-based engine, served by a spec-faithful connect responder over a `ws` WebSocketServer. - `client/` (`@42ch/spoke-demo-client`) — the **third-party story**: its own `Transport` implementation over `ws`, then the real library client (`connectRemoteAdapter` from `@42ch/spoke-connect/remote`) dials the host and calls the drop-in async `BaselinePorts` surface. The host's identity and capabilities come from its manifest. The demo server advertises itself as `demo-inference-host` with the baseline capability, the two toy-world tool capability ids (so the client's tools negotiate on the session), and the optional `l2-computable` / `l5-fork` families; its namespaces are `demo-harbor` and `toy_world` (`examples/connect-demo/server/src/adapter/mock-adapter.ts`): ```ts export const DEMO_SERVER_MANIFEST: HostCapabilityManifest = { schema_version: 1, host_id: "demo-inference-host", roles: ["checker", "assembler", "input-source"], capabilities: [ "spoke-baseline", TOY_WORLD_ROLL_DICE_ID, TOY_WORLD_LORE_LOOKUP_ID, "l2-computable", "l5-fork", "ke-extraction", "ke-ownership", ], namespaces: [DEMO_SCOPE_ID, TOY_WORLD_NAMESPACE], tools: [ROLL_DICE_DESCRIPTOR, LORE_LOOKUP_DESCRIPTOR], extensions: {}, }; ``` The `TOY_WORLD_*` and `ROLL_DICE_*` / `LORE_LOOKUP_*` constants are the frozen tool ids and descriptors from `tools/toy-world-tools.ts` — the tools direction is covered in [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools); this tutorial follows the port direction. `DEMO_SCOPE_ID` is the demo namespace, `"demo-harbor"` — every seed entity and every demo manifest belongs there. Behind the manifest sits `MockEngine`, a deterministic inference engine: it starts from a fixed seed corpus (two KnowledgeEntries — Mira the dockworker and the Harbor district — plus one relation, one rule, and a seeded storm-fork timeline of three events), accepts conditional puts with optimistic concurrency, and re-derives its own artifacts after every accepted mutation. Derivation is a pure function of store history: no wall clock, no randomness. The engine also owns the optional families' state: `l2-computable` sessions (`project` materializes a computable view from the request's static state; `compute` merges a delta and settles it back into state) and `l5-fork` timeline queries over the seeded fork. The host is fail-closed about who may dial it: its allowlist contains exactly one `peer_id` — the demo client's. The client, in turn, needs only the host's public key and the host's `peer_id` to trust the connection. ## 2. Run the host You need Node.js `^22.18.0 || ^24.11.0 || >=26.0.0` with `pnpm`. Clone the repository and install once: ```bash git clone https://github.com/42ch-dev/spoke.git cd spoke pnpm install ``` The CLIs run from built output. Build the workspace packages the built CLIs import at runtime, plus the demo packages themselves (`examples/connect-demo/README.md`): ```bash pnpm -F @42ch/spoke-schemas build # compile-time prerequisite: generated wire types pnpm -F @42ch/spoke-connect build # runtime dep of both built demo CLIs pnpm -F @42ch/spoke-operations build # runtime dep of the built server CLI pnpm -F @42ch/spoke-demo-server build pnpm -F @42ch/spoke-demo-client build ``` In **terminal 1**, start the host: ```bash node examples/connect-demo/server/dist/main.js --port 8787 ``` It prints its identity, its allowlist, and the URL it listens on: ```text SPOKE connect demo — mock inference host peer_id: 12D3KooWNm5t4HypYRmiC5v9CD2TnPKrJh2J8TcfJ2gPhA7L8TiZ allowlist: 12D3KooWM82bDYYgzgXaayHDdVciFe3bGvJ69qHnbSztNUJ933VQ listening: ws://127.0.0.1:8787 tools: discovers dialer tools from the authenticated manifest; reverse-invokes tools.toy_world.roll_dice mid-orchestration (Ctrl+C to stop) ``` The `peer_id` is the host's trust root — derived from its Ed25519 public key, exactly as in the first tutorial. The printed allowlist is the demo client's `peer_id`: this host will accept a dial only from that peer. Leave the host running. ## 3. Implement `WsTransport` A `Transport` is a consumer-implemented seam that carries connect envelopes between the adapter and the remote peer. The full contract table is in [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter). The demo client implements the seam over the `ws` WebSocket package (`examples/connect-demo/client/src/transport/ws-transport.ts`): ```ts import { WebSocket } from "ws"; import type { EnvelopeBytes, Transport } from "@42ch/spoke-connect/remote"; /** A pending `recv` waiter. */ type RecvWaiter = { resolve: (bytes: EnvelopeBytes) => void; reject: (error: Error) => void; }; /** View a `ws` message payload as envelope bytes (fresh per message). */ function toEnvelopeBytes(data: unknown): EnvelopeBytes { if (Buffer.isBuffer(data)) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } return new Uint8Array(data as ArrayBuffer); } ``` WebSocket already frames messages, so one WS message is exactly one connect envelope — no length-prefix delimiting needed on this carrier. The class keeps an inbound buffer for messages that arrive before anything calls `recv`, and a queue of pending `recv` waiters for the other direction: ```ts export class WsTransport implements Transport { readonly #socket: WebSocket; /** Resolves once the socket is open; rejects if the connect fails. */ readonly #open: Promise; #closed = false; readonly #buffer: EnvelopeBytes[] = []; readonly #waiters: RecvWaiter[] = []; constructor(url: string) { this.#socket = new WebSocket(url); this.#open = new Promise((resolve, reject) => { this.#socket.once("open", () => resolve()); this.#socket.once("error", (error) => { reject( error instanceof Error ? error : new Error(`ws connect to ${url} failed`), ); }); }); this.#socket.on("message", (data) => this.#push(toEnvelopeBytes(data))); // Both events fail pending recvs — a drop always surfaces as close/error. const fail = (): void => this.#failPending(new Error("ws connection closed")); this.#socket.on("close", fail); this.#socket.on("error", fail); } ``` `send` waits for the socket to open, then writes one envelope: ```ts async send(envelope: EnvelopeBytes): Promise { await this.#open; if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) { throw new Error("WsTransport is closed"); } await new Promise((resolve, reject) => { this.#socket.send(envelope, (error) => { if (error) { reject(error); return; } resolve(); }); }); } ``` `recv` serves buffered messages first, then waits; `close` is idempotent and fails any pending `recv` so the adapter's in-flight invokes fail fast instead of waiting out their timeouts: ```ts recv(): Promise { if (this.#closed) { return Promise.reject(new Error("WsTransport is closed")); } const buffered = this.#buffer.shift(); if (buffered !== undefined) { return Promise.resolve(buffered); } return new Promise((resolve, reject) => { this.#waiters.push({ resolve, reject }); }); } close(): void { if (this.#closed) { return; } this.#closed = true; this.#failPending(new Error("WsTransport is closed")); this.#socket.close(); } #push(bytes: EnvelopeBytes): void { const waiter = this.#waiters.shift(); if (waiter !== undefined) { waiter.resolve(bytes); return; } this.#buffer.push(bytes); } #failPending(error: Error): void { for (const waiter of this.#waiters.splice(0)) { waiter.reject(error); } } } ``` That is the whole seam. Every connect envelope the adapter sends or receives flows through these three methods; the adapter handles all session rules on top of them. ## 4. Dial with `connectRemoteAdapter` With the transport in hand, dialing is one call. `connectRemoteAdapter` performs the signed-hello exchange, the allowlist check, and the session snapshot verification, then resolves to an established adapter (`examples/connect-demo/client/src/main.ts`): ```ts export async function runDemoClient(options: { url: string; }): Promise { const transport = new WsTransport(options.url); const adapter = await connectRemoteAdapter({ transport, localIdentity: { seed: DEMO_CLIENT_SEED }, localManifest: DEMO_CLIENT_MANIFEST, remotePubkey: DEMO_SERVER_PUBKEY, allowlist: [DEMO_SERVER_PEER_ID], }); ``` The options mirror the first tutorial's session concepts: - `transport` — your `Transport` implementation; the adapter sends and receives envelopes through it. - `localIdentity.seed` — your 32-byte Ed25519 seed; the adapter signs your hello with it. - `localManifest` — your `HostCapabilityManifest`, advertised in the signed hello. The demo client is an `input-source` app in the `demo-harbor` and `toy_world` namespaces; its manifest declares the baseline capability, the two tool ids it serves, and the optional `l2-computable` / `l5-fork` families (a family is negotiated only when both manifests declare it): ```ts export const DEMO_CLIENT_MANIFEST: HostCapabilityManifest = { schema_version: 1, host_id: "demo-third-party-app", roles: ["input-source"], capabilities: [ "spoke-baseline", TOY_WORLD_ROLL_DICE_ID, TOY_WORLD_LORE_LOOKUP_ID, "l2-computable", "l5-fork", "ke-extraction", "ke-ownership", ], namespaces: [DEMO_SCOPE_ID, "toy_world"], tools: [ROLL_DICE_DESCRIPTOR, LORE_LOOKUP_DESCRIPTOR], extensions: {}, }; ``` - `remotePubkey` — the host's 32-byte Ed25519 public key. The remote `peer_id` is derived from it, and it must be on the allowlist (fail-closed). The demo ships fixed identity seeds — DEMO ONLY; production apps must generate their own Ed25519 keys. The client keeps its own copy of the host's public key and `peer_id` (`examples/connect-demo/client/src/identities.ts`): ```ts /** Public key derived from {@link DEMO_SERVER_SEED} — the remote key the client trusts. */ export const DEMO_SERVER_PUBKEY = getPublicKeyEd25519(DEMO_SERVER_SEED); /** peer_id derived from {@link DEMO_SERVER_PUBKEY} — the client's allowlist entry. */ export const DEMO_SERVER_PEER_ID = derivePeerIdFromEd25519Pubkey( DEMO_SERVER_PUBKEY, ); ``` In a real integration, key distribution is transport-adapter-owned: you obtain the host's public key out of band and pin it, exactly as the demo pins its constants. - `allowlist` — the peer ids this adapter accepts; the remote `peer_id` must be listed. A dial failure — wrong key, missing allowlist entry, handshake rejection — rejects the `connectRemoteAdapter` promise, and no adapter instance exists. Run the client in **terminal 2**: ```bash node examples/connect-demo/client/dist/main.js --url ws://127.0.0.1:8787 ``` The dial establishes, and the CLI prints the session: ```text SPOKE connect demo — third-party client dialing ws://127.0.0.1:8787 as 12D3KooWM82bDYYgzgXaayHDdVciFe3bGvJ69qHnbSztNUJ933VQ remote peer: 12D3KooWNm5t4HypYRmiC5v9CD2TnPKrJh2J8TcfJ2gPhA7L8TiZ (demo-inference-host) capabilities: spoke-baseline, tools.toy_world.roll_dice, tools.toy_world.lore_lookup, l2-computable, l5-fork namespaces: demo-harbor, toy_world ``` The remote peer id matches the host's printed `peer_id`, and the manifest is the server manifest you met in section 1 — the adapter exposes it as `adapter.remoteManifest`, cached at session establish. ## 5. Call the ports The adapter implements the async `BaselinePorts` six families, so you call knowledge, relation, scope, finding, rule, and host-manifest methods as if the peer were local. The demo client exercises the knowledge family with optimistic concurrency (`examples/connect-demo/client/src/main.ts`): ```ts // Step 1 — capability manifest (cached at establish, no round-trip). const serverManifest = adapter.remoteManifest; // Step 2 — put → get round-trip with OCC: create, then compare-and-swap. const created = requireOk( await adapter.putKnowledgeEntry(SUBMITTED_ENTRY, null), ); if (created.revision === undefined) { throw new Error("demo client: created entry has no revision"); } const updated = requireOk( await adapter.putKnowledgeEntry( { ...SUBMITTED_ENTRY, status: "confirmed" }, created.revision, ), ); const fetched = requireOk( await adapter.getKnowledgeEntry(SUBMITTED_ENTRY.entry_id), ); // Step 3 — list: seed corpus + submitted entry + engine-derived artifacts. const listed = requireOk( await adapter.listKnowledgeEntries({ scope_id: DEMO_SCOPE_ID, // DEMO_VIEWPOINT_HOLDER_ID = "demo-harbor/character/mira" viewpoint: DEMO_VIEWPOINT_HOLDER_ID, }), ); // Step 4 — findings round-trip. const findings = requireOk(await adapter.putFindings([SUBMITTED_FINDING])); // Step 5 — peer host manifests (the demo host knows no peers). const peerManifests = requireOk( await adapter.listPeerHostCapabilityManifests(), ); ``` Two things are worth noticing here. First, `putKnowledgeEntry` is conditional: the second argument is the expected base revision. `null` means **create** — the entry must not exist yet; a number means **compare-and-swap** — the store's current revision must equal it. The first put creates the entry at revision 1; the second put passes `created.revision` and updates the entry to revision 2 (and flips its status to `confirmed`). Revisions are store-owned — the host assigns them, never the caller. The submitted entry is a plain `KnowledgeEntry`: ```ts const SUBMITTED_ENTRY: KnowledgeEntry = { schema_version: 1, entry_id: "demo-harbor/item/compass", entry_type: "item", canonical_name: "Compass", status: "provisional", body: { summary: "A brass compass." }, extensions: {}, }; ``` Second, every port call settles to a `SpokeResult` — a discriminated `{ ok: true, value }` / `{ ok: false, code, message }` union — instead of throwing. The demo unwraps with a small helper that fails loudly on rejection (`examples/connect-demo/client/src/main.ts`): ```ts /** Unwrap a port-call result or fail the demo loudly (no silent fallbacks). */ function requireOk(result: AnySpokeResult): T { if (!result.ok) { throw new Error( `demo client: port call rejected (${result.code}): ${result.message}`, ); } return result.value; } ``` `getHostCapabilityManifest` is special: it is the session cache, served from the signed hello at establish — no round-trip. That is why the client reads `adapter.remoteManifest` instead of calling the port. ## 6. See the mock inference The host's engine watches the store and derives its own artifacts after every accepted mutation. Look at the list output the CLI prints at the end of its run: ```text listKnowledgeEntries → 5 entries (demo-harbor/character/mira, demo-harbor/location/harbor, derived/world-digest, demo-harbor/item/compass, demo-harbor/artifact/dice-roll) ``` The first two entries are the seed corpus; `demo-harbor/item/compass` is the entry you put; `derived/world-digest` is the engine's; `demo-harbor/artifact/dice-roll` is the orchestration's roll feed (see [Expose and invoke remote tools](https://spoke.42ch.dev/how-to/connect-remote-tools)). Every accepted put re-runs the derivation, which builds a reserved-id KnowledgeEntry (`examples/connect-demo/server/src/engine/mock-engine.ts`): ```ts const digest: KnowledgeEntry = { schema_version: 1, entry_id: DERIVED_WORLD_DIGEST_ENTRY_ID, entry_type: "note", canonical_name: "World Digest", status: "confirmed", body: { summary: `Digest of ${userEntries.length} knowledge entries in demo-harbor.`, computable: { entry_type_counts: entryTypeCounts, entry_ids_sorted: sortedIds, }, }, revision: this.derivationCount, extensions: {}, }; ``` After the demo flow, the digest reads: ```json { "schema_version": 1, "entry_id": "derived/world-digest", "entry_type": "note", "canonical_name": "World Digest", "status": "confirmed", "body": { "summary": "Digest of 4 knowledge entries in demo-harbor.", "computable": { "entry_type_counts": { "character": 1, "location": 1, "item": 1, "note": 1 }, "entry_ids_sorted": [ "demo-harbor/artifact/dice-roll", "demo-harbor/character/mira", "demo-harbor/item/compass", "demo-harbor/location/harbor" ] } }, "revision": 4, "extensions": {} } ``` The digest's `revision` equals the derivation count — it advances on every accepted put, so the artifact is a stable function of user history. The `derived/` id namespace is reserved: user puts into it are rejected. This is what a real inference host's output looks like through the same `BaselinePorts` surface: derived knowledge appears in ordinary listings and reads, indistinguishable in shape from user data. Timeline listings work the same way: the three seeded storm-fork events appear in the baseline `listTimelineEvents` output too — fork events are ordinary events in the shared store, and `listForkTimelineEvents` is only the fork-scoped refinement (section 8). ## 7. Handle errors Two classes of failure matter, and they surface differently. **Dial failures happen before an adapter exists.** If the host rejects your hello — wrong allowlist, wrong key, nonce replay — `connectRemoteAdapter` rejects and you have no adapter to close. The demo proves the allowlist path end to end: a third identity, `DEMO_STRANGER_SEED`, trusts the server but is not on the server's allowlist. The server-side allowlist check fails the hello and closes the socket mid-dial, so the client's dial fails fast with the connection loss (`examples/connect-demo/client/tests/e2e.test.ts`): ```ts it("rejects a dial from a non-allowlisted stranger identity", async () => { const transport = new WsTransport(server.url); transports.push(transport); // The stranger's OWN allowlist trusts the server, so the dial is // attempted; the SERVER-side allowlist rejects the hello and closes the // socket, failing the dial fast — no session is established. The // rejection is the handshake's connection loss (the server hung up // mid-dial), not a bare any-error assertion. await expect( connectRemoteAdapter({ transport, localIdentity: { seed: DEMO_STRANGER_SEED }, localManifest: DEMO_CLIENT_MANIFEST, remotePubkey: DEMO_SERVER_PUBKEY, allowlist: [DEMO_SERVER_PEER_ID], }), ).rejects.toThrow(/ws connection closed/); transport.close(); }); ``` **Port-call failures settle to `SpokeResult` rejects.** On an established session, every port call either resolves `ok` or rejects with `{ ok: false, code, message, details }` — your code branches on `result.ok` or unwraps with a helper like `requireOk`. Rejects carry the wire code (`REVISION_CONFLICT` for a create on an existing id, `STORED_REVISION_STALE` for a stale base revision, `INVALID_INPUT` for a reserved `derived/` id, …) and, for infrastructure failures, a `details.kind` that tells you which layer failed — including `transport` (I/O), `session_closed` (connection lost — stop the host and watch in-flight calls reject), and `timeout` (that call only; the session stays usable). The complete failure table lives in [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter). That is the whole error surface: dial rejects before the adapter exists, port calls reject after it — two classes, and each surfaces through one channel. ## 8. Drive the optional port families The demo's session carries two optional families beyond `spoke-baseline` — `l2-computable` (`project` / `compute` sessions) and `l5-fork` (fork-branch timeline queries). Both manifests declared them (sections 1 and 4), so the pair negotiated them and the responder's dispatch gate admits the `port.computable.*` / `port.fork.*` ops. ### Serve — the host side The server serves the families through the same `ports` seam as the baseline: `DemoOrchestrator` implements the composed `FullPorts` contract, and `MockEngine` owns the deterministic state. `project` materializes the session's computable view from the request's static state and records the session; `compute` merges the request's delta into the session view and — when `settle` is true — merges the view back into the session's static state; `listForkTimelineEvents` returns the seeded storm-fork timeline (`demo-harbor/fork/storm`, three events), scoped like any scope query. Nothing is reimplemented at the protocol layer — the responder probes the provider for the family methods and dispatches the catalogue rows. ### Drive — the client side `runDemoClient` drives the optional steps only when its own manifest declares the families — the negotiated set is the intersection, so a server that did not declare a family denies loudly instead of being skipped (`examples/connect-demo/client/src/main.ts`): ```ts // Steps 6-7 — optional families: drive them only when THIS client's // manifest declares them (the negotiated set is the intersection of both // manifests, so a server that does not declare a family denies loudly // through requireOk instead of skipping silently). The default manifest // declares both, so the demo flow always runs them. const drivesOptionalOps = dialManifest.capabilities.includes("l2-computable") && dialManifest.capabilities.includes("l5-fork"); // Step 6 — l2-computable round-trip: project materializes the session's // computable view from static state; compute applies the delta and // settles it back into static state (the derived state). let projected: ProjectSuccess | undefined; let computed: ComputeSuccess | undefined; let forkEvents: TimelineEvent[] | undefined; if (drivesOptionalOps) { const projectedResult = requireOk( await adapter.project({ session_id: COMPUTABLE_SESSION_ID, entry_id: COMPUTABLE_ENTRY_ID, state: { ...PROJECT_STATE }, }), ); if ("error" in projectedResult) { throw new Error( `demo client: project answered an error branch (${projectedResult.error.code})`, ); } projected = projectedResult; const computedResult = requireOk( await adapter.compute({ session_id: COMPUTABLE_SESSION_ID, entry_id: COMPUTABLE_ENTRY_ID, computable: { ...COMPUTE_DELTA }, settle: true, }), ); if ("error" in computedResult) { throw new Error( `demo client: compute answered an error branch (${computedResult.error.code})`, ); } computed = computedResult; // Step 7 — l5-fork round-trip: the seeded storm-fork timeline. forkEvents = requireOk( await adapter.listForkTimelineEvents({ scope_id: DEMO_SCOPE_ID, fork_id: DEMO_STORM_FORK_ID, }), ); } ``` The CLI prints the optional steps right after the baseline ones: ```text project demo-harbor/location/harbor → {"ships_at_dock":3} compute (settle) demo-harbor/location/harbor → {"ships_at_dock":3,"tide":"rising"} state {"ships_at_dock":3,"tide":"rising"} listForkTimelineEvents → 3 event(s) (demo-harbor/event/storm-landfall, demo-harbor/event/harbor-evacuation, demo-harbor/event/compass-secured) ``` `project` returns the materialized view (`{ ships_at_dock: 3 }`); `compute` applies the delta `{ tide: "rising" }` and settles — the settled view and the derived static state both read `{ ships_at_dock: 3, tide: "rising" }`; the fork timeline comes back verbatim from the seed corpus. ### Deny — the undeclared capability The deny path is the same fail-closed row as every dispatch deny: a family only one side declared is absent from the negotiated set, so the responder's gate answers the wire code `op_unsupported` and the client maps it to a `CAPABILITY_PORT_MISSING` reject with `details.wire_code` preserved. The e2e proves it with a server variant whose manifest omits `l2-computable` — the assertion is the client-side mapped reject itself (`examples/connect-demo/client/tests/e2e.test.ts`): ```ts const result = await adapter.compute({ session_id: "demo-session/deny-negative", entry_id: "demo-harbor/location/harbor", computable: { tide: "rising" }, settle: true, }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe("CAPABILITY_PORT_MISSING"); expect(result.details?.wire_code).toBe("op_unsupported"); } ``` The deny never succeeds silently: the caller observes the rejection, and nothing is fed into the engine. The same optional faces exist on the native bindings — `RemoteAdapterFFI.project` / `.compute` / `.list_fork_timeline_events`, and the responder's optional foreign-callback `PortsHandler` — see [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding). The full catalogue and the serving contract are in [Optional port families](https://spoke.42ch.dev/reference/connect#optional-port-families). ## What you now know - What a connect host looks like from the outside: a `BaselinePorts` adapter behind a signed-hello responder, advertising capabilities and namespaces in its manifest. - The message-oriented `Transport` seam: one envelope per `send`/`recv`, `recv` rejects on close, `close` is idempotent — and a complete WebSocket implementation of it. - How to dial with `connectRemoteAdapter`: `transport`, `localIdentity`, `localManifest`, `remotePubkey`, and a fail-closed `allowlist`. - The `BaselinePorts` call pattern: conditional puts with optimistic concurrency, `SpokeResult` returns, and `getHostCapabilityManifest` served from the session cache. - Where host-side inference shows up: derived artifacts with reserved ids appearing in ordinary listings. - How failures surface: dial rejection before an adapter exists, `SpokeResult` rejects with `details.kind` after. - How optional families flow end to end: declare them in both manifests, serve them through the responder `ports` provider, drive them with `project` / `compute` / `listForkTimelineEvents`, and observe the fail-closed deny (`CAPABILITY_PORT_MISSING`, `wire_code: "op_unsupported"`) when a family is not negotiated. ## Next steps - [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter) — the task-oriented counterpart: the full option tables, concurrency rules, and the error mapping. - [Route across multiple peers](https://spoke.42ch.dev/how-to/multi-peer-routing) — compose several established adapters behind one `BaselinePorts` surface. - [Connect wire reference](https://spoke.42ch.dev/reference/connect) — envelope field tables, envelope authentication, and the port-method ops catalogue. - [RemoteAdapter from native bindings](https://spoke.42ch.dev/how-to/remote-adapter-native-binding) — the same adapter lifecycle, optional port faces, and `PortsHandler` over FFI. # Package quick-start SPOKE ships consumer packages on **lockstep SemVer** — generated wire types, hand-written operations libraries, and Connect session clients (language-native TypeScript client, Rust reference, and native bindings). Pin all surfaces you use to the same `X.Y.Z`. ## TypeScript (npm) ```bash pnpm add @42ch/spoke-schemas@X.Y.Z @42ch/spoke-operations@X.Y.Z ``` - **`@42ch/spoke-schemas`** — generated wire types (import `KnowledgeEntry`, `TimelineEvent`, `PromoteRequest`, `AssemblePacket`, `HostCapabilityManifest`, … from the package root). - **`@42ch/spoke-operations`** — pure helpers, capability-sliced adapter ports, and `orchestrate*` entrypoints. ## Rust (crates.io) ```bash cargo add spoke-schemas@X.Y.Z spoke-operations@X.Y.Z ``` ```toml [dependencies] spoke-schemas = "X.Y.Z" spoke-operations = "X.Y.Z" ``` - **`spoke-schemas`** — generated Rust wire types from the same JSON Schema SSOT. - **`spoke-operations`** — port traits + `orchestrate_*` (re-exports `spoke_schemas`). ## Connect Connect session clients ship alongside the wire/operations packages at the same lockstep SemVer: ```bash pnpm add @42ch/spoke-connect@X.Y.Z ``` - **`@42ch/spoke-connect`** — TypeScript language-native client. - **`@42ch/spoke-connect/remote`** — the RemoteAdapter subpath: dial a remote peer over a consumer `Transport` and route across multiple peers (see [RemoteAdapter over a Transport](https://spoke.42ch.dev/how-to/connect-remote-adapter)). ```bash cargo add spoke-connect@X.Y.Z ``` - **`spoke-connect`** — Rust reference crate (libp2p transport + uniffi binding surface). Native bindings for host languages (C# NuGet `42ch.Spoke.Connect`, Kotlin Maven `dev.42ch:spoke-connect`, Swift SPM `SpokeConnect`, Go module `github.com/42ch-dev/spoke/crates/spoke-connect/bindings/go`, Python PyPI `spoke-connect`) and for C and C++ (hand-written C ABI, git) embed the shared session core via FFI — see [Native bindings](https://spoke.42ch.dev/how-to/connect-native-bindings) and [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding). ### C and C++ (git) ```bash git clone --branch vX.Y.Z --depth 1 https://github.com/42ch-dev/spoke.git git lfs install # once per machine git lfs pull # fetch the carrier libraries; a fresh clone smudges them automatically ``` - **`crates/spoke-connect/bindings/cpp/include/spoke_connect.h`** — the hand-written C ABI header. - **`crates/spoke-connect/bindings/cpp/include/spoke_connect.hpp`** — the C++17 header-only convenience layer. - **`crates/spoke-connect/bindings/cpp/native/osx-arm64/libspoke_connect_capi.dylib`** — the macOS arm64 carrier. - **`crates/spoke-connect/bindings/cpp/native/win-x64/spoke_connect_capi.dll`** — the Windows x64 carrier. - **`crates/spoke-connect/bindings/cpp/native/win-x64/spoke_connect_capi.dll.lib`** — the Rust-produced import library the Windows x64 link step consumes. The committed C/C++ carriers target macOS arm64 (`osx-arm64`) and Windows x64 (`win-x64`). Take `spoke_connect.h`, `spoke_connect.hpp`, and the native files for your target from the same repository tag `vX.Y.Z` — resolution is a tag checkout, while `cargo add spoke-connect` installs the crates.io crate. Full guide: [Connect from C and C++](https://spoke.42ch.dev/how-to/connect-cpp-binding). ## Integrator path 1. Import wire types from the schemas package. 2. Implement the port families for the capabilities you claim on one adapter type (`BaselineAdapter` … `FullAdapter`). 3. Call the matching orchestrator (`orchestrateUpsert`, `orchestratePromote`, `orchestrateExtract`, …) — pure gates run, and the persistence-bearing paths write through your ports. The optional `ke-extraction` path takes a standalone `ExtractionPort` plus an async extractor callback and returns provisional candidates for later admission through `promote`. 4. Walk the committed "Mira at Harbor" graph and the reference `ToyWorldAdapter` in `fixtures/toy-world/` (TypeScript adapter + Rust fixture crate). ## Version policy All packages bump together (lockstep SemVer); annotated tags `vX.Y.Z` match. See [Version & release](https://spoke.42ch.dev/release/versioning) for the pin guide. ## More resources - [README.md](https://github.com/42ch-dev/spoke/blob/main/README.md) — install, quick start, operations overview - [@42ch/spoke-schemas on npm](https://www.npmjs.com/package/@42ch/spoke-schemas) and its [package README](https://github.com/42ch-dev/spoke/blob/main/packages/spoke-schemas/README.md) - [@42ch/spoke-operations on npm](https://www.npmjs.com/package/@42ch/spoke-operations) and its [package README](https://github.com/42ch-dev/spoke/blob/main/packages/spoke-operations/README.md) - [spoke-schemas on crates.io](https://crates.io/crates/spoke-schemas) and its [crate README](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-schemas/README.md) - [spoke-operations on crates.io](https://crates.io/crates/spoke-operations) and its [crate README](https://github.com/42ch-dev/spoke/blob/main/crates/spoke-operations/README.md) # Version & release SPOKE publishes a **single lockstep SemVer** across all workspace packages and Rust consumer crates, so integrators pin one version across TypeScript and Rust artifacts. Releases are annotated git tags `vX.Y.Z`, materialized as GitHub Releases, and published to npm and crates.io on every tag without a `-rc.` prerelease segment. ## What to pin - **npm** — `@42ch/spoke-schemas`, `@42ch/spoke-operations`, `@42ch/spoke-connect` - **crates.io** — `spoke-schemas`, `spoke-operations`, `spoke-connect` - Same `X.Y.Z` everywhere; tags `vX.Y.Z` match the lockstep version. ## Release shape - **Lockstep manifests** — workspace root, all three TS packages, all three published Rust crates, fixture/codegen tooling, and `Cargo.lock` all share one version; CI asserts zero drift (`pnpm run verify:version`). - **Tags** — annotated `vX.Y.Z`. Every tag without `-rc.` — stable `vX.Y.Z` or prerelease like `v0.1.0-alpha.3` — publishes to npm and crates.io; tags with `-rc.` (`vX.Y.Z-rc.N`) create GitHub pre-releases only. - **Notes** — release notes come from `CHANGELOG.md`, generated by git-cliff from Conventional Commits. - **Publish** — npm and crates.io via CI Trusted Publishing on every tag without `-rc.`; `-rc.` tags create GitHub pre-releases only. ## SemVer semantics - **PATCH** — bugfix-only packaging release; the wire contract stays unchanged. - **MINOR** — backward-compatible wire or ops library additions. - **MAJOR** — breaking wire or public API change (pre-1.0, allowed without a deprecation period). ## Pin methods - npm: `pnpm add @42ch/spoke-schemas@X.Y.Z @42ch/spoke-operations@X.Y.Z @42ch/spoke-connect@X.Y.Z` - crates.io: `spoke-schemas = "X.Y.Z"`, `spoke-operations = "X.Y.Z"`, `spoke-connect = "X.Y.Z"` - Git tag: `git checkout vX.Y.Z`; or a GitHub Release source archive for that tag. ## Package SemVer vs wire `schema_version` Package SemVer and the integer wire `schema_version` move independently — a bump to one does not require a bump to the other. As an exception, release notes explicitly couple the two when a release covers both a package bump and a wire change. ## More resources - [CHANGELOG.md](https://github.com/42ch-dev/spoke/blob/main/CHANGELOG.md) — release notes - [GitHub Releases](https://github.com/42ch-dev/spoke/releases) — releases and source archives - [@42ch/spoke-schemas on npm](https://www.npmjs.com/package/@42ch/spoke-schemas) / [spoke-schemas on crates.io](https://crates.io/crates/spoke-schemas) — the published packages - [README.md](https://github.com/42ch-dev/spoke/blob/main/README.md) — install and pin examples