Event catalog
There are exactly 7 events:There is no
workspace.invite.expired event. Expiry is lazy — a stale invite simply won’t accept; there is no background sweep and thus no moment to fire it. Capability-change events are deliberately deferred, and resend emits no event (the status stays pending — no lifecycle transition).member.* vs invite.* — two intentionally distinct streams
invite.* tracks the invitation lifecycle; member.* tracks roster sync — any membership row appearing or disappearing. They are NOT redundant, and both are kept.
- On accept, BOTH fire:
workspace.invite.accepted(lifecycle) andworkspace.member.added(roster). Dedupe on the ids in the payload if you consume both. member.*also covers non-invite roster changes that have no invite event:member.removedfires on exactly five paths: a leave, a remove, a remove-from-subtree sweep, user-deletion cleanup, and when a user becomes owner and their existing member row is dropped (owner/member rows are disjoint).member.addedfires on the transfer-demote case (a previous owner demoted into a fresh member row).
member.* in favor of invite.*, you would lose every targeted removal transition (leaves, removals, offboarding) — which is why member.* is the stream to mirror your live seat/roster set.
Deleting a workspace does NOT emit per-member events
This is deliberate. A workspace delete tears down every membership in the subtree at once, so a 500-member tree would fan out 500+ POSTs in a single burst against a delivery layer with no retry and no batching — and every one of them would be redundant with theworkspace.deleted that accompanies it. The five paths that do fire member.removed are all bounded, targeted removals where the row is the only thing that changed and no other event would tell you about it.
Note the asymmetry with user-deletion cleanup: deleting a user does still fire member.removed per row, because there is no workspace.deleted to stand in for it — the workspaces survive, only that user’s seats disappear.
The idempotent already-member accept is a no-op: it marks the invite
accepted (so invite.accepted still fires) but creates no membership row, so no member.added fires.
