RFC 0045 — Plugin Events and Realtime Channels
Summary
Implement sdk.events as a plugin-scoped publish/subscribe surface for realtime UI synchronization. It is for low-latency application state updates: list changes, presence, cursors, record edits, progress updates, and other ephemeral events.
It is not a durable queue, not a notification inbox, and not an audit log. Durable user-visible notifications remain sdk.notifications; audit remains sdk.activity.
Motivation
Several plugin classes need realtime coordination: shared lists, collaborative canvases, progress indicators, live logs, presence, and collaborative editing. Polling works for small cases but creates latency, duplicate fetches, and inconsistent implementations.
The platform already has notification transport work for polling, SSE, and Redis pub/sub. This RFC generalizes the realtime transport concept into a plugin SDK surface while keeping delivery semantics intentionally modest.
Current state
sdk.eventsis reserved and throwsNotImplementedError.- Notification transport supports polling/SSE/Redis for notification delivery.
- There is no general plugin realtime channel.
- Plugins can build local polling routes, but there is no shared pattern.
Proposed design
As shipped, this section's authorizeChannel sketch changed — see the 0.2 changelog entry for the reasoning: channel authorization is manifest-declared (a new events manifest field, entry file default- exporting an EventChannelAuthorizer) and composed at generate time, the same pattern RFC 0046's schedules/jobs already use — not a call-based sdk.events.authorizeChannel() registered at runtime. This resolves open question 1 below. The sketch is left as originally written for historical context; see docs/plugin-development.md's "events — realtime channels" section for the actual shipped surface and examples.
SDK surface
interface EventEnvelope<T = unknown> {
id: string;
channel: string;
type: string;
payload: T;
createdAt: number;
}
sdk.events.publish(input: {
channel: string;
type: string;
payload: unknown;
}): Promise<void>;Client subscription uses a runtime route rather than direct SDK call:
GET /api/events/stream?pluginId=<id>&channel=<channel>The route requires a session and checks that the current user may subscribe to that channel.
Channel naming
Channels are plugin-scoped:
<pluginId>:<resource-kind>:<resource-id>Plugins pass only the local channel part to the SDK:
await sdk.events.publish({
channel: `list:${listId}`,
type: 'item.checked',
payload: { itemId },
});The runtime prefixes the calling plugin ID and actor context.
Authorization
Publishing requires events:publish. Subscribing requires events:subscribe. The plugin must provide channel authorization metadata or a route-level check.
Phase 1 uses a plugin-owned authorization callback registered server-side:
sdk.events.authorizeChannel('list:*', async ({ userId, channel }) => {
return userCanReadList(userId, channel.resourceId);
});If no authorization callback is registered, subscription fails closed.
Delivery semantics
Phase 1 semantics:
- best-effort delivery;
- no guaranteed ordering across processes;
- no replay after disconnect;
- bounded payload size;
- no durable persistence;
- clients reconnect and refetch state after reconnect.
Plugins must treat events as invalidation/update hints, not the sole source of truth.
Transport
The event broker can reuse the notification broker transport modes:
- in-process SSE for single-node deployments;
- Redis pub/sub for multi-process/multi-node deployments;
- polling fallback for environments where streaming is unavailable.
Security requirements
- Session required for subscriptions.
- Channel authorization required for every subscription.
- Plugins cannot publish as another plugin.
- Payload size is capped.
- Event payloads are not written to activity logs by default.
- Events do not cross tenant boundaries.
Alternatives considered
Use notifications for realtime sync
Rejected. Notifications are durable, user-facing inbox items. Realtime sync events are ephemeral and often too noisy for an inbox.
Require each plugin to implement SSE
Rejected. It duplicates transport and Redis/pub-sub handling.
Implement CRDT collaboration in the platform
Deferred. The platform should provide transport; plugins own their data model and conflict-resolution semantics.
Open questions
Resolved — see the 0.2 changelog entry for the full implementation:
- Should channel authorization be callback-based, manifest-declared, or route based? Manifest-declared: an
eventsfield (pattern/entry/description), composed at generate time intoruntime/generated/plugin-events.ts, the same static-import pattern RFC 0046'sschedules/jobsuse. A call-basedsdk.events.authorizeChannel()was considered but rejected for the same reasonschedules/jobsdidn't do this either — no reliable moment for plugin code to register a callback before the first subscribe request needs it. - Should the platform offer short replay windows for reconnects? Partially —
/api/events/pollreads a small, bounded (50 events / 5 minutes), per-process, in-memory ring buffer, not a true replay log. It exists primarily as the polling-fallback transport, but a reconnecting SSE client could also use it to catch up on very recent history. Not durable, not cross-process-consistent — see the ring buffer's own doc comment (runtime/src/event-ring-buffer.ts) and Delivery semantics above, which this doesn't change. - Should event payload schemas be declared in manifests? Not in this leg. Payloads are opaque
unknownon both the publish and authorize sides, capped at 16 KB. Left as a possible follow-up if a real need for compile-time payload validation emerges. - How should Redis transport be required in production multi-node deployments? Same operator-facing shape as
NOTIFICATION_TRANSPORT(RFC 0034) — a dedicatedSOVEREIGN_EVENTS_TRANSPORTenv var (ssedefault,redis,polling), reusing the existingREDIS_URLrather than a second connection string. Deliberately a separate env var fromNOTIFICATION_TRANSPORT, not a shared one — events and notifications scale differently and an operator may want to disable/resize one without touching the other.
Adoption path
- ✅ Add event broker abstraction using existing transport patterns (
runtime/src/event-broker.ts+ in-process/Redis implementations — structurally mirrors, but does not share code with, the notification broker; see its own doc comment for why they're kept separate). - ✅ Add
sdk.events.publish()and the subscription route (plus/api/events/pollfor the polling fallback — see open question 2). - ✅ Add channel authorization (manifest-declared, not a call-based API — see open question 1).
- ✅ Add tests for authorization, disconnect/reconnect, and disabled plugins.
- ✅ Document polling fallback and state-refetch requirements (
docs/plugin-development.md).
Changelog
| Version | Date | Change |
|---|---|---|
| 0.1 | June 2026 | Initial draft |
| 0.2 | August 2026 | Full sdk.events shipped (epic task 3.17, workstream 0015 leg 2). Manifest events field (pattern/entry/description) composed by scripts/generate-registry.ts into runtime/generated/plugin-events.ts, resolving open question 1 — channel authorization is manifest-declared + generate-time static import, not a call-based authorizeChannel(). sdk.events.publish() (packages/sdk/src/events.ts), gated on a new events:publish manifest permission and enforced host-side (requireEventsPluginContext, runtime/src/plugin-events.ts, mirroring plugin-mailer.ts's requireMailerPluginContext); payloads capped at 16 KB (runtime/src/event-limits.ts, EventPayloadTooLargeError). New, independent runtime/src/event-broker.ts (in-process EventEmitter + Redis pub/sub implementations, SOVEREIGN_EVENTS_TRANSPORT env var, reuses REDIS_URL) — deliberately not a shared instance with the notification broker (different keyspace, different payload shape, different security model; see that file's doc comment). runtime/src/event-authorization.ts's authorizeChannel() matches a subscribing user's channel against every manifest-declared pattern for the target plugin (simple prefix match on a trailing :* wildcard, enforced at manifest-validation time), invokes each matching handler, and fails closed — no match, every match false, or every match throwing all deny; any one match returning true allows. GET /api/events/stream (SSE, runtime/app/api/events/stream/route.ts) and GET /api/events/poll (.../poll/route.ts) share one validation path (runtime/src/event-subscribe-guard.ts): session, pluginId/channel query params, target plugin installed + declares events:subscribe + not disabled, then channel authorization — polling is not a lesser-checked shortcut. events added to RESERVED_API_SEGMENTS (runtime/src/api-namespace.ts). Resolved open question 2 (replay windows) via a small bounded (50 events / 5 min), per-process, in-memory ring buffer (runtime/src/event-ring-buffer.ts) that backs /api/events/poll — explicitly not durable persistence, satisfying this RFC's "no durable persistence" requirement while still giving non-SSE clients something to read; idempotent on event id since the Redis broker's own publish echoes back through its subscription. Open question 3 (manifest-declared payload schemas) remains unresolved — payloads stay opaque unknown, capped by size only. |