# 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.
## 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).
## 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 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.
## 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.
## 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).
## 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 `