Live updates
A live update keeps a detail view current while other people work on the same record. It is a GraphQL subscription that announces that something changed; the client reacts by re-running its ordinary query.
This page describes the pattern, the one shipped implementation (journals), and the specified-but-not-yet-built one (issue attributes). For subscription mechanics - subscribe, update, trigger - see GraphQL.
The contract
The broadcast carries no content. Every client refetches.
One payload is built per event and read by every subscriber, so anything inside it has already escaped per-user authorization. Keeping content out of it means visibility, ordering and formatting stay on the ordinary query path, which is the only place they are already correct.
What a payload may carry:
| Carries | Why |
|---|---|
Identifiers (journalId, changedAttributes) |
Lets a client decide whether it cares, without learning a value |
actorId |
Who caused the change - for attribution, and for deciding whether to flash a highlight at the person who already knows |
| Enough state to re-authorize | The subscription needs it to decide who may be told at all |
What it must not carry: attribute values, rendered text, names, or anything whose visibility or formatting depends on who is reading.
Three forces make this non-negotiable rather than a preference:
- Visibility is per-subscriber.
estimated_hoursneeds:view_estimated_hours; a custom field needsCustomField#visible_by?; a related entity has its ownvisible?. A shared payload cannot express that. - Editability moves with the value. A new status changes the allowed transitions; a new tracker changes
safe_attribute_names. A payload that updates the label but not the workflow leaves a stale control behind correct text. - Delivery is not ordered. Two rapid changes can arrive out of order across cable workers. A value payload can leave permanently wrong data on screen; a refetch always converges.
Authorization
A connection outlives the permissions it was opened with, so every event is re-authorized, not just subscribe:
| The three checks every live-update subscription makes | |
|---|---|
subscriber must re-read the user row rather than trust context[:subscriber]:
| Why the reload matters | |
|---|---|
When access is lost the subscription simply goes quiet. There is no "you can no longer see this" event; the user finds out on their next action.
Triggering
Triggers run from after_commit on the hottest write paths in the application, and errors raised from after_commit propagate into the request that saved the record. Always rescue and log:
Two rules that follow:
- Never broadcast an empty change. A trigger that fires on every save wakes every subscriber for nothing. Compute what actually changed first and return early when the set is empty.
- Do not gate on a feature flag. The lookup costs more than the trigger does when nobody is subscribed, and the signal is useful to consumers beyond the view that prompted it.
Bulk operations fan out one trigger per record. That is accepted; it matches what journal creation already does.
Frontend
useLiveUpdates (app/frontend/src/shared/composables/useLiveUpdates.ts) turns a subscription into one debounced refetch. It already handles the burst case and a hidden tab (refresh is deferred and caught up on return), so a feature-level composable stays thin:
| app/frontend/src/issue_detail_v2/composables/useJournalsLiveUpdates.ts | |
|---|---|
Rules:
- Subscribe once per view, in the store - never inside a component that repeats. One subscription per open detail, regardless of how many fields render from it.
- The actor refreshes too. See below - this is the rule, not a per-feature choice.
- Guard the store against out-of-order responses.
useCommentsStorekeeps a monotonically increasingrequestIdand applies only the newest response. Any store a live update refreshes needs the same, because a refetch can land while a later edit is still in flight. - Guard open editors. A field whose editor is open must warn rather than reload under the person using it. Since the actor now refreshes on their own change, this is load-bearing, not defensive: someone can edit a second field while the first field's broadcast is still in flight.
The actor refreshes too
No live update suppresses the echo of the client's own change. The person who made the change refetches along with everyone else.
Suppressing it looks like a free saving and is not:
- The server's answer can differ from what the actor expects. A status change also moves
done_ratioandclosed_on, rolls priority and dates up to the parent, and changes which transitions are offered next. Suppression leaves the person who just edited the record looking at the least accurate version of it. - The payload names a user, not a session. A suppressed feature never updates the same user's second tab or second device.
- A per-feature exception is worse than either answer. Two live updates behaving differently is a rule nobody can hold in their head, and the reader cannot tell a deliberate choice from an oversight.
What it costs, and why that is acceptable: the actor's own action usually refreshes already, so the broadcast adds one debounced refetch on top. useLiveUpdates coalesces bursts, and the store's request guard keeps the late response from winning.
useLiveUpdates has no opt-out to reach for: it took an optional skipRefresh predicate once, and that option is gone rather than merely unused, so the rule cannot be bypassed one feature at a time. actorId stays in the payload for a different job: deciding not to flash a highlight at the person who caused the change.
Shipped: journal changes
issueJournalsChanged keeps the v2 activity panel current.
| Piece | Location |
|---|---|
| Subscription | app/api/easy_graphql/subscriptions/issue_journals_changed.rb |
| Result type | app/api/easy_graphql/subscriptions/results/issue_journals_changed_result.rb |
| Trigger | Journal#broadcast_journals_changed |
| Composable | app/frontend/src/issue_detail_v2/composables/useJournalsLiveUpdates.ts |
Its delivery rule is worth reading as an example of how much care a content-free payload still needs: it delivers when the subscriber can read the journal now, or could read it before this change. The second half is what clears a comment switched to private off the screens still showing it - those readers get the content-free event and refetch it away.
Specified: issue attribute changes
Status: specified, not yet implemented. The implementation plan lives in docs/plans/live_issue_attributes.md.
issueAttributesChanged keeps the v2 attributes panel current, where fields are edited one at a time.
One subscription, not one per attribute
type Subscription {
issueAttributesChanged(issueId: ID!): IssueAttributesChangedPayload!
}
type IssueAttributesChangedResult {
event: String! # "updated"; room for "deleted"
changedAttributes: [String!]! # ["status_id", "assigned_to_id", "cf_12"]
actorId: ID # attribution and highlight suppression; null for system writes
journalId: ID # null when the write opened no journal
}
Per-attribute subscriptions were rejected:
- graphql-ruby matches triggers on exact arguments, so per-attribute means one websocket subscription per attribute per open tab, and that many
updateauthorizations per write. - A subset argument (
attributes: [String!]) cannot work at all - the trigger would have to enumerate every subset clients might have subscribed with. - Attributes move in groups. A status change also moves
done_ratio, andIssue#recalculate_attributes_forrolls priority, dates and done ratio up to the parent. Separate topics would emit four messages where one suffices.
changedAttributes is filtered per subscriber, so two subscribers on the same event can legitimately receive different key sets.
Key vocabulary
Keys are journal prop_key values verbatim - the vocabulary JournalDetail already uses and the history panel already renders. Custom fields force a dynamic string (cf_<id>), so there is no enum to be had and no second vocabulary to keep in sync.
| Panel field | Key | Also moves when |
|---|---|---|
| Assignee | assigned_to_id |
- |
| Status | status_id |
Usually with done_ratio; closing cascades to children |
| Priority | priority_id |
Rolls up to a parent with priority_derived? |
| Due date | due_date |
Rolls up to a parent with dates_derived? |
| Start date | start_date |
Reschedules following issues |
| Progress | done_ratio |
Derived from status, or averaged from children |
| Tracker | tracker_id |
Changes safe_attribute_names for everyone |
| Custom field | cf_<id> |
- |
Broadcast source
after_commit on: :update on Issue, not on Journal. It puts the whole changed set in one place - including custom fields, which a journal-based hook would have to reconstruct from details - and it does not depend on a journal having been opened, so a save that never called init_journal still broadcasts.
| app/models/issue.rb | |
|---|---|
Two existing mechanisms carry the weight:
journalized_options[:non_journalized_columns]is already the right denylist. It dropseasy_last_updated_by_id,easy_status_updated_on,closed_on,root_idand the rest - the columns that move on nearly every save and would otherwise make the broadcast fire constantly for nothing.changed_custom_fields(fromacts_as_customizable) is anattr_readerset insave_custom_field_valuesand cleared only onreload, so it is still readable inafter_commit, and it yields["cf_12"]directly.saved_changesalone misses custom fields entirely, because they live in their own records.
System and derived writes broadcast too. Automations and recalculate_attributes_for go through init_system_journal, and a parent's panel should update from them.
Known gaps
after_commit does not run for writes that bypass callbacks, so these leave a panel stale until the user refreshes:
| Path | What it changes | Notes |
|---|---|---|
Issue#close_children |
status_id, done_ratio on descendants via update_columns |
The only such path inside Issue. It writes a journal by hand right after, so issueJournalsChanged does fire for the descendant - only the attribute broadcast is missing. Add an explicit trigger there if closing a parent should move an open subtask's panel. |
IssuePriority#destroy, IssueStatus, Tracker, IssueCategory reassignment |
priority_id, status_id, tracker_id, category_id via update_all |
Administrative, low frequency. Not worth a trigger unless it proves otherwise. |
User/Group destroy |
assigned_to_id nulled via update_all |
Same. |
None of these are regressions - they are simply outside the hook's reach, and each can be given an explicit trigger later if it matters.
Filtering keys per subscriber
Filtering the key list rather than values keeps the permission surface small and directly testable, and a subscriber who cannot see estimated_hours is not woken when only that changed.
Issue#journalized_attribute_names reads User.current, while subscriber is a separately reloaded object. attribute_names_for_subscriber must bind User.current around that call, or take the user explicitly the way Journal#visible_details(user) does. Getting this wrong is silent - it filters against a stale permission set rather than raising.
Overlap with journals
Every journaled attribute change fires both subscriptions. That is intended: they answer different questions and feed different stores, and they debounce independently.
flowchart TD
A[Someone changes the status] --> B[Issue saves, journal created in the same transaction]
B --> C[issue_journals_changed from Journal]
B --> D[issue_attributes_changed from Issue]
C --> E[Authorized against the private-notes rule]
D --> F[Authorized against the visible key set]
E --> G[Activity panel refetches journals]
F --> H[Attributes panel refetches attributes]
Reusing issueJournalsChanged for attributes was rejected: its gate is the private-notes rule, which is the wrong question for an attribute, and it would make the attributes panel refetch on every comment posted on a busy issue.
Adding a new live attribute
Attributes backed by issues columns and custom fields are covered by the after_commit hook and need nothing. Association-backed ones do not appear in Issue#saved_changes and need their own trigger point calling the same method:
| Attribute | Key | Trigger point |
|---|---|---|
| Watchers | watcher_ids |
Watcher after_commit, scoped to watchable_type == "Issue" |
| Tags | tag_list |
Tagging callback |
| Relations | relations |
IssueRelation after_commit, both ends |
| Spent time | spent_hours |
TimeEntry after_commit, plus ancestors for total_spent_hours |
| Attachments | attachments |
Already journaled; needs an explicit trigger for the panel |
Each costs one line in a callback, one row in the vocabulary table above, and one case in the frontend map. No GraphQL change, no new subscription, no new websocket topic.
Ancestor fan-out deserves care when it arrives: a time entry on a deep subtask should wake every ancestor's panel, which is one trigger per ancestor. Batch it if it shows up in profiling.