Reaktor Graph Protocol

rgp/1 — the protocol a Reaktor runtime speaks about its own graph.

A Reaktor application is a graph, but never one graph in one process. The same system runs as an Android graph, an iOS graph, a browser graph, a desktop graph, a Worker and a JVM server, and those instances are usually built from different revisions of the same definitions. RGP is how a client attaches to all of them at once, reads their structure, watches them change, changes them, and keeps their versions distinguishable.

It is deliberately close in shape to the protocols that already solved parts of this problem — Chrome DevTools, LSP, DAP, the Kubernetes watch API — and §2 states exactly what is borrowed and what is refused.

1. Shape of the protocol

  client ──── transport ────► mesh ──┬── target: bestbuds-app @a91f3c  (android, activation ×2)
                                     ├── target: bestbuds-app @77bd02  (ios,     activation ×1)
                                     ├── target: chat-service @c04e18  (worker,  activation ×4)
                                     └── target: auth-service @5b2d91  (jvm,     activation ×1)

  one connection · many targets · one session per attached target

Four nouns:

NounDefinition
Meshthe set of runtimes reachable over one connection
Targetone graph instance — a (definition, revision, activation) triple
Sessionan attachment to one target; every command and event carries its sessionId
Handlean opaque reference to a node, scope, port or wire, resolvable on demand

Six domains, each independently enabled:

DomainOwns
Meshdiscovery, attach/detach, capability negotiation
Graphstructure: scopes, nodes, ports, edges, and their lifecycle events
Wirelive cross-runtime traffic: calls, credits, errors
Revisionwhich revisions are live, and how they differ
Editproposing and committing change
Valuereading values flowing through ports — separately granted

Transport is the Switchboard: frames on trunks. RGP is a set of cells like any other, so it inherits routing, credits and cancellation instead of restating them. It is not bound to that transport — §5 states the two properties any carrier must supply.

2. Prior art

Live introspection of a running program is a solved problem several times over. RGP is assembled out of those solutions rather than invented.

ProtocolTakenRefused, and why
Chrome DevTools ProtocolDomain namespacing; per-domain enable/disable; the target/session model (Target.attachToTarget with flattened sessionId); handle-based lazy expansion (RemoteObjectId + explicit release)Untyped JSON with no schema negotiation; no flow control at all; effectively single-client; a security model that is "trust localhost"
LSPCapability negotiation at initialize; incremental document sync; partial-result and progress tokens for long answers; pull diagnostics rather than unconditional pushA document-centric model with no concept of a running instance, and no notion of two versions of the same workspace being live at once
DAPReference-based expansion — variablesReference is a promise of more detail, fetched only if the client opens the rowStop-the-world debugging. Attaching must never change the timing of what you attached to
Kubernetes watchList-then-watch with a cursor: a read returns a version, a watch resumes from exactly that version, and an expired cursor is a hard error rather than a silent gap. Bookmarks to keep idle cursors aliveA central API server and a single consistent store. Reaktor's facts live with their owners
Erlang tracing / match specsFilter at the producer. A subscription carries a selector that is evaluated inside the target, so unwanted events never reach the wireBEAM-specific, schema-free, and trivially able to melt a production node
OpenTelemetryResource identity. OTel already standardised the exact triple this protocol needs — service.name, service.version, service.instance.id — and RGP's definition / revision / activation map onto it one-to-oneExport-only. No query, no attach, no mutation, and no way to ask a running process what it currently is
GraphQL subscriptionsClient-shaped payloads: the request determines what comes backA closed schema. Reaktor's node and port vocabulary is open and module-extensible by design
gdb remote serial—Halt semantics and one target per connection

What none of them do. Every protocol above attaches to one running thing: one browser, one workspace, one process, one cluster. None models one logical system whose parts are simultaneously live at different revisions — a Worker on rev 7, a shipped Android build on rev 5, an iOS build on rev 6. That is the normal state of a deployed Reaktor system, and §9 is the part of RGP with no direct ancestor.

3. Identity

Everything else rests on this, and getting it wrong is how a tool like this starts lying quietly.

Node UUIDs are not identity. Graph mints Uuid.random() per process, so two reads across a reconnect share no ids. Anything keyed on them redraws a new graph every time a socket blips.

