TL;DR: Treat event discovery as a versioned contract, not as a list of topics returned by a convenient endpoint. For a collaborative editor, publish a small, signed catalog of event types, validate every cursor update against that catalog, and make the client reject unknown or stale versions before they can corrupt presence. The deciding constraint is presence accuracy: a cursor that is late is usually less harmful than one that is confidently wrong.
This is an architecture decision record for a device-status style realtime system that also syncs collaborative cursors. The same contract has to survive reconnects, partial rollouts, and a user opening two tabs with different client builds.
Decision and invariants
The server owns the event schema. A discovery response names the event type, version, payload schema identifier, ordering scope, and expiry policy. It does not promise that every event is replayable. The client pins the catalog version for a session and reports a contract mismatch as a typed error.
That boundary matters.
Four invariants keep the system understandable:
- An event has one envelope shape:
type,version,id,occurred_at,scope, andpayload. - Cursor positions are ephemeral state. They can be dropped, coalesced, or superseded; device status changes need a durable sequence.
- Unknown fields may be ignored only when the schema says they are optional. Unknown event types are never silently treated as cursor updates.
- A reconnect must negotiate the catalog before subscribing. A cached catalog is usable only until its declared expiry.
Here is the trade-off record I would attach to the design review:
| Option | Presence accuracy | Failure boundary | Operational cost | Appropriate use |
|---|---|---|---|---|
| Hard-coded client event map | High until drift | Build-time mismatch | Low | One tightly coupled release train |
| Runtime discovery plus JSON Schema | High with explicit negotiation | Session admission | Medium | Independent client and server releases |
| Free-form events with documentation | Unbounded drift | Individual handler | Low at first, high during incidents | Prototypes only |
| Full event replay for every update | Misleading for cursors | Backlog and ordering pressure | High | Auditable device status, not pointer motion |
I choose runtime discovery with a pinned schema version. It adds one negotiation round trip, but that cost is visible and bounded. A stale cursor is annoying; a stale device-status interpretation can trigger the wrong UI action.
What should an event discovery API guarantee?
Discovery needs a narrow contract of its own. Return a catalog document with a monotonically increasing catalog_version, an expires_at timestamp, and entries such as cursor.moved and device.status.changed. Each entry should include a schema URI or immutable identifier, the allowed scope (document:123 or account:456), and delivery semantics (latest_only for cursors, ordered for status).
Do not infer semantics from a topic string. cursor.moved.v2 is still ambiguous if the coordinate system, editor revision, or actor identity is undocumented. Put those details in the payload schema and make the schema identifier immutable. JSON Schema provides a portable vocabulary for this boundary; its additionalProperties setting is especially useful for deciding whether a client can tolerate additive fields.
The discovery response should be cacheable, but cache validation must be explicit. An ETag or equivalent validator lets a client ask whether its catalog is still current without downloading the whole document. On a 304 response, retain the previous catalog and its original expiry. On a changed catalog, finish the current session under the old rules or reconnect; mixing versions inside one subscription makes incident logs nearly impossible to interpret.
The critical path: negotiate, validate, apply
The code below is deliberately small. It models the checks that belong on the client boundary; the transport can be WebSocket, WebRTC data channel, or another realtime link. WebRTC 1.0 defines the data-channel primitives, but it does not define your application event taxonomy, so that taxonomy remains your responsibility.
from datetime import datetime, timezone
from jsonschema import Draft202012Validator
CATALOG_VERSION = 17
ENVELOPE_SCHEMA = {
"type": "object",
"required": ["type", "version", "id", "occurred_at", "scope", "payload"],
"properties": {
"type": {"type": "string"},
"version": {"type": "integer", "minimum": 1},
"id": {"type": "string", "minLength": 1},
"occurred_at": {"type": "string", "format": "date-time"},
"scope": {"type": "string"},
"payload": {"type": "object"}
},
"additionalProperties": False
}
CATALOG = {
("cursor.moved", 2): {
"delivery": "latest_only",
"scope_prefix": "document:",
"schema": {
"type": "object",
"required": ["actor_id", "revision", "line", "column"],
"properties": {
"actor_id": {"type": "string"},
"revision": {"type": "integer", "minimum": 0},
"line": {"type": "integer", "minimum": 0},
"column": {"type": "integer", "minimum": 0}
},
"additionalProperties": False
}
}
}
def accept(event, session_scope):
Draft202012Validator(ENVELOPE_SCHEMA).validate(event)
key = (event["type"], event["version"])
entry = CATALOG.get(key)
if entry is None or not event["scope"].startswith(entry["scope_prefix"]):
raise ValueError("contract_mismatch")
if event["scope"] != session_scope:
raise ValueError("scope_mismatch")
Draft202012Validator(entry["schema"]).validate(event["payload"])
occurred = datetime.fromisoformat(event["occurred_at"].replace("Z", "+00:00"))
if occurred > datetime.now(timezone.utc):
raise ValueError("clock_skew")
return event
In a Node.js client, the same flow is commonly implemented with a JSON Schema validator and a generated type declaration. The language is secondary. The important behavior is that validation happens before state mutation, and that contract_mismatch is observable with the catalog version, event type, and client build. Never log the entire payload by default; cursor payloads can contain document coordinates that are sensitive in some environments.
For latest_only, retain only the newest event per (scope, actor_id) and editor revision. For ordered, keep a per-scope sequence and request a resync when a gap appears. Applying one global sequence to both streams creates head-of-line blocking: a burst of pointer motion can delay a device-status transition that the operator needs to see.
Rejected option and its valid use case
The rejected design is a free-form event bus where clients subscribe to names and discover payload shape from documentation. It looks productive during the first integration. During a staggered deploy, though, a renamed field becomes None, an old tab renders a cursor at (0, 0), and the transport still reports success. That is a contract failure disguised as healthy connectivity.
This option is valid for internal prototypes with one release train, disposable state, and no cross-version clients. It is also useful as a quarantine lane for telemetry that has no UI consumer. Keep it away from commands, presence, and device status.
Three popular transports illustrate why this decision belongs above the transport layer. Socket.IO provides event-based messaging and fallback transports, but your application still defines schema and version rules. Pusher Channels supplies named channel events, yet the payload contract remains an application concern. Ably exposes realtime channels and message metadata, while ordering and history choices still need to be mapped to your domain semantics. None of these boundaries removes the need for discovery, validation, or a mismatch policy.
Rollout, observability, and the incident rule
Ship a catalog before shipping a new event. During rollout, accept the old and new schema versions on the server, but have each client emit its pinned catalog version in a connection metric. Alert on mismatch rate, validation failures by event type, resync requests, and the age of the newest cursor per active document. A healthy socket count can hide a completely broken presence stream.
Test the matrix, not just the happy path: old client/new catalog, new client/old catalog, an expired cache, a duplicated cursor, a missing ordered sequence number, and two tabs racing to update the same actor. Include a clock-skew case; rejecting a future timestamp is safer than letting a device appear online from a message that has not happened yet.
The incident rule is simple: stop applying events when the contract is unknown, preserve the last known good presence state, and surface a reconnect or resync signal. That gives operators a bounded failure and gives the user an honest stale indicator instead of fabricated accuracy.
Presence lies quietly.
Top comments (0)