Skip to content

feat(conversations): epoch sync and system events for group membership - #425

Merged
codebestia merged 2 commits into
codebestia:mainfrom
noevidence1017:feat/369-epoch-sync-system-events
Jul 30, 2026
Merged

feat(conversations): epoch sync and system events for group membership#425
codebestia merged 2 commits into
codebestia:mainfrom
noevidence1017:feat/369-epoch-sync-system-events

Conversation

@noevidence1017

Copy link
Copy Markdown
Contributor

closes #369

Summary

Membership changes and epoch transitions now go into an ordered, gap-free per-conversation log. A client that missed commits fetches exactly what it missed, replays it in order, and converges on the same epoch as everyone who was online.

Previously a join emitted a fire-and-forget member_joined socket event and nothing else. A client that was offline for it had no way to learn the group had changed, and no notion of an epoch to reconcile against.

Why a separate log

Chat messages are ordered by (createdAt, id). That is fine for a timeline and wrong for group control:

  • A client that applies a join and a leave in the wrong order derives a different key schedule and can no longer decrypt.
  • A timestamp cursor can silently skip an event written slightly out of clock order — and a skipped membership change is indistinguishable, from the client's side, from no change at all.

So group control gets group_control_events with a strictly monotonic, gap-free sequence per conversation, and conversations.epoch records where the group stands. "Am I behind?" becomes an integer comparison; "catch me up" becomes sequence > mine, which cannot skip.

epoch on each row is the value after the event, so a client compares its own epoch against the newest row and knows exactly how far behind it is.

Correctness

Ordering under concurrency. The epoch is bumped with UPDATE ... RETURNING in the same transaction that assigns the sequence. That update row-locks the conversation, so a concurrent join and leave are forced into a real order rather than racing for the same sequence number. The unique index on (conversationId, sequence) is the backstop, not the mechanism.

