OpenProse
SDK API Reference

Adapters

The /adapters injection boundary -- the one Substrate primitive, the restart-survival invariant, the gateway-ingress and cursor toolkit, record/replay, and the port contracts a custom backend implements.

Adapters

@openprose/reactor/adapters is the injection boundary: the seam where a reactor's I/O lives. Everything above it -- the reconciler, the canonicalizer, the receipt chain -- is a pure function over the ports this subpath defines. A deployment swaps the backends here without touching the engine, and the front door facade reaches in for you on the common paths.

This page is the reference for what /adapters exports and, more importantly, the contracts a custom backend must honor to stay correct. The headline is one record:

import { fileSystemSubstrate } from "@openprose/reactor/adapters";

The one Substrate primitive

A reactor keeps two durable things: its truth (the world-model) and its memory of what it already decided (the receipt trail). Substrate is the single record that answers "where does a reactor keep both" -- one shape, four fields:

interface Substrate {
  readonly clock: ClockAdapter;          // the only time source
  readonly storage: StorageAdapter;      // the receipt ledger's append-only trail
  readonly worldModel: WorldModelStore;  // the truth the render commits through
  readonly ledger: MutableReceiptLedger; // derived from `storage` (see below)
}

Two named factories build it correctly so you do not hand-wire the four fields:

  • fileSystemSubstrate({ directory }) -- the durable substrate. A system clock, a filesystem storage adapter (the receipt trail at <directory>/receipts.json), a durable ledger re-derived from that storage, and a filesystem world-model store under <directory>/world-models/. This is the canonical layout the CLI and DevTools fixtures share, so a substrate built here and a state-dir the CLI populated re-open the same durable trail and truth.
  • inMemorySubstrate() -- the ephemeral substrate for tests and replay. The same clock, an in-memory storage adapter, a ledger re-derived from that in-memory storage (identical re-derivation semantics, just no disk), and an in-memory world-model store. Nothing persists; a fresh inMemorySubstrate() is empty.

The constructors (mountDag / createReactor / runProject) accept a { substrate }, and the substrate is a strict superset of the older à-la-carte fields -- no backend was removed. The facade builds one for you from { directory }.

The restart-survival invariant

This is the load-bearing reason the durable factory exists, and the one thing a custom backend must not get wrong.

For a durable substrate, the ledger MUST be derived from the same storage adapter -- createFileSystemReceiptLedger({ storage }) over the exact storage the substrate also exposes. The durable ledger re-derives every node's last receipt from that storage's append-only trail at construction. So re-opening the same directory re-opens the full prior memory, and the boot sweep memo-skips the unchanged nodes instead of re-rendering them.

fileSystemSubstrate bakes this in -- it builds the storage adapter and then builds the ledger over that very adapter, so a consumer never has to remember to wire it. "The ledger is the source of truth": a restart that re-opens the same directory resumes the prior memory, which is what makes "cost scales with surprise, not wall-clock time" survive a process restart.

The storage-only spread idiom

You may need to swap one field -- say a custom storage adapter -- while keeping the rest correct. The blessed override is a spread over the factory, never a hand-built record:

import { createReactor } from "@openprose/reactor";
import { fileSystemSubstrate } from "@openprose/reactor/adapters";

const r = createReactor({
  substrate: { ...fileSystemSubstrate({ directory }), storage: myStorage },
  topology,
  mounts,
});

Spreading the factory keeps the other three fields built-right. Supplying a divergent in-memory ledger alongside durable storage is the explicit opt-out, not an accident -- if you spread a different ledger over durable storage, you have deliberately broken restart-survival. The factory makes the correct path the short path; the override makes the unusual path visible.

Gateway ingress and the idempotency cursor

/adapters ships the toolkit for turning an external arrival into a receipt at the system's edge, without ever letting a re-delivered arrival manufacture a second one.

The connector is the one impure seam -- the actual I/O against a source:

import { createPollConnectorAdapter } from "@openprose/reactor/adapters";

// `fetch` performs the real read (HTTP GET, queue drain, file read). A test
// supplies a deterministic stub. Either way the adapter stays a pure function
// over injected I/O.
const connector = createPollConnectorAdapter((request) => fetchSource(request));

pollGateway (and its async sibling pollGatewayAsync) drives the edge. For each new arrival it stages the item into the gateway node's upstream truth, marks the cursor, then wakes the node:

import {
  pollGateway,
  createIdempotencyCursor,
} from "@openprose/reactor/adapters";

const cursor = createIdempotencyCursor();

const result = pollGateway(dag, {
  connector,
  source_id: "inbox",
  node: "gateway",
  extract: (payload) => toArrivals(payload), // -> readonly GatewayArrival[]
  cursor,
  stage: (arrival) => appendToInbox(arrival.item),
});

