Source: ddx-documentation/adr/0016-sse-bus.md
- **Status:** Accepted (plan `sse-bus`, task 001, 2026-10-23). - **Amends:** [ADR 0005](/en/docs/adr/0005-frameworks) approved dependency table (one row points here). - **Refines:** [ADR 0014](/en/docs/adr/0014-unified-settings) (`settings.changed` events), [ADR 0015](/en/docs/adr/0015-entity-kernel) (`entity.changed` events, `AccessPolicy` audience). - **Plan:** `plans/sse-bus/` (overview `00-overview.md`, invariants `_invariants.md`). - **Structure reference:** `findings/hms-sse-architecture/report.md` (HMS = structure only; no code or values copied). - **Ports:** [../ports.md](/en/docs/ports) (Redis host port 16340). ## Context - **Reconnect storm (owner log, lead note 2026-10-23).** The BFF logged `GET /api/v1/events 200 in 3ms` ×197 and ddx-api answered `429 ThrottlerException` on `/events`. The web manager reset its backoff counter on every open, so a stream that opened and closed at once retried every ~1 s, per tab. The lead fixed the counter (reset only after 10 s stable open). The root causes stay: every tab opens its own stream, the generic throttler guards `/events`, and every client retries in lockstep after an API restart. - **Reload hang (hang-fix, 2026-10-23).** Open SSE streams kept the old ddx-api process alive on a watch reload. `SseHub.closed$` + `takeUntil` and `return503OnClosing` fixed the hang. A restart still drops every stream at the same moment. - **In-memory replay, epoch resync.** The hub keeps the replay buffer in process memory. Event ids carry a process epoch. After a restart the buffer is empty and every client gets `resync` (`epoch-changed`), so every tab refetches everything at once — a second wave on top of the reconnect wave. - **One process, no queue.** Fan-out works only inside one ddx-api process. Nothing durable carries work out of the request (webhooks to other systems, retries). ## Decision 1. **Redis is the bus backbone.** One Redis server (image below) on host port 16340, container port 6379, owned by `ddx-docker-redis/`. AOF on; `maxmemory-policy noeviction` (BullMQ needs it). ddx-api reads `REDIS_URL` (`redis://127.0.0.1:16340` in dev). 2. **Redis Streams = the event log.** Each durable event is `XADD`ed (approximate `MAXLEN` cap) to a per-channel stream BEFORE it is published, so "logged ≥ delivered". The Stream entry id `<ms>-<seq>` IS the SSE `id`. Ids survive an API restart; the `epoch-changed` resync wave ends. Replay = `XRANGE (<Last-Event-ID> +`. A trimmed gap answers `resync` (`buffer-trimmed`); a Redis error answers `resync` (`replay-failed`), never an empty success. Volatile types (heartbeat, presence, system notices) never enter the log and never carry an id. 3. **Redis pub/sub = the fan-out.** `SseHub.publish` = validate → `XADD` (durable types) → `PUBLISH` → each hub filters locally with the one ACL seam (`isVisibleTo`, audience from `AccessPolicy.audienceOf`). Live and replayed frames pass the same filter. Domain code never calls Redis; only the hub and its transport do. Two connections per process (command + subscriber), both from one `platform/redis` provider. 4. **BullMQ = the outbound queue.** Outbound webhook deliveries run as BullMQ jobs on the same Redis: attempts + exponential backoff, dead-letter after the last attempt. The worker never re-enqueues its own job and never sends a user- or audience-scoped event to an outbound URL. 5. **HMAC webhooks.** Inbound `POST /webhooks/:source/:event`: HMAC-SHA256, timestamp skew check, per-source event whitelist, then a nonce claim (`SET NX PX`) — the nonce is claimed only AFTER the MAC verifies. Outbound deliveries are signed the same way. Header names, skew and TTLs live in `@ddx/contracts/sse-webhooks` (task 002), not here. 6. **Root-provider connection.** ddx-web opens ONE `EventSource` per browser, from `ddx-web/src/lib/sse/manager` only, owned by a provider at the root of the `(app)` group. The portal layout only sets the scope (org, user); a scope change reconnects. 7. **Leader tab.** Tabs elect one leader with Web Locks (`navigator.locks`); the leader holds the stream and relays frames to the other tabs over `BroadcastChannel`. Without those APIs each tab connects on its own (degraded, still correct). 8. **Load shaping.** `/events` skips the generic throttler and gets a per-principal connection limiter instead. Clients use full-jitter backoff and honour the server `retry:` hint; on shutdown the hub sends a `system.notice` with a jittered `retry:`. ## Approved dependencies Resolved live on **2026-10-23** (UTC 15:36) on dudoxxmac. `~/.claude/ddx-topic-flows/VERSIONS.md` has **no** `redis`, `ioredis` or `bullmq` row on 2026-10-23 (`rg -i 'redis|bullmq'` → no match), so the version source is the recorded command, per ADR 0005 Decision 4. Not copied from HMS. | Package / image | App | Kind | Approved version | Resolved | Version source (command, 2026-10-23) | |---|---|---|---|---|---| | `redis` Docker official image | `ddx-docker-redis/` | image | `redis:8.10.2-alpine3.23` **exact tag** | 8.10.2 (tag pushed 2026-10-22) | Docker Hub `GET /v2/repositories/library/redis/tags?ordering=last_updated` — newest GA alpine tag; `8.10-rc2-*` is an RC and is excluded | | `ioredis` | ddx-api | dep | `^6.0.0` | 6.0.0 (dist-tag `latest`, published 2026-07-31) | `pnpm view ioredis version` → `6.0.0` | | `bullmq` | ddx-api | dep | `^6.4.0` | 6.4.0 (dist-tag `latest`, published 2026-10-21) | `pnpm view bullmq version` → `6.4.0` | Notes: - `bullmq@6.4.0` declares `ioredis >=5.0.0` as a peer (`pnpm view bullmq@6.4.0 peerDependencies`), so `ioredis ^6.0.0` satisfies it. BullMQ docs require Redis ≥ 6.2 and recommend 7+; 8.10.2 meets both. - Ranges are caret within the major (ADR 0005 Decision 4). A major bump (bullmq 7, ioredis 7, redis 9) needs a new ADR. The Redis image is pinned to the exact tag; a tag bump is a one-line edit of this table plus the compose file. - `bullmq@6.4.0` is 2 days old on 2026-10-23. If the pnpm 12 release-age gate rejects it, the install task adds `bullmq@6.4.0` to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml` and lists that file in its task files. - No other package is approved by this ADR (`redis` npm client, `bullmq-otel` and `pg` peers stay uninstalled). ## Rejected | Option | Why not | |---|---| | In-memory fallback when Redis is down | Silent split brain: events published while Redis is down reach only one process and never enter the log, so replay lies. Plan invariant: never fall back to in-memory fan-out silently. Redis down = `/health` not ready + a loud error. | | WebSocket | The traffic is server → browser only. SSE gives `Last-Event-ID` resume, plain HTTP through the BFF and the existing `api/v1` pipe. A socket adds a second protocol and its own auth path. | | Centrifugo / Mercure / NATS / `better-sse` | A second server (or a library hub) beside ddx-api: a second ACL seam outside `AccessPolicy`, a second port, a second auth path. HMS defect D1 (two hubs). `better-sse` keeps state in process — the defect this ADR removes. | | Kafka | Ops weight (cluster, topics, retention tuning) far beyond one dev workspace; Redis Streams give ordered ids, replay and trimming at this scale, and BullMQ needs Redis anyway. | ## Consequences - **Redis is a hard dependency.** ddx-api refuses to start without `REDIS_URL` (env validation marks it required, no default). `./ddx-manage.sh` starts the Redis container with Postgres and MinIO. - Good: event ids survive a restart; a resuming client gets replay, not a `resync` wave. - Good: fan-out is multi-instance ready (pub/sub); one ACL seam for live, replayed and bridged frames. - Good: one browser connection per user (leader tab) cuts the per-tab load and the 429 loop. - Cost: one more container, one more port (16340), one more thing that can be down in dev. - Risk: Stream memory. Each stream is capped (`MAXLEN ~`) and the cap is a named constant in the hub `POLICY.md`; a trimmed cursor answers `resync`, never silence. ## Validation ```sh rg -q 'bullmq' ddx-documentation/adr/0016-sse-bus.md && rg -q 'ioredis' ddx-documentation/adr/0016-sse-bus.md rg -q '0016' ddx-documentation/adr/0005-frameworks.md rg -q '16340' ddx-documentation/ports.md ``` --- Last reconciled: 2026-10-23 · task 001 (sse-bus)
sse-bus, task 001, 2026-10-23).settings.changed events), (entity.changed events, AccessPolicy audience).plans/sse-bus/ (overview 00-overview.md, invariants _invariants.md).findings/hms-sse-architecture/report.md (HMS = structure only; no code or values copied).GET /api/v1/events 200 in 3ms ×197 and ddx-api answered 429 ThrottlerException on
/events. The web manager reset its backoff counter on every open, so a stream that opened
and closed at once retried every ~1 s, per tab. The lead fixed the counter (reset only after
10 s stable open). The root causes stay: every tab opens its own stream, the generic throttler
guards /events, and every client retries in lockstep after an API restart.SseHub.closed$ + takeUntil and return503OnClosing fixed the hang. A
restart still drops every stream at the same moment.resync (epoch-changed), so every tab refetches everything at once — a second wave on top
of the reconnect wave.ddx-docker-redis/. AOF on; maxmemory-policy noeviction (BullMQ
needs it). ddx-api reads REDIS_URL (redis://127.0.0.1:16340 in dev).XADDed (approximate MAXLEN cap)
to a per-channel stream BEFORE it is published, so "logged ≥ delivered". The Stream entry id
<ms>-<seq> IS the SSE id. Ids survive an API restart; the epoch-changed resync wave
ends. Replay = XRANGE (<Last-Event-ID> +. A trimmed gap answers resync
(buffer-trimmed); a Redis error answers resync (replay-failed), never an empty success.
Volatile types (heartbeat, presence, system notices) never enter the log and never carry an id.SseHub.publish = validate → XADD (durable types) →
PUBLISH → each hub filters locally with the one ACL seam (isVisibleTo, audience from
AccessPolicy.audienceOf). Live and replayed frames pass the same filter. Domain code never
calls Redis; only the hub and its transport do. Two connections per process (command +
subscriber), both from one platform/redis provider.POST /webhooks/:source/:event: HMAC-SHA256, timestamp skew
check, per-source event whitelist, then a nonce claim (SET NX PX) — the nonce is claimed
only AFTER the MAC verifies. Outbound deliveries are signed the same way. Header names, skew
and TTLs live in @ddx/contracts/sse-webhooks (task 002), not here.EventSource per browser, from
ddx-web/src/lib/sse/manager only, owned by a provider at the root of the (app) group.
The portal layout only sets the scope (org, user); a scope change reconnects.navigator.locks); the leader holds
the stream and relays frames to the other tabs over BroadcastChannel. Without those APIs
each tab connects on its own (degraded, still correct)./events skips the generic throttler and gets a per-principal connection
limiter instead. Clients use full-jitter backoff and honour the server retry: hint; on
shutdown the hub sends a system.notice with a jittered retry:.Resolved live on 2026-10-23 (UTC 15:36) on dudoxxmac. ~/.claude/ddx-topic-flows/VERSIONS.md
has no redis, ioredis or bullmq row on 2026-10-23 (rg -i 'redis|bullmq' → no match),
so the version source is the recorded command, per ADR 0005 Decision 4. Not copied from HMS.
Notes:
bullmq@6.4.0 declares ioredis >=5.0.0 as a peer (pnpm view bullmq@6.4.0 peerDependencies),
so ioredis ^6.0.0 satisfies it. BullMQ docs require Redis ≥ 6.2 and recommend 7+; 8.10.2 meets both.bullmq@6.4.0 is 2 days old on 2026-10-23. If the pnpm 12 release-age gate rejects it, the
install task adds bullmq@6.4.0 to minimumReleaseAgeExclude in pnpm-workspace.yaml and
lists that file in its task files.redis npm client, bullmq-otel and pg peers
stay uninstalled).REDIS_URL (env validation
marks it required, no default). ./ddx-manage.sh starts the Redis container with Postgres and
MinIO.resync wave.MAXLEN ~) and the cap is a named constant in the
hub POLICY.md; a trimmed cursor answers resync, never silence.Last reconciled: 2026-10-23 · task 001 (sse-bus)
adr/
deploy/
research/
rg -q 'bullmq' ddx-documentation/adr/0016-sse-bus.md && rg -q 'ioredis' ddx-documentation/adr/0016-sse-bus.mdrg -q '0016' ddx-documentation/adr/0005-frameworks.mdrg -q '16340' ddx-documentation/ports.md