Reaktor Service
Service is the graph's boundary communication layer. A phone, a worker, a server and a native library each run their own graph, and Service operations are how those graphs call each other. A Service is a typed operation contract: the common concept for invoking application logic, querying data, calling native code, publishing an event, opening a stream or submitting durable work. The contract does not own every execution mechanism. HTTP, local calls, FFI, Mesh, Bus, database adapters and workflow runtimes supply qualified bindings.
Revision: 1 October 2026. Contracts now carry an identity and a version, every handler has a descriptor with schema fingerprints, and every Cloudflare worker describes its own graph. Section 2 lists what exists.
Earlier revision: 11 September 2026. The current module implements typed unary handlers and HTTP integration. The broader interfaces below are a design proposal aligned with the Data Layer and unified system graph. A standalone Kotlin specimen provides the proposed types. It is not a published runtime API.
1. Responsibility and boundaries
A service groups named operations. Each has stable identity and revision, typed input/output/error schemas, an interaction protocol, effects and requirements. Implementation and binding are separate: the same logical operation can have several implementations and locations, subject to its behavioral contract.
| Concern | Owner |
|---|---|
| Operation contracts, typed handlers, context, protocol-level outcome | reaktor-service |
| Canonical identity/schema vocabulary and graph installation | reaktor-service contracts and descriptors; reaktor-graph-runtime installs them as ServiceNode ports |
| HTTP routing/client adaptation | Service and platform adapters |
| ABI, codecs, native handles, buffers, callbacks, affinity | reaktor-ffi |
| Topic/queue delivery, consumer acknowledgement and replay | Bus adapters |
| Durable history, scheduling, recovery and workflow execution | Work/workflow runtimes |
| Database transactions, queries and data consistency mechanisms | Data/store adapters and coordination runtimes |
| Room/session/media transport | Mesh and transport providers |
One concept does not require one giant interface containing SQL, native pointers, retry loops and media frames. Keep a small definition plus orthogonal callable protocols and execution policies. Most users write a typed handler and use generated bindings.
2. What exists in source today
| Surface | Current implementation boundary |
|---|---|
Service.kt | Service(baseUrl, httpClient) registers typed client/server handlers and interceptors |
RequestHandler.kt | Typed suspend invoke(request): response, serializers, endpoint metadata and route pattern |
Request.kt / Response.kt | Serializable DTOs with transient transport/context fields; status/header handling remains HTTP-shaped |
ServiceTransport.kt | HTTP, LOCAL, PEER, PUBSUB, QUEUE and WORKFLOW metadata; enum membership alone is not a dispatcher |
ServiceInterceptor.kt | Staged typed chains; the base invocation paths currently exercise application stages |
| Spring WebFlux / Cloudflare Hono adapters | Match routes, decode a request, invoke a handler and encode a complete response |
| Cloudflare service bindings | bindingHttpClient(name) gives any contract client a Ktor engine that sends through a service binding, so one worker calls another with the same typed client the phone uses |
| Durable Objects | durableObjectHttpClient(namespace) does the same for a Durable Object: the first path segment of the client's base URL names the object (durableObjectUrl("endpoint:42")). The object serves its contract through Hono inside itself, so an actor is called like any other service. The notification dispatcher is the first, as reaktor.notification-dispatcher |
| Call scope | Every handler runs inside a ServiceCall coroutine element holding the W3C trace context and the request's attributes (Cloudflare bindings, the verified caller). A call that stays inside the isolate inherits both |
| Tracing | TracePropagation sends traceparent on outgoing calls and SpanRecorder records one span per call it serves or makes. Workers and the Spring server store spans in ClickHouse service_spans; workers without a ClickHouse binding hand them to analytics' private SpanIntake entrypoint |
ServiceContract | Each contract names itself once, for example ServiceContract("bestbuds.circles"), with a version. Clients, servers and mocks inherit it |
| Operation ids | <RequestSerialName>.<property> when a handler is declared with by, so every implementation of a contract shares them. An explicit id given in the declaration is kept |
OperationDescriptor | Contract, version, operation, interaction, side (client or server), request and response schema with a fingerprint, and the HTTP method and route kept apart from identity |
ServiceNode in reaktor-graph-runtime | Registers each handler as a port of type op:<operation>:<request>-><response>, so wiring refuses a handler whose types differ. Calls through api() pass through the port's interceptors |
| Island shape | Every Cloudflare worker and the Spring server serve /_reaktor/shape to worker service tokens: the island's graph, contracts and operations, read by the Graph pane. A worker lists its Durable Object classes under objects, each an island of its own |
The existing path is unary and HTTP-oriented. It does not yet implement generalized bounded streams, duplex sessions, durable job handles, broker acknowledgement, or synchronous FFI contracts. The early FFI Invokable has a synchronous byte-array-to-Long path and a partial Flow<Long> path; it does not establish full Service parity. See FFI current state.
3. Contract, implementation, binding
An operation ID such as Document.Save survives a change in route, host or language. A schema revision identifies its typed payloads. A binding identifies transport, deployment, provider/configuration revision and execution policy. Activation IDs and runtime epochs remain separate from all three.
data class Operation<I, O, E>(
val id: Ref,
val revision: Revision,
val input: Schema<I>,
val output: Schema<O>,
val error: Schema<E>,
val effects: Set<Effect>,
val requirements: Set<Requirement>,
)
interface Contract<C> {
val id: Ref
val revision: Revision
val protocol: Ref
val requirements: Set<Requirement>
}
interface Binder {
fun <C> bind(contract: Contract<C>, choice: BindingChoice): BindingResult<C>
}
C retains the actual callable type through binding. A caller cannot attach arbitrary input/output types to an untyped endpoint name. Generated descriptors preserve all payload and terminal-error schemas for their protocol. Resolution returns a qualified binding or an unsupported result naming missing requirements. A binder must validate the protocol/schema/capability match before exposing a callable.
The specimen's Ref and Revision are dependency-free stand-ins for canonical kernel types, not a proposed parallel ID registry. Production descriptors use immutable snapshots and canonical serialization. Provider extensions are typed/versioned requirements; open extensibility does not mean silently accepting unknown capabilities.
4. Callable protocols
fun interface SyncCall<I, O, E> {
fun call(input: I, context: CallContext): Outcome<O, E>
}
fun interface AsyncCall<I, O, E> {
suspend fun call(input: I, context: CallContext): Outcome<O, E>
}
fun interface StreamCall<I, O, E> {
suspend fun open(input: I, resume: Cursor?, context: CallContext):
Outcome<Receiver<O, E>, E>
}
fun interface DuplexCall<Open, I, O, E> {
suspend fun open(input: Open, context: CallContext):
Outcome<DuplexSession<I, O, E>, E>
}
fun interface Publisher<T, E> {
suspend fun publish(event: Event<T>, context: CallContext):
Outcome<PublicationReceipt, E>
}
fun interface DurableCall<I, O, E> {
suspend fun submit(input: I, context: CallContext): Outcome<Job<O, E>, E>
}
Use these forms independently. Unary sync/async describes call behavior; publish/subscribe describes delivery; durable job describes a lifetime surviving the caller. A long-running HTTP request is not automatically a durable job. A Kotlin Flow is not automatically a replayable log or durable subscription.
Streams, upload, duplex and subscriptions
interface Receiver<T, E> {
suspend fun receive(maxItems: Int): Outcome<Received<T>, E>
suspend fun close(): Outcome<Unit, E>
}
interface Sender<T, E> {
suspend fun send(value: T): Outcome<Unit, E>
suspend fun finish(): Outcome<Unit, E>
}
interface DuplexSession<I, O, E> : Sender<I, E>, Receiver<O, E>
interface Subscription<T, E> : Receiver<Delivery<T>, E> {
suspend fun settle(token: Ref, disposition: Disposition): Outcome<Unit, E>
}
Opening can fail before a session exists. Receiving distinguishes a bounded batch, terminal end and typed/transport failure. Demand is bounded in items and by a declared byte/window limit; a provider cannot satisfy this with an unbounded hidden buffer. Sending waits for capacity. finish half-closes the sending side; close releases the whole receive/session scope. Uploads additionally expose a final result after input completion.
Adapters specify ordering, replay, resumability, maximum frame size, idle/deadline behavior, loss/conflation policy and terminal error mapping. Resume cursors are opaque, scoped to subscription/query/profile, schema and epoch; unsupported or expired resumption is explicit. A live cursor is not a persistence guarantee. Define whether concurrent sends/receives are allowed; the baseline serializes sends and receives independently while permitting one of each in duplex operation.
A subscription receives delivery tokens and can acknowledge, retry or dead-letter according to provider capabilities. Ack follows the consumer's declared durable processing boundary. send() success means the declared local/transport acceptance stage, not an application commit. Kotlin Flow, TypeScript async iterators and native observers can be generated ergonomic adapters over this lifecycle. They must preserve cancellation and bounded demand.
Durable jobs
interface Job<O, E> {
val id: Ref
suspend fun inspect(context: CallContext): Outcome<JobUpdate<O, E>, E>
suspend fun watch(resume: Cursor?, context: CallContext):
Outcome<Receiver<JobUpdate<O, E>, E>, E>
suspend fun cancel(context: CallContext): Outcome<CancelDisposition, E>
}
Submission returns only after the runtime has durably accepted the job under its contract. Repeated submission with the same valid idempotency key and request fingerprint resolves the same accepted job within the declared retention window. Handles are re-openable by job ID with fresh authorization; they are not process-local promises. The runtime needs history, versioning, timers, effect identities, retention and restart recovery.
Cancellation distinguishes requested, already terminal and unsupported. It does not reverse prior effects. Job progress carries a resumable cursor; terminal outcomes may contain partially applied/unknown effects. Compensation and reconciliation are explicit operations.
5. Context and outcomes
data class CallContext(
val invocation: InvocationId,
val authority: AuthorityRef,
val tenant: Ref,
val remainingBudgetMillis: Long,
val idempotency: IdempotencyKey? = null,
val causation: InvocationId? = null,
val trace: TraceContext? = null,
val consistency: ReadConsistency = ReadConsistency.Eventual,
)
sealed interface Outcome<out O, out E> {
data class Ok<O>(val value: O) : Outcome<O, Nothing>
data class Rejected<E>(val error: E, val effect: EffectStatus) : Outcome<Nothing, E>
data class Failed(val failure: Failure) : Outcome<Nothing, Nothing>
}
Today the context that travels is ServiceCall: the trace context and the inbound request's attributes, carried in the coroutine context and sent as traceparent across HTTP and service bindings. The fuller CallContext above is the target.
Context is typed and immutable. A receiving runtime validates the authority reference and tenant scope; client-provided context is not trusted. Propagate a decreasing deadline budget across hops, not raw process-local monotonic timestamps. Redact trace metadata; keep transport headers and secret credentials in their adapter scopes.
Success means the operation's declared postcondition. Domain rejection is separate from unavailable/deadline/cancelled/unauthorized/unsupported failures. Effect status is NOT_STARTED, APPLIED, PARTIALLY_APPLIED or UNKNOWN; do not turn a lost response into proof of no commit. A status lookup and stable effect ID are required for safe resolution of uncertain writes. Retryability depends on operation semantics and idempotency, not just an HTTP status or exception class.
Read consistency can request eventual, bounded staleness, at-least specified domain versions, an actual snapshot token or linearizability within a named domain. Unsupported requirements fail binding/invocation explicitly. The contract does not manufacture a global snapshot across unrelated stores.
6. Interceptors and execution policies
Keep synchronous and asynchronous interceptor chains distinct. A synchronous call cannot run an async auth lookup by secretly blocking a browser or hopping runtimes. Both can share pure policy logic and prepared validated context; their boundary behavior stays explicit. Stream/session interceptors wrap opening, frame/delivery processing and closing without re-running a whole session to retry one frame.
Policies include direct, serial actor, concurrent and thread-affine execution. Actor scheduling is useful for mutable state ownership, but is not mandatory for a pure function. A local direct binding can use ordinary calls without envelopes or queues. Remote bindings pay serialization and network cost; execution location is not semantically transparent where latency, failure, affinity or authority differs.
Retry ownership belongs at a declared boundary with one budget. Layered retries must not multiply attempts accidentally. Middleware cannot claim exactly-once effects without durable deduplication and the right transaction boundary. Cancellation releases resources but may race with an external commit; outcomes preserve uncertainty.
7. Binding families, including FFI
| Binding | Supported behavior and qualifications |
|---|---|
| Local | Direct sync or async; no forced serialization; normal ownership/context rules |
| HTTP / RPC | Unary and explicitly implemented streaming protocols; map status, disconnect, timeout and schema failures |
| FFI | Sync, async and streaming where target-qualified; native ownership, ABI, handle generations, affinity, callbacks and reentrancy |
| Bus / queue | Publication/subscription and delivery to handlers; ack/retry/DLQ semantics owned by the delivery runtime |
| Mesh | Session/duplex and media/data lifetimes; persistence is an explicit operation |
| Workflow / work | Durable submission and handle lifecycle where the runtime supplies durable acceptance/history |
FFI belongs under the same Service concept but remains a specialized module. A sync C++ method can bind to SyncCall and run inline if affinity permits. It can also have an explicitly generated async facade with appropriate scheduling and ownership. Async-to-sync adaptation requires an eligible runtime and explicit blocking policy; never promise it universally.
Borrowed buffers expire at return. Async work uses owned or retained memory through completion, including callback/cancellation races. Native handles carry runtime identity and generation; use-after-release or wrong-runtime access is rejected where possible. Native process faults cannot all be caught as typed errors. These constraints belong to the FFI contract, not business request DTOs.
8. Graph and data integration
Each compute host is an island with its own graph. A contract is the boundary between islands: the phone's client ServiceNode and the worker's server ServiceNode for the same contract carry the same operation ids and schema fingerprints, so a tool can join them and show one distributed application. A worker builds its island graph on its first request, because Workers forbid random identifiers at startup, and mounts its ServiceNodes in the order it declares them, because route matching follows registration order.
A trace follows a call across islands. The phone or a worker opens a span when it calls a contract and sends its id as traceparent; the island that serves the call records its span as a child. The Graph pane reads the spans from ClickHouse and lays them on the call wires, so a single request can be followed from the phone through every worker it touches.
Service definitions install into the main graph with the same IDs/schemas used by direct module consumers. Graph ports reflect interaction protocols and effects; a port type is richer than HTTP:GET:Health.Ping. The graph connects operations to logical data via Reads, Writes, Derives, Publishes, Subscribes and lifecycle relationships.
A query planner can inspect requirements and known dependencies, produce a bounded plan and bind its operations to SQL, native FFI or another qualified implementation. A backup/migration plan can identify affected operations. An opaque function must declare conservative effects/dependencies or remain opaque. Graph inspection is optional at invocation time; shared semantics are not optional identity duplication.
9. Migration and acceptance
Preserve existing typed handler ergonomics and generated client/server APIs. Introduce canonical operation descriptors behind GetHandler/PostHandler and current Request/Response facades; separate HTTP metadata from operation schemas. Keep existing routes and tests while adding direct/sync binding and typed failure/context mapping. Add stream, bus and durable protocols through their runtimes, without claiming support just because an enum value exists.
Ship one qualified binding pair per protocol before advertising broad portability. Test schema mismatch, target absence, sync affinity, borrowed/retained lifetime, bounded memory under slow consumers, stream cancellation/half-close, duplicate deliveries, ack loss, job restart, idempotent submission and unknown outcomes. Use actual platform integration tests for FFI; standalone Kotlin compilation cannot establish Kotlin/Native, JavaScript or ABI behavior.
The design stays elegant when the common API remains a typed operation and handler, and complexity is paid only by the protocol and runtime that needs it. Its strength comes from composable contracts with explicit limits, not from making every invocation look like an HTTP request.