result.ingested_ids; // arrivals past the cursor that drove a wake this poll
result.skipped_ids;  // arrivals already in the cursor -- no wake, no receipt

The cursor is what dedups before the edge. Each GatewayArrival carries a stable id (a message id, an event id, a content hash) -- the same logical arrival yields the same id across polls and redeliveries. An already-seen (source, id) pair is dropped, so it never produces a second receipt. The order of arrivals is the delivery order, so the cursor advances monotonically.

The cursor is durable. It round-trips through the storage registry as a plain JSON-able snapshot, so a restart resumes without re-ingesting the backlog:

import {
  loadIdempotencyCursor,
  cursorRegistryPatch,
} from "@openprose/reactor/adapters";

// At boot: rehydrate from the registry the storage adapter persisted.
const cursor = loadIdempotencyCursor(storage.readRegistry());

// After a poll: project the cursor's snapshot into a registry patch to persist.
storage.writeRegistry({ ...storage.readRegistry(), ...cursorRegistryPatch(cursor) });

The staging order is deliberate -- stage, mark, then wake. Marking before the wake means a throw mid-render does not re-stage on the next poll: the arrival is durably consumed once it is staged, and the gateway's render re-runs against the staged truth on a later wake if it failed. A single payload that lists the same idempotency key twice is a source bug the cursor cannot disambiguate, so pollGateway fails loudly rather than silently ingesting one and dropping the other.

Record and replay

Two recording adapters make a run reproducible without a live provider -- the mechanism the keyless DevTools replay path rests on.

  • createRecordReplayModelGatewayAdapter({ records }) replays a captured sequence of model calls. Each invoke is matched against the next record's request (canonical-JSON equality); a mismatch throws with the record id, so a drifted run is caught at the exact diverging call rather than silently producing a different truth. The adapter exposes calls() and remaining() for inspection.
  • createPassthroughAgentSdkAdapter({ launch?, sandbox? }) is the agent-SDK passthrough that records every launch and sandbox run. Without handlers it echoes the request payload back; createNullAgentSdkAdapter(payload) is the inert variant that returns a fixed payload. Both fold the sandbox path in (the architecture folds sandbox execution into the agent-SDK port).

Every leaf adapter clones its request in and payload out through canonical JSON, so an adapter cannot be mutated by a caller and a payload is always a defensive copy.

The port contracts a backend implements

A custom backend implements one of the trimmed v1 ports. These are the contracts the harness is a pure function over -- the short names are the headline vocabulary Substrate uses; the Reactor*-prefixed originals are the deprecated aliases, kept reachable from both /adapters and /internals (nothing was removed).

Port (short name)Reactor* originalResponsibility
ClockAdapterReactorClockAdapterthe only time source -- now(): string
StorageAdapterReactorStorageAdapterthe receipt ledger's append-only trail plus the shrunk registry
WorldModelStoreReactorWorldModelStoreread-by-reference, commit-and-fingerprint, content-addressed versioning
--ReactorModelGatewayAdapterrender / compile-step invocation -- the model call seam
--ReactorConnectorAdapterexternal evidence sources (gateways)

A few contracts are worth stating plainly, because they are where a custom backend tends to drift:

  • StorageAdapter is append-and-list over receipts plus read/write of a shrunk registry. The registry is a dumb canonical-JSON key/value -- it carries the topology world-model and self-driven schedule as opaque blobs, nothing more. Keep it byte-stable across equal states (sorted, canonical) so the durable snapshot is deterministic.
  • WorldModelStore hands the render a queryable reference to a node's prior truth (never pre-stuffed into context), and commit produces the deterministic canonical serialization, content-addresses it, and returns the new version plus the canonicalizer-computed fingerprints. Only the published workspace is fingerprinted; the render's private workspace is not.
  • ReactorModelGatewayAdapter reports usage as a { provider, model, tokens } triple -- the cost-bearing half of the receipt. It does not know the wake source, so surprise_cause is supplied by the reconciler, not the gateway.

The reference backends for these ports also live on /adapters: createSystemClockAdapter / createFixedClockAdapter, createFileSystemStorageAdapter / createMemoryStorageAdapter, FileSystemWorldModelStore / InMemoryWorldModelStore (and their create* helpers), FileSystemReceiptLedger / InMemoryReceiptLedger, and the createStaticConnectorAdapter for an inert in-memory source.

Honest status: the v1 port surface is deliberately trimmed. The signer port is deferred to the crypto byte-hash milestone -- the only honest v1 signature is the null signature, so the receipt chain is tamper-evident and chain-consistent, not a cryptographic byte hash. eventSink is dropped on purpose: telemetry is read off the ledger, not a separate sink. sandbox is folded into the agent-SDK port. None of this is a gap to paper over; it is the surface as shipped.

Where to go next

On this page