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 + 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:
import type {
BaselineAdapter,
ComputableAdapter,
ForkAdapter,
FullAdapter,
KnowledgeEntryPort,
RelationPort,
ScopeQueryPort,
FindingPort,
RuleQueryPort,
HostManifestPort,
ComputablePort,
ForkTimelineQueryPort,
ExtractionPort,
} from "@42ch/spoke-operations";2. Implement the ports
Port methods are async on the normative surface: TypeScript methods return Promise<SpokeResult<T>> and Rust traits declare async fn …(&self, …) -> SpokeResult<T> — the orchestrators await every call.
KnowledgeEntryPort — entry persistence
getKnowledgeEntry(entryId: string): Promise<SpokeResult<KnowledgeEntry>>;
putKnowledgeEntry(
entry: KnowledgeEntry,
expectedBaseRevision: number | null,
): Promise<SpokeResult<KnowledgeEntry>>;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
getRelation(relationId: string): Promise<SpokeResult<Relation>>;
putRelation(
relation: Relation,
expectedBaseRevision: number | null,
): Promise<SpokeResult<Relation>>;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
listKnowledgeEntries(scope: Scope): Promise<SpokeResult<KnowledgeEntry[]>>;
listTimelineEvents(scope: Scope): Promise<SpokeResult<TimelineEvent[]>>;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
putFindings(findings: Finding[]): Promise<SpokeResult<Finding[]>>;Findings are checker output, persisted through this port after orchestrateCheck runs your checker callback.
RuleQueryPort — rule resolution
listRules(ruleRefs: string[]): Promise<SpokeResult<Rule[]>>;Resolves check rule references. Embedded rules[] in the request override by rule_id; unresolved refs reject the check.
HostManifestPort — collaboration metadata
getHostCapabilityManifest(): Promise<SpokeResult<HostCapabilityManifest>>;
listPeerHostCapabilityManifests(): Promise<SpokeResult<HostCapabilityManifest[]>>;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
project(request: ProjectRequest): Promise<SpokeResult<ProjectResponse>>;
compute(request: ComputeRequest): Promise<SpokeResult<ComputeResponse>>;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
listForkTimelineEvents(
scope: Scope & { fork_id: ForkId },
): Promise<SpokeResult<TimelineEvent[]>>;Fork-scoped timeline reads. One object may satisfy both ScopeQueryPort and this port.
ExtractionPort — optional ke-extraction
loadExtractionInput(request: ExtractRequest): Promise<SpokeResult<OpaqueJson>>;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".
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/ demonstrates the pattern — see Walk the ToyWorld reference adapter 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_MISSINGwhen an orchestrator needs them at a dynamic boundary.HostManifestPortis baseline-required and never gated behind that code.
Next steps
- Orchestrate operations — the
orchestrate*calls your adapter enables. - Walk the ToyWorld reference adapter — a complete
FullAdapterin TypeScript and Rust, with a conformance harness. - Data model reference — the wire objects your ports persist.
- Ops wire reference — request/response envelope shapes.