/** A semantic declaration: "the chat service". Survives renames, moves, rebuilds. */
value class DefinitionId(val value: String)          // "chat-service"

/** One exact content of a definition. Derived — see below. */
value class RevisionDigest(val value: String)        // "a91f3c…", 32 hex chars

/** One running instance. Has a host, a session and an epoch; survives nothing. */
data class ActivationRef(
    val host: String,        // device id, worker colo, pod name
    val session: String,     // process/isolate instance
    val epoch: Long,         // monotonic; a late frame from epoch n-1 has no authority
)

A target id is the addressable form: TargetId = definition@revision#activation.

The revision digest is derived, not declared

RevisionDigest = H( sorted( cellName ‖ methodId ‖ schemaFingerprint )* )

Every input already exists — HELLO carries cell descriptors with a schema fingerprint per method. Nothing new is computed and nothing is stamped by a build step.

This is the load-bearing choice in the whole protocol. A declared build id can be forgotten, reused, or faked; a derived digest cannot. Two runtimes reporting the same digest expose the same contract, and no amount of configuration can persuade the protocol otherwise. Contract skew is therefore detected by construction rather than by discipline.

:::caution Corrected 13 September 2026 — a contract digest does not identify an implementation The digest hashes cell names, method ids and schema fingerprints. A bug fix changes handler behaviour without changing any of those inputs, so two builds with identical public schemas produce the same digest. Treating that digest as a revision identity would group an old and a new binary together and could resolve a historical trace against the wrong implementation.

Five identities stay separate:

IdentityDerived fromAnswers
Semantic definitionDefinitionIdwhich component is this
Contract fingerprintthe digest abovecan these two talk to each other
Built artifactbuild output hash, commit sha, artifact referenceis this the same implementation
Configurationconfig revisionsame code, different behaviour
ActivationActivationRefwhich running instance

The contract fingerprint drives compatibility checks. An immutable artifact identity drives implementation comparison. Where build identity is unavailable it stays unknown, and is never substituted with the contract digest.

Acceptance: two builds with identical public schemas and different handler behaviour must report as compatible and as distinct implementations. :::

4. Targets and sessions

enum class RuntimeKind { Android, Ios, Browser, Desktop, Worker, Jvm, Native, Hermes }

enum class TargetCapability {
    Describe,        // Graph domain readable
    Subscribe,       // Graph/Wire events available
    Wires,           // this runtime originates or terminates cross-runtime calls
    Edit,            // Edit domain available (may still refuse every patch)
    Values,          // Value domain available, subject to its own grant
    Activate,        // runtime-target patches are possible here
}

data class Target(
    val targetId: TargetId,
    val definition: DefinitionId,
    val revision: RevisionDigest,
    val activation: ActivationRef,
    val runtime: RuntimeKind,
    val channel: Channel?,               // stable | canary | pinned — see §9
    val capabilities: Set<TargetCapability>,
    val attachedAt: Instant,             // when the mesh first saw this activation
    val metadata: Map<String, String>,   // buildId, commit, artifact, region — never identity
)

A session is opened per target. Every frame after attach carries sessionId, so one connection multiplexes every runtime — CDP's flattened session mode, for the same reason: a client that has to open one socket per target cannot correlate anything across them.

Sessions are independent. Enabling Graph on one target does not enable it anywhere else, and a target that dies takes only its own session with it.

5. Framing and payloads

RGP rides Switchboard frames. The protocol needs exactly two properties from any carrier, and a carrier that lacks either is not usable:

  1. Ordered, independently-flow-controlled streams. A slow subscription must not head-of-line-block a fast command.
  2. Credit-based backpressure, with request(n) semantics.

Message shapes

sealed interface RgpMessage { val session: SessionId? }

data class Command(
    override val session: SessionId?,
    val id: CommandId,            // unique per connection; correlates the reply
    val method: Method,           // "Graph.describe" — interned to a u16 at HELLO
    val params: Payload,
) : RgpMessage

data class Reply(
    override val session: SessionId?,
    val id: CommandId,
    val result: Payload,
) : RgpMessage

data class Failure(
    override val session: SessionId?,
    val id: CommandId?,           // null for a stream-level failure
    val error: RgpError,
) : RgpMessage

