Primitive spec
Palver
The live-channel primitive. Palver turns the same audit event outbox every other primitive emits into authenticated WebSocket pushes to browser and native runtime sessions — with no new event infrastructure, no new auth model, and the same DTO shapes the REST endpoints return.
wss://palver.latticekit.app. Channel subscriptions authorize on the same JWT as the REST API; per-primitive derivers fan domain events out of the audit outbox to browsers in real time.
What it owns
Palver owns one job: deliver audit-event-shaped pushes to authenticated client sessions, scoped to channels the client is entitled to subscribe to. It is one more subscriber on the same CompositeEventPublisher that Daneel, Speaker, Hober, and Mallow already plug into.
Palver explicitly does not own durability (the outbox is source of truth), authorization (defers to Terminus), or one-shot outbound messaging (that is Speaker; same outbox feeds both, different delivery shape).
Concepts
- Channel namespace
- Three shapes: entity-scoped (
seldon.booking.bk_xyz), tenant-wide (tenant:t_123), sub-tenant-wide (subtenant:t_123:st_456). Entity-scoped channels do NOT carry tenantId in the name; tenancy is implicit from the JWT handshake, and the subscribe-time entitlement check binds (channel, tenant). - Event envelope
- One envelope per fan-out:
eventClass,entityType,entityId,occurredAt,payloadAfter,payloadBefore,channel,auditEventId. ThepayloadAftershape is the same DTOGET /<primitive>/v1/<entity>/{id}returns — discrepancies are publisher bugs, not subscriber concerns. - Subscription auth
- JWT on the Centrifugo connect, verified by Palver's connect proxy. Same key, same claim shape as the REST endpoints. Per-channel entitlement is checked via
TerminusDirectory.checkEntitlementat subscribe time, in the subscribe proxy — the same call Seldon Booking makes at quote time, the same call Speaker makes before dispatch. - Resume contract
- Every push carries
auditEventId. On reconnect the client callsGET /palver/v1/replaywith the last-received id aslastEventId; Palver returns strictly-after events the client is still entitled to, bounded by outbox retention. Past the window the response carriesgap: trueand the client re-fetches via REST. - PalverChannelDeriver
- Per-primitive service that turns an AuditEvent into the channel list it should fan out to. Tagged service; seventeen ship today — Seldon, Trantor, Terminus, Hardin, Hober, Payments, Mallow, Speaker, Daneel, Radiant, Pelorat, Magnifico, Branno, Elliot, Demerzel, Gaal and Elar each derive their own per-entity and tenant-scoped channels.
PalverFanOutPublisherwalks all of them and unions the result. - Runtime split & out-of-tx publish
- The application layer does not hold persistent connections, so the realtime transport runs out of process. Publishing never happens inside the request transaction: the fan-out publisher queues one message per channel, and an async handler delivers it. A delivery failure retries on its own queue rather than rolling back the originating write — a dropped notification must never undo the thing it was notifying about. Every envelope carries
auditEventIdfor client-side dedup.
API surface
The socket is served by Centrifugo; clients connect with a standard Centrifugo client. Palver plugs in through Centrifugo's connect and subscribe proxy callbacks, which authenticate the JWT and authorize each channel, and adds one REST endpoint for replay.
| Method | Path | Purpose |
|---|---|---|
| WS | wss://palver.latticekit.app | Centrifugo connection. The JWT goes in the Authorization header or the connect data.token. |
| POST | /palver/v1/proxy/connect | Internal: Centrifugo's connect proxy. Verifies the JWT and binds the connection to a person and tenant. |
| POST | /palver/v1/proxy/subscribe | Internal: Centrifugo's subscribe proxy. Checks tenant, sub-tenant and entitlement for the channel. Denials use Centrifugo error codes 1001 (auth), 1003 (forbidden), 1008 (connection budget) and 1010 (temporary, retry). |
| GET | /palver/v1/replay?channel=&lastEventId= | Events after a cursor on one channel, under the same authorization as a live subscribe. Returns events, nextCursor and gap; gap: true means re-fetch via REST. |
Example: subscribe to a booking
The client connects with its JWT and subscribes; Palver authorizes the channel through the subscribe proxy.
// client: Centrifugo client, JWT on connect
const c = new Centrifuge("wss://palver.latticekit.app/connection/websocket",
{ data: { token: jwt } });
c.newSubscription("seldon.booking.bk_01JAZB…").subscribe();
c.connect();
// server → client (push on booking.confirmed)
{
"eventClass": "seldon_booking.confirmed",
"entityType": "seldon_booking",
"entityId": "bk_01JAZB…",
"occurredAt": "2026-06-10T14:31:22Z",
"channel": "seldon.booking.bk_01JAZB…",
"auditEventId": "ae_01JAZE…",
"payloadAfter": { /* same shape as GET /seldon/v1/bookings/bk_01JAZB… */ }
}
// after a reconnect, catch up over REST
GET /palver/v1/replay?channel=seldon.booking.bk_01JAZB…&lastEventId=ae_01JAZE…
Authorization: Bearer <jwt>
How it fits with the rest
flowchart LR
OB[(Audit outbox)] --> Pv(Palver)
Pv -- checkEntitlement --> Te[Terminus]
Pv --> WS[WebSocket sessions]
Pv -. shares outbox with .-> Sp[Speaker]
Pv -. shares outbox with .-> D[Daneel]
Palver subscribes to the same outbox every other event consumer subscribes to. A single booking.confirmed event can fan to Speaker (which sends the confirmation email under consent gate), to Daneel (which starts the reminder workflow), and to Palver (which pushes the new row to an operator's live booking list). All three see the same envelope; each delivers it in its own shape. Terminus is the authorization layer for subscriptions, same as for every other access decision on the platform.