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:
| Noun | Definition |
|---|---|
| Mesh | the set of runtimes reachable over one connection |
| Target | one graph instance — a (definition, revision, activation) triple |
| Session | an attachment to one target; every command and event carries its sessionId |
| Handle | an opaque reference to a node, scope, port or wire, resolvable on demand |
Six domains, each independently enabled:
| Domain | Owns |
|---|---|
Mesh | discovery, attach/detach, capability negotiation |
Graph | structure: scopes, nodes, ports, edges, and their lifecycle events |
Wire | live cross-runtime traffic: calls, credits, errors |
Revision | which revisions are live, and how they differ |
Edit | proposing and committing change |
Value | reading 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.
| Protocol | Taken | Refused, and why |
|---|---|---|
| Chrome DevTools Protocol | Domain 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" |
| LSP | Capability negotiation at initialize; incremental document sync; partial-result and progress tokens for long answers; pull diagnostics rather than unconditional push | A document-centric model with no concept of a running instance, and no notion of two versions of the same workspace being live at once |
| DAP | Reference-based expansion — variablesReference is a promise of more detail, fetched only if the client opens the row | Stop-the-world debugging. Attaching must never change the timing of what you attached to |
| Kubernetes watch | List-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 alive | A central API server and a single consistent store. Reaktor's facts live with their owners |
| Erlang tracing / match specs | Filter at the producer. A subscription carries a selector that is evaluated inside the target, so unwanted events never reach the wire | BEAM-specific, schema-free, and trivially able to melt a production node |
| OpenTelemetry | Resource 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-one | Export-only. No query, no attach, no mutation, and no way to ask a running process what it currently is |
| GraphQL subscriptions | Client-shaped payloads: the request determines what comes back | A 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:
| Identity | Derived from | Answers |
|---|---|---|
| Semantic definition | DefinitionId | which component is this |
| Contract fingerprint | the digest above | can these two talk to each other |
| Built artifact | build output hash, commit sha, artifact reference | is this the same implementation |
| Configuration | config revision | same code, different behaviour |
| Activation | ActivationRef | which 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:
- Ordered, independently-flow-controlled streams. A slow subscription must not head-of-line-block a fast command.
- 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:
| Codec | When | Cost |
|---|---|---|
| Positional | schema fingerprints match on both sides | fastest; fields by index, no key strings |
| Keyed | fingerprints differ but the shapes bridge | larger; 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.
describereturns aGraphSliceand thecursorit was taken at.enable(fromCursor)streams every event after that cursor. No gap, no overlap.- If the target can no longer replay from that cursor,
enablefails withCursorExpiredand the client must re-describe. A gap is an error, never a silent continuation. Graph.cursorAdvancedis 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:
| Verdict | Meaning | Wire behaviour |
|---|---|---|
Exact | identical fingerprint | positional codec |
Compatible | differs, bridgeable without loss | keyed codec |
Degraded | bridgeable with loss — a field defaulted or dropped | keyed codec, recorded per call |
Incompatible | no bridge exists | refused; ERROR category codec |
Absent | the method is not in the provider revision | refused; 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.
| Channel | Resolves to |
|---|---|
stable | the revision marked stable for this definition |
canary | the 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.
| Target | Changes | Undone by | If confused |
|---|---|---|---|
Document | the persisted GraphDocument | undo / discard | edits look applied; nothing runs differently |
Source | Kotlin, via an adapter that understands the construct | git | a lossy regenerate destroys hand-written code |
Runtime | a live activation — wire a port, restart an actor | nothing; it already happened | an "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:
- The producer never blocks on a subscriber. Buffer policy is per-subscription —
conflatefor state,drop-oldestfor 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. - 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 needs | Supplied by |
|---|---|
| Stable ids and a parent chain | NodeKey, ScopeSummary.parent |
| Architecture levels | DescribeParams.level — a server parameter, not a client filter |
| Liveness | Mesh.targetCreated / targetDestroyed; presence is observed, never declared |
| Traffic | Wire.opened / closed with shape, credit, duration, outcome |
| Provenance and freshness | Provenance and Coverage on every read |
| Version lane | Revision.list — live revisions, activation counts, ghosts |
| Compatibility | per-edge Verdict |
| Findings | typed RgpError plus Finding from Edit.prepare |
(The FFI-facing node and edge kinds moved here from FFI §10, which links back.)
| Graph node | Meaning |
|---|---|
FfiRuntime | a switch's host runtime (Kotlin, C++, Hermes instance, Python, remote) |
FfiModule | a cell — a typed service on a switch |
FfiFunction | a method with schema fingerprint, shape and capability facets |
FfiBinding | a trunk endpoint (JNI method, cinterop symbol, JSI install, C-ABI attach, gRPC peer) |
HermesBundle | signed JS artifact registering cells |
FfiCall | an observed wire — shape, frames, credits, duration, error |
FfiFinding | fingerprint 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.
- Authenticate at
Mesh.hello. A client without a principal receives no targets — not a reduced list, none. - Meshes are environment-scoped. A production mesh is not a default binding, and there is no route between meshes.
- Grants are per session and per domain.
Readnever impliesSubscribe;Subscribenever impliesValues; nothing impliesEdit. A read wire is not upgradeable in place. Valuesis separately granted and separately audited (§11).- Every
Edit.executeis 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
describeat every level it advertises, with honestCoverage; - produce a cursor from
describethatenableaccepts, and failCursorExpiredrather 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
RevisionDigestfrom 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
- Frame codec and message shapes over
FlexBuffersBuilder/Vector— commands, replies, events, cursors, credits. Everything is blocked on this. - Transport: loopback, plus WebSocket over a Durable Object room.
reaktor-ioalready shipsWebSocket.send(ByteArray)andPartySocket, andreaktor-cloudflarealready depends onpartyserver. 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. Mesh+Graphread path: targets, sessions,describe, handles,resolve.Graphevent path: cursors, selectors, credits, sourced from the existingGraph.instrument()taps.Revision: digests, grouping, verdicts, ghosts,compare.Wire: the live traffic view.Editover the existingGraphEditorSessionandChangeProtocol.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.conflictsurfaces 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.
Related docs
| Doc | Relationship |
|---|---|
| Living Graph | The identity and provenance laws this protocol implements |
| Reaktor FFI — Switchboard | The transport: frames, cells, switches, trunks, wires |
| Graph Substrate Redesign | GraphPatch, ChangeProtocol, the typed record model |
| Hierarchical Graph UI | How the result is presented and navigated |
| Graph Sources | Non-native facts, and what provenance does not grant |
| Component Lifecycle | Where graph events are produced in-process |
| Reaktor Service | Operation keys, transports, the client/server contract |
| Agentic Tooling | One client of the Edit domain |
| Formal Methods | Patches as a transition system; findings as a return edge |