data class Event(
    override val session: SessionId?,
    val method: Method,           // "Graph.nodeAttached"
    val cursor: Cursor,           // monotonic within a session — see §7
    val params: Payload,
) : RgpMessage

Encoding

A Payload is a FlexBuffer. Two codecs, selected per method at handshake:

CodecWhenCost
Positionalschema fingerprints match on both sidesfastest; fields by index, no key strings
Keyedfingerprints differ but the shapes bridgelarger; fields by name, missing fields defaulted

Positional decode never runs unverified. A fingerprint mismatch either downgrades that method to the keyed codec or refuses it outright. Decoding a foreign layout by position is a memory-safety bug, not a compatibility strategy, and this rule has to hold from the first implementation rather than be retrofitted once several trunks exist.

Handles

value class NodeHandle(val value: Long)
value class ScopeHandle(val value: Long)
value class PortHandle(val value: Long)
value class WireHandle(val value: Long)

Handles are minted by the target, opaque to the client, and valid for the session. They exist so that Graph.describe can answer with summaries and let the client pull detail only for what it actually renders — the variablesReference idea from DAP, and the only reason a graph with twenty thousand nodes is tractable over a wire.

Graph.release(handles) returns them. A session close releases everything.

6. Domain: Mesh

interface MeshDomain {
    /** First message. Negotiates protocol version and capabilities both ways. */
    suspend fun hello(params: HelloParams): HelloResult

    /** Current targets. Also the point at which a client learns about revision skew. */
    suspend fun listTargets(filter: TargetFilter = TargetFilter.All): List<Target>

    /** Open a session. Fails if the target died between listing and attaching. */
    suspend fun attach(targetId: TargetId): SessionId
    suspend fun detach(session: SessionId)

    /** Auto-attach to targets as they appear; how a client follows a rolling deploy. */
    suspend fun setAutoAttach(enabled: Boolean, filter: TargetFilter)
}

data class HelloParams(
    val protocol: String,                  // "rgp/1"
    val client: ClientIdentity,            // name, version, principal
    val supports: Set<Method>,             // methods the client can handle as events
    val maxFrameBytes: Int,
    val defaultCredit: Int,
)

data class HelloResult(
    val protocol: String,
    val mesh: MeshIdentity,                // environment name; meshes never span environments
    val supports: Set<Method>,
    val grants: Set<Grant>,                // Read | Subscribe | Edit | Values | Activate
    val limits: Limits,                    // max subscriptions, max in-flight, max describe depth
)

Events: Mesh.targetCreated, Mesh.targetDestroyed, Mesh.targetInfoChanged.

Capability negotiation is taken from LSP rather than CDP: a client states what it can receive, the mesh states what it will serve, and neither guesses. A client that does not understand Wire.wireFrame never receives one.

7. Domain: Graph

Structure, and the events that change it.

interface GraphDomain {
    suspend fun enable(params: EnableParams): EnableResult
    suspend fun disable()

    /** Structure of one scope. The unit of reading. */
    suspend fun describe(params: DescribeParams): GraphSlice

    /** Full detail for handles the client decided to render. */
    suspend fun resolve(handles: List<NodeHandle>): List<NodeDetail>

    /** Structural query, evaluated inside the target. */
    suspend fun search(selector: GraphSelector, limit: Int): List<NodeSummary>

    suspend fun release(handles: List<Long>)
}

data class DescribeParams(
    val scope: ScopeHandle? = null,                       // null = root
    val level: ArchitectureLevel = Container,             // System | Container | Component | Code
    val depth: Int = 1,                                   // child scopes to expand inline
    val includePorts: Boolean = true,
)

data class GraphSlice(
    val scope: ScopeSummary,
    val children: List<ScopeSummary>,     // collapsed boundaries, not contents
    val nodes: List<NodeSummary>,
    val edges: List<EdgeSummary>,
    val cursor: Cursor,                   // exactly the point this slice was taken at
    val coverage: Coverage,               // what was omitted, and why
)

data class NodeSummary(
    val handle: NodeHandle,
    val key: NodeKey,                     // stable: scopePath + declared label + kind
    val label: String,
    val kind: NodeKind,                   // open vocabulary
    val scope: ScopeHandle,
    val providerCount: Int,
    val consumerCount: Int,
    val unconnectedConsumers: Int,        // a dangling dependency, visible without expanding
    val status: NodeStatus?,
)

