Eventing & SSE
Server-sent events are the live-sync seam between the single-writer store and any number of readers (Flutter LiveSyncService, browser tabs, curl).
Types
EventType: charge.created, charge.updated, task.created, notification.updated, sync.required — frozen in contract v1. ResourceType: charge, task, notification. ChangeType: created, updated. Every commit appends DraftEvent → StoredEvent{ id, type, entityId, resourceType, changeType, bookingId?, entityVersion?, createdAt, streamCursor } to the ring.
Ring + cursors
- Capacity
EVENT_RING_CAPACITY=100— oldest dropped when full, never blocking commit. streamCursoris an opaque monotonic id (event_id()usingIdGenerator);get_sync_snapshot_meta_200.jsonshows shape.streamEpochchanges only on reset — signals full refetch.
SSE endpoint
GET /api/v1/events
Accept: text/event-stream
Last-Event-ID: 01H... # optional; replays from that cursor
Cache-Control: no-cache
Response frames (see docs/contracts/examples/sse_frames.json):
id: 01HROTZ3Q...
event: charge.updated
data: {"chargeId":"CHG-001","status":"contested","bookingId":"BKG-001","...": "..."}
: heartbeat
event: sync.required
data: {"reason":"ring trimmed; cursor not retained"}
EventBrokerinapp/services/event_bus.pyholds a per-clientasyncio.Queue(capacitysse_queue_capacity=64, default 64). On overflow oldest is dropped per-client.publisher(events)is set bystore.set_publisher(broker.publish)in lifespan; called afteros.replaceso clients never see uncommitted events.GET /eventschecksLast-Event-IDagainst the ring; if found, replays from next event; if not found (trimmed/unknown) sendssync.requiredso client takesGET /sync-snapshot.- Heartbeat comment
": \n\n"everysse_heartbeat_seconds=15to keep proxies from closing. - Barrier:
/eventsis in_SELF_MANAGED_PATHS— it bypasses theOperationBarrier.shared()so a long-lived stream doesn't block reset; reset closes all broker queues viaawait broker.close_all().
Flutter consumption
LiveSyncService (lib/core/sync/live_sync_service.dart) opens KxApi.subscribeEvents(cursor) using dio with ResponseType.stream; SseEntityCommitter (sse_entity_committer.dart) applies charge.updated to Drift via LocalStore.applyRemoteChanges, then advances SnapshotCoordinator cursor. SnapshotCoordinator decides when sync.required forces a full GET /sync-snapshot. Leader election (SyncLeadership — sync_leadership.dart + factory_web/stub) ensures one tab drains the stream when multiple browser tabs are open.
Chaos & failure
ChaosController (app/core/chaos.py) can inject per-path latency and probabilistic 429 via KX_CHAOS_LATENCY_MS / KX_CHAOS_ERROR_RATE toggles (also surfaced to Flutter --dart-define). SSE is injectable — LiveSyncService must retry with backoff (RetryPolicy).
Testing
tests/api/test_attachments_events_dev.py— broker queue capacity, heartbeat, replay viaLast-Event-ID, ring trim detection.- Golden
sse_frames.jsonis the bytes-level oracle; contract tests assert headertext/event-stream+cache-control.
What SSE is not
This is not a durable log — 100 events, no persistence beyond state.json ring. Not a message queue — not acknowledged, no delivery guarantee. It is a notification that something changed; authoritative state is always GET /sync-snapshot or GET /hub + charge endpoints.