Atomicity with the membership change. The membership row and its control event are written in one transaction (appendGroupControlEvent accepts the caller's transaction handle). A member committed without the epoch bump that announces them would leave every other client unaware of someone who can now decrypt — precisely the divergence this log exists to prevent. The live broadcast happens only after the commit, so a client reacting to the event always finds the membership already in place.

Last member leaving emits nothing. That deletes the conversation and its control log, so there is nobody left to reconcile and nothing to reconcile against.

System events

Every control event also persists a content_type='system' message and links to it by id, so the timeline and the control log can never disagree. The body is one stable shape:

{
  "type": "group_control",
  "eventType": "member_added",
  "conversationId": "",
  "epoch": 4,
  "sequence": 7,
  "actorUserId": "",
  "targetUserId": ""
}

Like the existing device-change system messages it is stored unencrypted and carries no private content — only who changed what and the resulting epoch.

Live fan-out, to both the optimized room and the plain conversation id:

  • group_system_event — the full control event.
  • epoch_changed{ conversationId, epoch, sequence } for clients that only need to know they must reconcile.
  • new_message — the system message, so existing timeline rendering picks it up with no client change.

Both new types are registered in KNOWN_EVENT_TYPES.

Endpoints

GET /conversations/:id/epoch
→ { "conversationId": "…", "epoch": 4, "latestSequence": 7 }

The cheap "am I behind?" check.

GET /conversations/:id/group-control?sinceSequence=<n>&limit=<n>
→ { "currentEpoch": 4, "latestSequence": 7, "events": [ … ], "nextSequence": 7, "hasMore": false }

Missed events ascending by sequence — the order to replay in. sinceSequence is exclusive, so replaying with the same cursor never re-applies an event the client already has; omitting it returns the whole log, the first-sync path. Page size defaults to 100, clamped to 500. currentEpoch/latestSequence describe where the group is now, so a client knows whether the page finished the catch-up before it even looks at hasMore.

POST /conversations/:id/group-control   { "payload": "<opaque MLS commit>" }
→ 201 { "sequence": 8, "epoch": 5, "eventType": "commit", … }

Members only, payload capped at 64 KiB and stored byte-for-byte. The server sequences group control; it does not interpret it.

All three are membership-gated. The join response additionally carries the resulting epoch and sequence, so the caller needs no follow-up request.

Acceptance criteria

Criterion Where
Missed commits retrievable in order GET /conversations/:id/group-control, ascending by gap-free sequence, exclusive cursor, paged without dropping events
System events reflect membership/epoch changes content_type='system' message per event, linked to the control row, plus group_system_event / epoch_changed / new_message live
Clients converge to the same epoch Epoch bump and sequence assignment serialized on the conversation row; tested by replaying the log and comparing against the live epoch

Testing

apps/backend/src/__tests__/groupControl.test.ts adds 17 tests over an in-memory store: epoch bump and sequence assignment, gap-free monotonicity across events, the system message and its back-link, opaque payload passthrough, failing loudly on a missing conversation, the three broadcast channels, the no-socket-server case, the catch-up read (after-cursor, exclusive cursor, full log, paging without gaps), and two convergence tests — a client replaying from zero lands on the current epoch, and a replaying client lands on the same epoch as one that saw the events live.

conversations.routes.test.ts gains 12 tests for the routes: membership gating on all three endpoints, cursor validation, payload size and emptiness, the sequenced join and leave, the response shape, and the assertion that the last member leaving records no event.

Full backend suite: 427 tests across 42 files, all passing. eslint clean, prettier --check clean.

Migration

apps/backend/drizzle/0001_group_control_events.sql — new enum, the group_control_events table with its unique (conversation_id, sequence) index, and conversations.epoch defaulting to 0. Additive; existing conversations start at epoch 0 with an empty log, which is exactly what a client with no cursor expects.

Note: the audit-logging work for #376 also lands a 0001_ migration. If both merge, one journal entry needs renumbering — the two migrations touch entirely separate tables and do not otherwise conflict.

docs/group-epoch-sync.md documents the log, the ordering guarantees, the socket events and the catch-up protocol.

Membership changes and epoch transitions now go into an ordered, gap-free
per-conversation log, so a client that missed commits can fetch exactly what
it missed and converge on the same epoch as everyone else.

- Chat messages are ordered by (createdAt, id), which is fine for a timeline
  but wrong for group control: applying a join and a leave in the wrong order
  derives a different key schedule, and a timestamp cursor can silently skip
  an event written slightly out of clock order. group_control_events therefore
  carries its own strictly monotonic sequence, and conversations.epoch records
  the resulting epoch.
- Sequencing is serialized on the conversation row: the epoch is bumped with
  UPDATE ... RETURNING in the same transaction that assigns the sequence, so a
  concurrent join and leave are forced into a real order. The unique index on
  (conversationId, sequence) is the backstop, not the mechanism.
- The membership row and its control event commit together. A member written
  without the epoch bump announcing them would leave every other client
  unaware of someone who can now decrypt, which is the divergence this log
  exists to prevent. The live broadcast follows the commit, so a client
  reacting to the event always finds the membership already in place.
- Every event persists a content_type='system' message in one stable shape and
  links to it, so the timeline and the control log cannot disagree. Live
  fan-out emits group_system_event, epoch_changed and new_message, the last so
  existing timeline rendering picks it up unchanged.
- GET /conversations/:id/epoch answers "am I behind?" as an integer compare.
  GET /conversations/:id/group-control returns missed events in replay order
  with an exclusive cursor, so replaying the same cursor never re-applies an
  event. POST /conversations/:id/group-control sequences a client MLS commit;
  the payload is opaque and capped, since the server orders group control
  rather than interpreting it.
- The last member leaving emits nothing: the conversation and its log are
  deleted, so there is nobody left to reconcile.

docs/group-epoch-sync.md documents the log, the endpoints and the catch-up
protocol.
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@noevidence1017 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@codebestia
codebestia merged commit a96266a into codebestia:main Jul 30, 2026
0 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epoch sync + system events for membership

2 participants