data class NodeDetail(
    val summary: NodeSummary,
    val ports: List<PortDetail>,
    val declaration: SourceRef?,          // file and line, when the owner can prove it
    val attributes: Map<String, String>,
    val provenance: Provenance,
)

data class PortDetail(
    val handle: PortHandle,
    val key: String,                      // service operation key where applicable
    val type: String,
    val direction: Direction,             // Consumer | Provider
    val connected: Boolean,
    val remote: TargetId?,                // set when the edge leaves this runtime
)

NodeKey — scope path plus declared label plus kind — is what a client stores. Handles are per-session and change on reconnect; keys do not. This is what makes a reconnect refresh a graph rather than replace it.

List-then-watch

Taken from the Kubernetes watch API, because the alternative is a protocol that silently drops events and nobody notices for a year.

  1. describe returns a GraphSlice and the cursor it was taken at.
  2. enable(fromCursor) streams every event after that cursor. No gap, no overlap.
  3. If the target can no longer replay from that cursor, enable fails with CursorExpired and the client must re-describe. A gap is an error, never a silent continuation.
  4. Graph.cursorAdvanced is emitted periodically on an idle stream so a quiet client's cursor does not age out — Kubernetes bookmarks, same purpose.

Selectors are evaluated in the target

data class GraphSelector(
    val scopes: Set<ScopeHandle> = emptySet(),
    val includeDescendants: Boolean = true,
    val kinds: Set<NodeKind> = emptySet(),
    val events: Set<Method> = emptySet(),
    val keyPattern: String? = null,
)

Filtering at the sink is the mistake Erlang's tracing facility exists to avoid: the cost of an unwanted event is paid in serialisation and wire bytes long before the client discards it. A selector travels with enable and is applied inside the target, before a frame is built.

Event vocabulary

