25
Backend
04
Security
08
iOS
07
Infra

Publish trip-id signals over GraphQL subscriptions and filter each device's own echo on the server

ADR-0041 ACCEPTED · 2026-02-15
Publish trip-id signals over GraphQL subscriptions and filter each device's own echo on the server

Absorbs ADR-0042.

Decision

Send a signal and no trip data. A tripUpdated subscription event carries only the trip's id, and a trip-list event carries only the user's id. On a signal, the client pulls through its normal read path.

The server publishes each signal to a Redis pub/sub channel per trip, or per user for the trip list, after the database transaction commits. Every node subscribes to the channels its connected clients need and fans each message out through an in-process broadcast channel. A failed publish is logged and never fails or rolls back the write. Postgres is the source of truth and delivery is best effort. The subscription runs over HTTP multipart responses, which Apollo iOS supports, and a WebSocket endpoint serves the same resolvers.

Drop echoes on the server, per connection, by device id. The publisher writes the originating device's id into every signal, and each subscription stream drops a signal whose origin matches its own connection's device id. Both ids are the one the server has already checked against the caller's token (ADR-0028). A write the server makes on its own, such as background enrichment, carries a reserved origin that matches no device, so the device whose edit triggered it still hears about it. A signal whose origin cannot be parsed passes through unfiltered.

Why

A change on one device should reach the user's other devices. The client already fetches and caches a trip, and the trip is derived by replaying effects (ADR-0033). A payload richer than an id would give the client another route for trip data with its own consistency rules. An id keeps one read path and leaves the subscription layer ignorant of what a trip contains.

Every subscriber of a trip receives the signal, including the device that wrote it, which would then pull data it already holds. MQTT 5 solves this with noLocal; Redis pub/sub has no filtering, so the filter belongs in our stream. The token already binds a device id, so the server knows origin and subscriber without a new identity scheme. Failing open costs one redundant pull, where failing closed could hide another device's change.

Rejected alternatives

  • The full trip on every change. It ships the whole trip when one field changed and bypasses the client's read path and cache.
  • Deltas. Small on the wire, but the server has to diff replayed states, which couples subscriptions to the effect system, and the client has to merge them.
  • Filter echoes in the client by an origin id in the message. Every client still receives its own echoes, and suppression works only where each client implements it.
  • A transactional outbox with sequence numbers. It buys at-least-once delivery and gap detection, which matters once a missed signal hides someone else's edit in multi-user collaboration. While it only delays the user's own update, it isn't worth the machinery.

Consequences

Every signal costs the client one round trip. A lost signal delays an update and cannot corrupt state, but nothing detects the loss; the client catches up on its next pull. A client cannot suppress another device's signals by claiming its id, because the server takes the id from that client's own token. Filtering is per device, so two connections from one device suppress each other, which is right for one app instance per device; a web client with several tabs would need an identity per connection.