Source: ddx-documentation/sse-events.md
> Generated by scripts/sse-catalog.mjs — do not hand-edit. Source: `packages/ddx-contracts/src/{sse,sse-channels,sse-webhooks}.ts`. Regenerate: `node scripts/sse-catalog.mjs`; gate: `node scripts/sse-catalog.mjs --check`. ## Envelope Every event carries these fields (envelope version `v: 2`). The hub assigns `v`, `id`, `ts` and `channel`. `userId` set = delivered to that user only, inside `orgId`. | Field | Type | Required | |---|---|---| | `v` | `2` | yes | | `id` | string `/^\d{1,16}-\d{1,10}$/` | yes | | `channel` | SseChannel (see channel kinds) | yes | | `orgId` | string (non-empty) | yes | | `userId` | string (non-empty) | no | | `ts` | ISO datetime | yes | | `type` | event type (below) | yes | Heartbeat cadence: 15000 ms. Replay depth per org: 10000 events. Resync reasons: `buffer-trimmed`, `invalid-id`, `replay-failed`. ## Event types 12 types, sorted by name. **durable** = written to the per-org Redis stream, replayed after `Last-Event-ID`, frame has an `id:` line. **volatile** = live only, frame has no `id:` line. | Type | Channel kind | Durability | Frame `id:` | Published by | |---|---|---|---|---| | `entity.changed` | `entity` | durable | yes | ddx-api SseBusService `publishEntityChanged` (kernel entity writes); inbound webhook `POST /webhooks/:source` | | `heartbeat` | `system` | volatile | no | ddx-api SseBusService / SseController, every `SSE_HEARTBEAT_MS` and on connect | | `job.done` | `job` | durable | yes | inbound webhook `POST /webhooks/:source` (sse-bus task 010) | | `job.progress` | `job` | durable | yes | inbound webhook `POST /webhooks/:source` (sse-bus task 010) | | `notification` | `notification` | durable | yes | ddx-api SseBusService `publishNotification` | | `presence.changed` | `presence` | volatile | no | ddx-api/src/sse/presence/presence.service.ts (online/offline edge per org) | | `resync` | `system` | volatile | no | ddx-api SseBusService replay, to one connection only | | `settings.changed` | `settings` | durable | yes | ddx-api SseBusService `publishSettingsChanged` / `publishUserSettingChanged` | | `stream.chunk` | `stream` | volatile | no | ddx-api AI demo stream (`POST /orgs/:orgSlug/ai/demo-stream`, seeded transcript) | | `stream.done` | `stream` | durable | yes | ddx-api AI demo stream, once per stream (`complete` or `aborted`) | | `system.notice` | `system` | volatile | no | ddx-api SseBusService `noticeFor` (shutdown, degraded, recovered) | | `system.reconnect` | `system` | volatile | no | ddx-api SseBusService `drain` (api node draining, one per open stream, then the stream ends) | ### Payload fields per type #### `entity.changed` | Field | Type | Required | |---|---|---| | `change` | `created` \| `updated` \| `deleted` \| `acl-changed` \| `attached` | yes | | `entity` | `project` \| `task` \| `thread` \| `message` \| `notification` \| `user` \| `organization` \| `member` | yes | | `entityId` | string (non-empty) | yes | | `parentId` | string (non-empty) | no | | `version` | string (non-empty) | no | | `audience` | object | no | | `audience.userIds` | string (non-empty)[] | no | | `audience.roles` | string (non-empty)[] | no | #### `heartbeat` No payload fields beyond the envelope. #### `job.done` | Field | Type | Required | |---|---|---| | `jobId` | string (non-empty) | yes | | `source` | string (length 1..64) | yes | | `outcome` | `succeeded` \| `failed` | yes | | `audience` | object | no | | `audience.userIds` | string (non-empty)[] | no | | `audience.roles` | string (non-empty)[] | no | #### `job.progress` | Field | Type | Required | |---|---|---| | `jobId` | string (non-empty) | yes | | `source` | string (length 1..64) | yes | | `percent` | number 0..100 | yes | | `stage` | string (length 1..64) | yes | | `audience` | object | no | | `audience.userIds` | string (non-empty)[] | no | | `audience.roles` | string (non-empty)[] | no | #### `notification` | Field | Type | Required | |---|---|---| | `notification` | object | yes | | `notification.id` | string (non-empty) | yes | | `notification.kind` | string (non-empty) | yes | | `notification.payload` | record<string, unknown> | yes | | `notification.createdAt` | ISO datetime | yes | #### `presence.changed` | Field | Type | Required | |---|---|---| | `presence` | object | yes | | `presence.userId` | string (non-empty) | yes | | `presence.state` | `online` \| `offline` | yes | #### `resync` | Field | Type | Required | |---|---|---| | `reason` | `buffer-trimmed` \| `invalid-id` \| `replay-failed` | yes | #### `settings.changed` | Field | Type | Required | |---|---|---| | `key` | string (non-empty) | yes | | `version` | int ≥ 0 | yes | #### `stream.chunk` | Field | Type | Required | |---|---|---| | `streamId` | string (non-empty) | yes | | `seq` | int ≥ 0 | yes | | `text` | string | yes | | `speaker` | string (non-empty) | no | | `origin` | `seeded-transcript` | yes | | `sourceId` | string (non-empty) | yes | #### `stream.done` | Field | Type | Required | |---|---|---| | `streamId` | string (non-empty) | yes | | `chunks` | int ≥ 0 | yes | | `origin` | `seeded-transcript` | yes | | `sourceId` | string (non-empty) | yes | | `outcome` | `complete` \| `aborted` | yes | #### `system.notice` | Field | Type | Required | |---|---|---| | `kind` | `shutdown` \| `degraded` \| `recovered` | yes | | `retryMs` | int 0..300000 | no | #### `system.reconnect` | Field | Type | Required | |---|---|---| | `retryMs` | int 0..300000 | yes | ## Channel kinds A client may only narrow by kind (`?kinds=`) and entity name (`?entities=`); `system` frames always pass the filter. | Kind | Address | Event types | |---|---|---| | `org` | `org:<orgId>` | — | | `user` | `user:<orgId>:<userId>` | — | | `entity` | `entity:<orgId>:<entityName>` | `entity.changed` | | `stream` | `stream:<orgId>:<streamId>` | `stream.chunk`, `stream.done` | | `notification` | `notification:<orgId>[:<userId>]` | `notification` | | `settings` | `settings:<orgId>` | `settings.changed` | | `system` | `system:<orgId>` | `heartbeat`, `resync`, `system.notice`, `system.reconnect` | | `presence` | `presence:<orgId>` | `presence.changed` | | `job` | `job:<orgId>:<jobId>` | `job.done`, `job.progress` | Entity names (`entity.changed`, `entity:` channel): `project`, `task`, `thread`, `message`, `notification`, `user`, `organization`, `member`. ## Webhooks ### Headers (inbound and outbound) | Role | Header | |---|---| | signature | `X-DDX-Signature` | | timestamp | `X-DDX-Timestamp` | | eventId | `X-DDX-Event-Id` | | source | `X-DDX-Source` | MAC = HMAC-SHA256(source secret, `<ts>.<eventId>.<rawBody>`), sent as `sha256=<hex>`. Order: MAC → skew → source whitelist → nonce claim. | Limit | Value | |---|---| | Max clock skew | 300 s | | Nonce TTL | 900 s | | Outbound attempts | 6 | | Backoff per attempt | 0 ms, 2000 ms, 4000 ms, 8000 ms, 16000 ms, 32000 ms | ### Inbound types (`POST /webhooks/:source`) Body `{ orgId, type, payload }`. The body never sets `userId` or `audience`. #### `entity.changed` | Field | Type | Required | |---|---|---| | `payload.change` | `created` \| `updated` \| `deleted` \| `acl-changed` \| `attached` | yes | | `payload.entity` | `project` \| `task` \| `thread` \| `message` \| `notification` \| `user` \| `organization` \| `member` | yes | | `payload.entityId` | string (length 1..128) | yes | | `payload.parentId` | string (length 1..128) | no | | `payload.version` | string (non-empty) | no | #### `job.done` | Field | Type | Required | |---|---|---| | `payload.jobId` | string (length 1..128) | yes | | `payload.outcome` | `succeeded` \| `failed` | yes | #### `job.progress` | Field | Type | Required | |---|---|---| | `payload.jobId` | string (length 1..128) | yes | | `payload.percent` | number 0..100 | yes | | `payload.stage` | string (length 1..64) | yes | ### Outbound types Org-wide durable types only, delivered without `userId` and `audience`: `entity.changed`, `job.done`, `job.progress`, `settings.changed`.
Generated by scripts/sse-catalog.mjs — do not hand-edit. Source:
packages/ddx-contracts/src/{sse,sse-channels,sse-webhooks}.ts. Regenerate:node scripts/sse-catalog.mjs; gate:node scripts/sse-catalog.mjs --check.
Every event carries these fields (envelope version v: 2). The hub assigns v, id, ts and channel. userId set = delivered to that user only, inside orgId.
Heartbeat cadence: 15000 ms. Replay depth per org: 10000 events. Resync reasons: buffer-trimmed, invalid-id, replay-failed.
12 types, sorted by name. durable = written to the per-org Redis stream, replayed after Last-Event-ID, frame has an id: line. volatile = live only, frame has no id: line.
entity.changedheartbeatNo payload fields beyond the envelope.
job.donejob.progressnotificationpresence.changedresyncsettings.changedstream.chunkstream.donesystem.noticesystem.reconnectA client may only narrow by kind (?kinds=) and entity name (?entities=); system frames always pass the filter.
Entity names (entity.changed, entity: channel): project, task, thread, message, notification, user, organization, member.
MAC = HMAC-SHA256(source secret, <ts>.<eventId>.<rawBody>), sent as sha256=<hex>. Order: MAC → skew → source whitelist → nonce claim.
POST /webhooks/:source)Body { orgId, type, payload }. The body never sets userId or audience.
entity.changedjob.donejob.progressOrg-wide durable types only, delivered without userId and audience: entity.changed, job.done, job.progress, settings.changed.
adr/
deploy/
research/