(Moved here from Component Lifecycle §7, which links back. The mapping onto today's Graph/Node code stays there — that is a lifecycle-implementation concern. This is the wire form, and it is normative.)

sealed interface GraphLifecycleEvent : ReaktorLifecycleEvent.Graph {
    data class GraphCreated(val graphId: String) : GraphLifecycleEvent
    data class GraphRestored(val graphId: String, val snapshotId: String?) : GraphLifecycleEvent
    data class GraphSaving(val graphId: String, val reason: Reason) : GraphLifecycleEvent
    data class GraphDestroying(val graphId: String, val reason: Reason) : GraphLifecycleEvent

    data class NodeAttaching(val graphId: String, val nodeId: String) : GraphLifecycleEvent
    data class NodeAttached(val graphId: String, val nodeId: String) : GraphLifecycleEvent
    data class NodeDetaching(val graphId: String, val nodeId: String, val reason: Reason) : GraphLifecycleEvent
    data class NodeDetached(val graphId: String, val nodeId: String, val reason: Reason) : GraphLifecycleEvent

    data class GraphActivated(val graphId: String, val reason: ActivationReason) : GraphLifecycleEvent
    data class GraphDeactivated(val graphId: String, val reason: ActivationReason) : GraphLifecycleEvent
    data class NodeActivated(val graphId: String, val nodeId: String, val reason: ActivationReason) : GraphLifecycleEvent
    data class NodeDeactivated(val graphId: String, val nodeId: String, val reason: ActivationReason) : GraphLifecycleEvent

    data class PortCreated(val graphId: String, val ownerId: String, val port: PortRef) : GraphLifecycleEvent
    data class PortConnected(val graphId: String, val edgeId: String, val consumer: PortRef, val provider: PortRef) : GraphLifecycleEvent
    data class PortDisconnected(val graphId: String, val edgeId: String?, val consumer: PortRef, val provider: PortRef) : GraphLifecycleEvent
}

On the wire these become Graph.nodeAttached, Graph.portConnected and so on, each carrying a cursor and the handles of everything it names.

Graph.instrument() in reaktor-telemetry already produces this material — it collects graph.lifecycle, backStack.top and node.addPortEventListener, and recurses into ContainerNode.graphs, with no change to Graph.kt or Node.kt. The producer largely exists; what is missing is somewhere to send it.

Cross-runtime boundaries

(Moved here from Hierarchical Graph UI §7, which links back.)

A ServiceAPI contract can be implemented locally, across HTTP, through a Worker, by an actor adapter or through FFI. A client declaration, provider declaration and deployed binding are distinct entities in one graph. Matching contracts identify candidates; a selected binding or a qualified observation establishes the actual route. Equal operation names or schema hashes do not prove two resources are the same service.

Resolving a boundary port must reveal:

  • exact consumer/provider ports and source declarations;
  • schema/version compatibility and explicit field mappings;
  • interaction shape, transport, target/environment and selected binding;
  • authority/grants and the actual mediation point;
  • deadline, cancellation, failure, retry and completion rules;
  • memory ownership, buffer lifetime, copies/pinning and thread/isolate rules for FFI;
  • artifact/activation correspondence, observations and coverage;
  • supported composition, validation, profiling and deployment commands.

A line drawn through a native boundary cannot promise zero-copy, cancellation propagation or memory safety unless the adapter and the evidence establish it.

PortDetail.remote carries the candidate TargetId derived from the service operation key — <request serialName>.<property>, computed identically on both sides. It is a naming convention that identifies a candidate. Only Wire (§8) can establish that the route is real.

8. Domain: Wire

Live cross-runtime traffic. This is the domain that justifies a protocol rather than a periodic export — nothing reconstructible from a snapshot appears here.

interface WireDomain {
    suspend fun enable(selector: WireSelector, sampling: Sampling): EnableResult
    suspend fun disable()
    suspend fun describe(handle: WireHandle): WireDetail
}

data class WireSelector(
    val ports: Set<PortHandle> = emptySet(),
    val peers: Set<TargetId> = emptySet(),
    val minDurationMs: Int? = null,       // only report calls slower than this
    val errorsOnly: Boolean = false,
)

enum class Sampling { All, Errors, Sampled }    // Sampled: head-based, rate in EnableResult

data class WireDetail(
    val handle: WireHandle,
    val method: String,                   // service operation key
    val shape: Shape,                     // Unary | ServerStream | ClientStream | Bidi
    val consumer: Endpoint,               // target + port
    val provider: Endpoint,
    val compatibility: Verdict,           // §9 — computed from schema fingerprints
    val openedAt: Instant,
    val frames: Long,
    val credit: CreditState,              // granted, outstanding, stalledForMs
    val outcome: Outcome?,                // Completed | Cancelled | Deadline | Error(category, code)
)

Events: Wire.opened, Wire.credit, Wire.closed. Wire.frame exists but is gated on the Values grant, because a frame body is user data — see §11.

A stalled wire is the point. CreditState.stalledForMs makes visible the one failure mode that no log line and no snapshot will ever show you: a consumer that stopped requesting, an emitter parked at zero credit, and a feature that appears simply to have stopped working.

9. Domain: Revision

The part with no direct ancestor in the protocols of §2.

A system rarely runs one revision. The Worker deployed this morning; the Android build in the store was compiled six weeks ago; an iOS build sits between them; a developer's desktop is ahead of all three.

One definition with N live revisions presents as N targets, grouped, never merged. Merging would synthesise a contract that no activation actually serves, and every question asked of the merged view would have an answer that is true of nothing.

  ▸ chat-service                       ← definition
    ├── @a91f3c  rev 7 · stable · 4 activations · first seen 2h ago
    ├── @77bd02  rev 5 · legacy · 2 activations · last seen 3m ago
    └── @c04e18  rev 8 · canary · 1 activation  · desktop dev build
interface RevisionDomain {
    suspend fun list(definition: DefinitionId): List<RevisionSummary>
    suspend fun compare(a: RevisionDigest, b: RevisionDigest): RevisionDiff
    suspend fun compatibility(consumer: RevisionDigest, provider: RevisionDigest): CompatibilityReport
    /** Which live consumer revisions still bind a method only `revision` provides. */
    suspend fun retirementBlockers(revision: RevisionDigest): List<Blocker>
}

data class RevisionSummary(
    val digest: RevisionDigest,
    val definition: DefinitionId,
    val activations: Int,
    val channel: Channel?,
    val firstSeen: Instant,
    val lastSeen: Instant,
    val ghost: Boolean,               // no live activation; retained for the ghost window
    val metadata: Map<String, String>,
)

Compatibility is computed

Per method, for a consumer on X calling a provider on Y:

VerdictMeaningWire behaviour
Exactidentical fingerprintpositional codec
Compatiblediffers, bridgeable without losskeyed codec
Degradedbridgeable with loss — a field defaulted or droppedkeyed codec, recorded per call
Incompatibleno bridge existsrefused; ERROR category codec
Absentthe method is not in the provider revisionrefused; ERROR category route

No new mechanism is required. This is the existing fingerprint rule from FFI §5 applied along a second axis: across revisions instead of across languages. A cross-revision edge carries its worst per-method verdict, so a red edge means a shipped client is calling a method whose schema it does not share — the defect that is otherwise invisible until a user hits it.

Channels

A wire opens against (cell, channel); the mesh resolves the channel to an activation.

ChannelResolves to
stablethe revision marked stable for this definition
canarythe newest revision marked canary, if any
pinned:<digest>exactly that revision, or fail

A client that must keep working asks for stable and never names a digest. A client reproducing a bug pins. Nothing resolves across meshes: environments are separate meshes, and there is no routing between them. That constraint is what prevents the false join in which a laptop appears to be calling production.

Retirement as a query

The set of contracts that must keep working is derived: the union, over every live consumer revision, of the methods it binds. So a revision can be retired when retirementBlockers returns empty, and until then these are answerable rather than guessed at:

  • What still calls v1? — consumers by revision, with activation counts.
  • How long until v1 can go? — remaining activations and last-seen times.
  • What breaks if I delete this method? — every consumer revision whose verdict becomes Absent.
  • What is degraded right now? — every live wire bridging with loss.

Ghosts, and the diff

A revision whose last activation disappears becomes a ghost: retained for a window so "what did this look like an hour ago" is answerable, and marked so a ghost is never mistaken for something running.

Because revisions are sibling targets, Revision.compare is a structural diff of two graphs with stable NodeKeys on both sides. This closes what the atlas review recorded as the largest omission in the graph section: version diffing was never attempted, and a semantic graph with stable element ids is exactly the substrate that makes it work.

Revision is an axis, not a zoom level. "Show me Container level" and "show me revision 7" are independent questions, and the scope/layer/time/mode separation gains revision as a fifth independent dimension rather than folding it into any existing one.

10. Domain: Edit

Reads and writes are not symmetric, and collapsing them into one generic mutation surface is the most damaging thing this protocol could do. A read is idempotent and cheap. A write changes a running system.

interface EditDomain {
    /** Validates, computes effects, returns a plan. Changes nothing. */
    suspend fun prepare(patches: List<GraphPatch>): Plan

    /** Commits a prepared plan under an authority reference. */
    suspend fun execute(plan: PlanRef, authority: AuthorityRef): ExecutionReceipt

    suspend fun cancel(plan: PlanRef)
}

The mutation value is the existing GraphPatch from the substrate contract, carried unchanged:

data class GraphPatch(
    val id: String,
    val base: Revision,                             // what it was computed against
    val preconditions: List<EntityPrecondition>,    // expected digests — optimistic concurrency
    val operations: List<PatchOp>,
    val sourceAuthorityRef: String,
    val target: WriteTarget,                        // added by rgp/1 — see below
)

PatchOp is sealed and stays sealed. There is no setProperty(nodeId, key, value); a generic setter bypasses typing, effect requirements and ownership in one call, and every guarantee in this section evaporates the moment one exists.

Three write targets

WriteTarget is the field rgp/1 adds, and it exists because most of the graph is derived from Kotlin source. Mutating a live graph does not change the source, so the next build erases it — and a tool that forgets this is a tool that lies.

TargetChangesUndone byIf confused
Documentthe persisted GraphDocumentundo / discardedits look applied; nothing runs differently
SourceKotlin, via an adapter that understands the constructgita lossy regenerate destroys hand-written code
Runtimea live activation — wire a port, restart an actornothing; it already happenedan "experiment" is a production incident

A patch that cannot name its target is refused at prepare. Graph Sources states this as a law; this is where it is enforced in code rather than in prose.

Prepare is not execute

prepare returns a Plan — patches, commands, required effects, required evidence, and an expiry — and changes nothing. execute takes a PlanRef and an authority reference. A plan that expires is re-prepared against the current revision, never resurrected.

Every editing surface goes through the same path. GraphCommand's own documentation already states the doctrine — renderers translate gestures into commands and never touch the document directly, so every editing surface gets identical semantics — and Edit is that path made remote. A client's change is undone by the same undo that reverses a drag, and lands in the same audit record.

Concurrent writers are expected. Two clients patching the same port collide on preconditions and surface as Edit.conflict, never as a silent last-writer-wins.

Events: Edit.planPrepared, Edit.planExpired, Edit.committed, Edit.conflict.

11. Domain: Value

Reading the values moving through a port, or an actor's current state.

Separated into its own domain and its own grant for one reason: descriptors are not payloads. Graph.describe returns shapes, types and fingerprints. Value returns user data. A debugging tool that streams a production user's messages to a laptop is a breach with a progress bar, and the only structural defence is that the capability can be withheld wholesale rather than argued about per call.

interface ValueDomain {
    suspend fun read(port: PortHandle): ValueSnapshot     // redacted per policy
    suspend fun watch(port: PortHandle, selector: ValueSelector): Subscription
}

Redaction policy is applied in the target, before encoding. It is not yet specified — see §16.

12. Errors

data class RgpError(
    val code: Int,
    val category: Category,
    val message: String,
    val detail: Payload?,
)

enum class Category {
    Protocol,     // malformed frame, unknown method, bad session
    Route,        // no such target, cell or method — includes revision `Absent`
    Codec,        // fingerprint mismatch with no bridge
    Cursor,       // expired; the client must re-describe
    Authority,    // grant missing or insufficient for this wire
    Precondition, // an Edit precondition failed — a conflict, not a crash
    Target,       // the target failed or died mid-command
    Overload,     // the target refused to serve; retry with backoff
}

Errors are values. An exception never unwinds across a boundary; it is encoded, and re-raised idiomatically on the far side. Overload is a first-class outcome rather than a timeout, so a target under pressure can shed a client deliberately instead of degrading silently.

13. Cost, and the rule that protects the target

Attaching must not change what you attached to. Two hard rules:

  1. The producer never blocks on a subscriber. Buffer policy is per-subscription — conflate for state, drop-oldest for events — and when credit is exhausted the target drops. It does not suspend graph execution to wait for a socket. A tool that perturbs the timing of the thing it inspects cannot be used to debug timing.
  2. Every drop is reported. A subscription carries droppedSinceCursor, so a gap in the record is visible as a gap. Silence must never be indistinguishable from quiescence.

Declared costs, so a target can enforce them: Graph.describe is bounded by depth and a server-side node cap, with truncation reported in Coverage. Wire at Sampling.All is the expensive mode and a target may refuse it and answer Sampled instead. Limits are published in HelloResult.limits rather than discovered by hitting them.

14. What the renderer receives

Presentation is specified in Hierarchical Graph UI and Visual Language. RGP's obligation is to supply what those need without the renderer reconstructing it:

Renderer needsSupplied by
Stable ids and a parent chainNodeKey, ScopeSummary.parent
Architecture levelsDescribeParams.level — a server parameter, not a client filter
LivenessMesh.targetCreated / targetDestroyed; presence is observed, never declared
TrafficWire.opened / closed with shape, credit, duration, outcome
Provenance and freshnessProvenance and Coverage on every read
Version laneRevision.list — live revisions, activation counts, ghosts
Compatibilityper-edge Verdict
Findingstyped RgpError plus Finding from Edit.prepare

(The FFI-facing node and edge kinds moved here from FFI §10, which links back.)

Graph nodeMeaning
FfiRuntimea switch's host runtime (Kotlin, C++, Hermes instance, Python, remote)
FfiModulea cell — a typed service on a switch
FfiFunctiona method with schema fingerprint, shape and capability facets
FfiBindinga trunk endpoint (JNI method, cinterop symbol, JSI install, C-ABI attach, gRPC peer)
HermesBundlesigned JS artifact registering cells
FfiCallan observed wire — shape, frames, credits, duration, error
FfiFindingfingerprint mismatch, missing cell, credit stall, unversioned ABI, decode failure

Edges (provides, exports, binds, calls, loads, observedBy) and capability facets (NativeAvailable, HermesAvailable, SyncOnly, StreamCapable, StubTarget, UnversionedAbi, UnsafeMemoryBoundary, Experimental) carry over unchanged.

The ProxyNode — a ProviderPort<T> in Kotlin feeding a ConsumerPort<T> in C++ — is graph-facing sugar over cells: a port pair whose edge is implemented by a wire. It is a projection, not a mechanism, and arrives after the mechanism.

15. Security

RGP publishes an entire architecture over a socket and its write half changes running systems. CDP's security model — trust whatever connects to localhost — is not available here, because the transport is a network.

  1. Authenticate at Mesh.hello. A client without a principal receives no targets — not a reduced list, none.
  2. Meshes are environment-scoped. A production mesh is not a default binding, and there is no route between meshes.
  3. Grants are per session and per domain. Read never implies Subscribe; Subscribe never implies Values; nothing implies Edit. A read wire is not upgradeable in place.
  4. Values is separately granted and separately audited (§11).
  5. Every Edit.execute is attributable. The receipt names the principal, the plan and the resulting revision.

16. Protocol versioning and conformance

The protocol's own version (rgp/1) is distinct from the graph revisions it describes. Mesh.hello negotiates it; a mismatch on the major version is a hard failure, and unknown methods within a matching major are ignorable rather than fatal, so a domain can be added without breaking older clients.

A conforming target must:

  • answer describe at every level it advertises, with honest Coverage;
  • produce a cursor from describe that enable accepts, and fail CursorExpired rather than skip;
  • apply selectors before encoding, provably, under a load test;
  • never suspend graph execution on subscriber backpressure;
  • refuse positional decode on a fingerprint mismatch;
  • refuse a patch with no WriteTarget;
  • report the same RevisionDigest from two activations of one build, and different digests from two different builds.

The last one is the acceptance test for the entire version model, and it is checkable in CI with two builds and no infrastructure.

17. Implementation order

  1. Frame codec and message shapes over FlexBuffersBuilder / Vector — commands, replies, events, cursors, credits. Everything is blocked on this.
  2. Transport: loopback, plus WebSocket over a Durable Object room. reaktor-io already ships WebSocket.send(ByteArray) and PartySocket, and reaktor-cloudflare already depends on partyserver. A Worker's switch is hosted in the Durable Object — workerd isolates are per-request, so a switch in a stateless handler dies before anyone can attach to it.
  3. Mesh + Graph read path: targets, sessions, describe, handles, resolve.
  4. Graph event path: cursors, selectors, credits, sourced from the existing Graph.instrument() taps.
  5. Revision: digests, grouping, verdicts, ghosts, compare.
  6. Wire: the live traffic view.
  7. Edit over the existing GraphEditorSession and ChangeProtocol.
  8. Value, once a redaction policy exists.

Steps 1–4 produce a live structural view across desktop, Workers, servers and web. Step 5 makes it honest about deployment. Steps 6–7 make it a debugger and an editor.

Specify Edit's payloads during step 1 even though it is built at step 7. A read-only protocol that ships alone will fix a node identity model, a scope model and a coverage semantics that the write path then spends its life fighting.

18. Open decisions

  • Ghost retention window — per definition or global, and whether ghosts survive a client disconnect.
  • Digest inputs. Cell descriptors are certain. Whether node-level structure belongs in the digest is not: including it makes every code change a new revision, which may be correct or may be unusable noise.
  • Cross-mesh comparison. Environments are separate meshes with no routing, but a human legitimately wants to diff staging against production. A read-only federation is probably needed and must not become a routing path.
  • Redaction policy for Value — unspecified, and the domain should not ship without it.
  • Conflict resolution. Edit.conflict surfaces the collision; the resolution rule is still open.
  • Sampling strategy for Wire — head-based is simple and biased; tail-based needs buffering in the target.
DocRelationship
Living GraphThe identity and provenance laws this protocol implements
Reaktor FFI — SwitchboardThe transport: frames, cells, switches, trunks, wires
Graph Substrate RedesignGraphPatch, ChangeProtocol, the typed record model
Hierarchical Graph UIHow the result is presented and navigated
Graph SourcesNon-native facts, and what provenance does not grant
Component LifecycleWhere graph events are produced in-process
Reaktor ServiceOperation keys, transports, the client/server contract
Agentic ToolingOne client of the Edit domain
Formal MethodsPatches as a transition system; findings as a return edge