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 behind the same ALB and TLS as the API. The connect/subscribe proxy authorizes against Terminus on the same JWT the REST endpoints use; twelve per-primitive channel derivers fan domain events out of the audit outbox; publishing happens out-of-transaction over Messenger. The Demerzel Live Activity page subscribes with the Centrifuge client and renders the stream.
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 in the WebSocket Upgrade headers. Same key, same claim shape as the REST endpoints. Per-channel entitlement is checked via
TerminusDirectory.checkEntitlementat subscribe time — 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 sends the last-received id aslastEventId; Palver replays strictly-after events the client is still entitled to, bounded by outbox retention. Past the window, agapnotification tells the client to re-fetch via REST. - PalverChannelDeriver
- Per-primitive service that turns an AuditEvent into the channel list it should fan out to. Tagged service; twelve ship today — Seldon, Trantor, Terminus, Hardin, Hober, Payments, Mallow, Speaker, Daneel, Radiant, Pelorat, and Magnifico each derive their own per-entity and tenant-scoped channels.
PalverFanOutPublisherwalks all of them and unions the result. - Runtime split & out-of-tx publish
- Symfony PHP-FPM does not hold persistent connections, so the runtime is Centrifugo, out of process. The PHP side does not publish inside the request transaction: the fan-out publisher dispatches a
PalverFanOutMessageper channel onto Messenger, and an async handler posts to the Centrifugo HTTP API. Delivery failures fall to Messenger's retry + failed transport rather than rolling back the originating write; every envelope carriesauditEventIdfor client-side dedup.
API surface
Palver is consumed over WebSocket, not REST. The surface below is the wire protocol exchanged on a connected socket.
| Direction | Op | Purpose |
|---|---|---|
| Client → Server | HTTP Upgrade + Authorization: Bearer <jwt> | Handshake. JWT verified; on failure, close 4001. |
| Client → Server | { op: "subscribe", channels: [...], lastEventId? } | Subscribe to channels with optional resume cursor. |
| Server → Client | { op: "subscribed", channels: [...], deniedChannels: [...] } | Per-channel entitlement result. |
| Server → Client | { eventClass, entityType, entityId, payloadAfter, ..., auditEventId } | An event push on a subscribed channel. |
| Client → Server | { op: "ping" } | Heartbeat (every 30s). |
| Server → Client | { op: "pong" } | Heartbeat response. |
| Server → Client | { op: "gap", channel, lastDelivered } | Replay window exceeded; client should re-fetch via REST. |
| Server → Client | Close 4001 / 4003 / 4008 / 4010 | Auth failed / forbidden / policy / server going away. |
Example: subscribe to a booking
After the WebSocket Upgrade with a JWT, the client subscribes:
// client → server
{
"op": "subscribe",
"channels": [
"seldon.booking.bk_01JAZB…",
"tenant:t_01J8K2…"
],
"lastEventId": "ae_01JAZD…"
}
// server → client
{ "op": "subscribed",
"channels": ["seldon.booking.bk_01JAZB…", "tenant:t_01J8K2…"],
"deniedChannels": [] }
// 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… */ }
}
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.