The first-party apps learn that a source was re-indexed, a lens was shared or a pair session changed without polling. Govern keeps one ordered event feed per organisation and streams it to each viewer, filtered by what that viewer may read now. PostgreSQL is the only moving part: no broker is needed, so the feed works in an air-gapped, single-database installation.
Events carry ids, counts and short codes only: never a chunk, a file, a lens name, a query or a message. An app reads the content through the normal, authorised routes.
Open a stream
curl -N -H "Authorization: Bearer $TOKEN" -H "Accept: text/event-stream" \
"https://<your Govern host>/api/v1/context/events?spaceId=<optional>"
spaceIdlimits the stream to one space (the caller must be able to read it, else404).- Resume with the
Last-Event-IDheader (orlastEventId); a browserEventSourcedoes it by itself. 422for a malformed value,429 context.events.too_many_connectionsover the connection caps,503while the server shuts down.
Stream format
SSE event | data |
|---|---|
ready | {lastEventId, latestEventId, replaying, heartbeatSeconds, maxAgeSeconds} |
| a feed type | {id, type, spaceId?, spaceIds?, lensId?, sessionId?, subjectId?, actor?, at?, meta?, redacted?} |
presence.updated, presence.left | {type, userId, spaceId, sessionId?, lensId?, state?, expiresAt?} |
heartbeat | {at, latestEventId} every 30 seconds |
error | {code, message, retryable, latestEventId?}; the server closes the stream after it |
A : keep-alive comment is written every 10 seconds.
Ordering and resume
- Event ids are per organisation, strictly increasing and gap-free in the feed. A stream delivers only the events the caller may read, so a client sees gaps in the ids it receives.
- Everyone sees the same total order, and no event is skipped. That order is the order in which events were relayed, which with several replicas can differ from the order the changes committed. Treat an event as "this changed, here are its ids" and reconcile with the object's
versionor by reading it. - Reconnecting with the last id replays exactly what was missed, without loss or duplicates, within 1000 events and 24 hours. Further back, the stream sends
context.events.resume_gap: reload your state and connect without an id.
Error codes
| Code | Meaning |
|---|---|
context.events.slow_consumer | The client did not keep up; reconnect with the last id |
context.events.access_revoked | Access to the space or the sign-in ended (not retryable) |
context.events.resume_gap | The events after Last-Event-ID are no longer available |
context.events.shutting_down, context.events.reconnect | The server is stopping, or the stream reached its maximum age (15 minutes); reconnect with the last id |
context.events.authz_unavailable, context.events.internal | Retryable |
Who receives what
| Event family | Delivered when the viewer |
|---|---|
source.*, snapshot.created, handoff.created, memory.*, project.repositories_changed | May read the space |
lens.* | May see the lens; spaceIds lists only the lens's spaces the viewer may read |
session.* | May read the session's space and takes part in the session |
agentfile.* | Owns the personal file, or may read the node of any other file |
work.item.* | May see the work item |
overlap.* | Owns one of the two items |
share.* | May see the lens |
Events of a restricted space carry redacted: true, the type and the ids only. Authorisation decisions are re-checked every 10 seconds (KEYSTONE_CONTEXT_EVENTS_REFRESH), so a revocation takes effect within that time; a stream bound to a lost space ends with access_revoked, and so does one whose sign-in stopped being valid. A permission removed from a role reaches a running stream when it reconnects, at the latest after 15 minutes.
Feed types
source.indexed, source.stale, source.deleted, snapshot.created, handoff.created, project.repositories_changed, lens.created, lens.updated, lens.shared, lens.unshared, lens.frozen, session.started, session.joined, session.left, session.ended, session.lens_changed, session.sources_changed, memory.created, memory.accepted, memory.superseded, memory.archived, agentfile.created, agentfile.updated, agentfile.pending, agentfile.rejected, agentfile.archived, agentfile.hold, work.item.declared, work.item.updated, work.item.finished, work.item.abandoned, overlap.opened, overlap.updated, overlap.ended, and share.requested, share.approved, share.rejected, share.cancelled, share.expired, share.refused.
Session invitations publish no event: an invitee finds them with GET /api/v1/context/sessions/invited.
Presence
PUT /api/v1/context/presence
{"spaceId": "<id>", "sessionId": "<optional>", "lensId": "<optional>", "state": "active"}
The answer carries expiresAt and ttlSeconds; heartbeat every ttlSeconds / 3. DELETE /api/v1/context/presence?spaceId=&sessionId= leaves at once.
stateis a short token (^[a-z][a-z0-9_-]{0,31}$, defaultactive), never free text.- Presence is ephemeral: it is never audited and never stored in the feed. Entries expire 30 seconds after the last heartbeat (
KEYSTONE_CONTEXT_PRESENCE_TTL) and the table is empty after a database failover; it refills within a heartbeat. - The collaboration policy's
presenceswitch applies (403 context.collab_policy.capability_disabled).
Deployment notes
- A reverse proxy must not buffer the stream or time it out sooner than the keep-alive. The server sets
X-Accel-Buffering: no; raise the proxy's read timeout above the heartbeat (30 seconds). - Browsers allow about six HTTP/1.1 connections per origin: open one stream per app, not per component. HTTP/2 lifts the limit.
- Connection caps are per replica: 5 streams per user and 200 per organisation by default.
- If a replica's PostgreSQL LISTEN connection drops, delivery falls back to polling (about 7 seconds at worst) until it reconnects. Selfguard does not watch this fallback yet; the log line is "LISTEN connection lost".
- A malformed
KEYSTONE_CONTEXT_EVENTS_*value turns only these routes into503 context.events.unavailable; retrieval is unaffected.
Metrics: keystone_context_events_streams, keystone_context_events_published_total, keystone_context_presence_published_total, keystone_context_events_slow_consumers_total, keystone_context_events_pull_errors_total, keystone_context_events_skipped_total.