Change notification

Table of Contents

What the interface does when the data underneath it moves.

A list shows records somebody else may be editing. A form shows a record that may already have changed. Neither can be allowed to say something untrue, and neither may be reloaded out from under a person mid-sentence. This is the language for that, derived from what the Qt client does, written down so it is not reinvented per entity.

Status. The transport is built: the BFF streams the events and holds the shared, routed subscriptions. The registry listens on each collection's canonical subject, <component>.v1.<resource>_events.>, and hands on the time of the change. A published event names its tenant, and its party on a party-owned table, in the envelope (X-Tenant-Id, X-Party-Id). The registry holds one subscription for each entity and tells a session only of an event it may hear: its own tenant, the system tenant, and for a member the parties they work in. An event with no tenant is told to nobody.

The consumer is built for lists, the staff list, the hierarchy and the person page: one stream for the session, useEntityChanges, a gold Refresh button that pulses, marked rows and history entries, and a warning on an edit in progress. The sections below state what each does. Dependent screens and a role grant are not heard yet, and §7 lists what is open.

1. What the Qt client does

Worth stating because it is the accumulated answer, and because two of its three parts are right and the third is a gap.

The list marks itself stale. When the server reports a change to the collection, the Reload action begins to pulse and its tooltip becomes "Data changed on server - click to reload". Nothing reloads on its own.

Changed rows are highlighted. After a reload, rows whose recorded_at is newer than the previous reload are drawn in gold, and the highlight pulses six times at 500 ms. On the first load there is no baseline, so nothing is highlighted. The point is that a person watching a live system sees what changed without re-reading the table.

The detail form is not told. A Qt dialog holding a record does not learn that the record changed elsewhere. Saving then either fails on the optimistic lock or, worse, overwrites. That is the gap, and this document closes it.

2. The rules

Never reload on notification. No screen refreshes because the server said something changed. A list that jumps while somebody is reading it is worse than a list that is briefly out of date, and a form that reloads discards what has been typed. The person decides when to bring changes in.

Notification is quiet, and it is in one place per screen. A pulse on the reload affordance, and a mark on the records that moved. Not a toast, not a badge, not a sound. If a screen has a reload action, that action is where the news appears, because that is where the person would go to act on it.

A changed record is marked where it is. In a list, the row is highlighted. In a detail screen, the record says that a newer version exists, and offers to load it. The mark is on the thing that changed, so a person does not have to work out which of forty rows is the one that moved.

A form that is being edited is never overwritten. If the record changed while somebody was typing, the offer to load is visible but is not taken for them. What they typed is theirs until they discard it.

Saving into a stale record is refused, visibly. The optimistic lock already refuses it. The screen's job is to have said so before the save, so the refusal is a confirmation rather than a surprise.

3. The three states

State What it means What the screen shows
Current Nothing has changed since the data was fetched Nothing. Silence is the normal state
Stale Something in this collection changed The reload action pulses; its tooltip says what happened
Stale rows These particular records changed The rows are highlighted, and the highlight fades after a moment

A screen moves from current to stale, and stays there until the person reloads. It does not move back on its own, because the point of the mark is that it is still there when they look up.

4. The TypeScript shape

The parts, in the order data flows.

4.1. The events exist already

The generator emits an event per entity — country_event and its siblings — and the services publish them on {component}.v1.{resource}_events.{action}, for example refdata.v1.events.countries.updated. They are not something the interface invents.

4.2. A channel to the browser

The browser never reaches NATS, so the BFF carries the news. One long-lived stream per session, not one per screen: a screen opening and closing must not churn connections, and a person with six lists open wants one stream.

GET /api/events           text/event-stream

The envelope already exists in the protocol package. connected, party-changed, session-expired and account-changed are already declared, and the last three are this channel's business, because they are the same question asked by different parts of the interface. The consumer needs one more kind, which is specified here and not yet declared:

{ event: 'entity-changed', data: { component, entity, at } }

4.3. The payload is a time, not a list

This is the decision that matters most, and it is the one that keeps a bulk import from becoming a denial of service on the interface.

A change notification says "this collection changed at time T". For a list, it does not say which records. A person cannot act on a list of a thousand identifiers, and the interface cannot do anything with them that it cannot do better itself. A record page is different, and the section 4.4 says how. What it actually needs to know is one thing: is what I am showing older than what exists? A time answers that. A list does not.

So the payload carries the time of the change. The server's clock and the browser's differ, so the client does not compare that time with its own. It compares the moment the news arrived with the moment the load that produced its data began, both on the browser's clock. The server's time is kept for the tooltip only.

news arrived 12:00:03  >  load began 11:59:58   →  stale
news arrived 11:59:41  >  load began 11:59:58   →  not stale, and nothing to do

News that arrives while a load is in flight counts, because that load may have read before the change. The cost is one refresh that finds nothing new.

Coalescing falls out of that for free. A thousand events from one import all carry a time after the client's load, so the first makes the screen stale and the remaining nine hundred and ninety-nine change nothing. They are not deduplicated by a special case; they are idempotent by construction. There is no ceiling to choose and no set to bound, because no set exists.

The rows that changed are worked out after the reload, not before it. The Qt client marks the rows whose recorded_at is newer than its last reload, which compares the server's clock with its own. The web compares each row with the same page read before: a row that was not there, or whose content is not what it was, is marked. It uses no clock, and the first read of a page marks nothing. The events name a collection, so the records are identified from the data rather than from the notification.

4.4. A few keys, for a record page

A list shows many records and goes stale for any change to its entity. A record page shows one, and a pulse for another person's record teaches the reader to ignore the pulse. Found in the live test: Diane changed her job title, and Hazel's own page pulsed.

Every event already carries the key of its record in the payload. The BFF now passes it on:

{ event: 'entity-changed', data: { component, entity, at, keys?: [{ username: 'diane.kay' }] } }
  • The BFF joins the events of one entity that arrive within 150 ms into one message for each session that may hear them, so a bulk import is one message and not a thousand. The window is fixed from the first event and is not extended, so a very long burst is a series of messages, one for each 150 ms, and not one.
  • A message names up to 20 records. With more, or when any event had an empty key, it names none. keys is then left out, and a reader treats every record as changed.
  • A list is stale once, for any change, as before. It also uses keys to say where the change fell; see the section 4.5.
  • A record page names its record in its watch, by the entity's declared key, such as { username }. It goes stale when the message names its record or names none.
  • The tenant and party filter is unchanged. The keys of a message are only those of events the session may hear.

A watch that cannot state its record's key before it has read it, such as a contact record that is keyed by its email, leaves record out and stays stale for any change.

4.5. Where a list shows a change

A list goes stale for any change to its entity, because a record that is not on the page can still move a row onto it or off it, through the sort, the search or the total. A stale Refresh does not tell the reader where to look, so the list uses the keys to place the change. It keeps the keys of the changes heard since its load, and compares them with the rows on the page, which it identifies by the source's recordKey:

What the list heard What it shows
A few records, all on the page Those rows pulse.
A record that is not on the page A strip along the top of the table.
Changes too many to name (no keys) The same strip.
A list whose rows cannot be told apart The same strip.

The strip says that something changed which the page cannot show. When the changes name rows on the page and a record off it, both show. A list keeps at most 100 records by name, and past that it only knows that something changed. The Refresh button stays gold in every case, and the list never reloads by itself.

4.6. Subscriptions are shared, and routed

One subscription per screen would be wrong twice over: a deployment with a hundred people watching accounts would open a hundred NATS subscriptions to the same subject, and every one of them would carry the same message.

So a subscription is shared, keyed by the tenant and the entity, and the BFF keeps a map from that key to the sessions watching it:

(tenant, component.entity)
      │
      ├── session A   ← has a list of countries open
      ├── session B   ← has a list of countries open
      └── session C   ← has a country open

Filtered by tenant, and that is not decoration. Tenancy is the boundary the whole system is built on: a change in one tenant's accounts is not news in another's, and telling a session about it would leak the fact that it happened. The subject is the same for every tenant, so every event arrives at the registry and each carries its tenant in the envelope. A session is told only of its own tenant and the system tenant, whose rows are shared, and a member only of the parties they work in. An administrator hears every party of the tenant.

Reference counted, and torn down when the last watcher goes. The map entry exists while at least one session watches and is removed when the last one stops, so a subscription is not held open for an entity nobody is looking at. A person who navigates away stops watching that entity and keeps watching nothing else until they navigate somewhere that watches something.

A session watches what is on screen. A list of countries watches country. A country's detail screen watches that record's entity, because the record it is showing is in it. A screen that watches nothing is not subscribed to anything, and that is the normal state of most of the interface.

4.7. What is out of scope, for now

Only the entity itself. A change to a country is news to the country list. It is also news to a book that references one, and to anything showing a country's name by lookup — and none of those are told, because the event names the entity that changed and not the entities that depend on it. This is the right thing to leave for later: the dependency graph is real, it is not derivable from the notification, and getting it wrong means a screen that refreshes constantly for reasons nobody can see.

It follows that a screen can be quietly out of date about something it shows by reference. A book list showing a country's name will keep showing the old name until something else makes it reload. That is a known limitation rather than a bug, and it is recorded here so it is not mistaken for one later.

4.8. Subscribing per entity, in the interface

// The browser holds one stream. A screen names what it shows, and the hook says
// whether that is older than a change that has been heard.
const load = useLoadState([['account', username], ['timeline', 'person', username]]);
const { stale, changedAt } = useEntityChanges(
    [{ component: 'iam', entity: 'accounts' }],
    load,
);

The entity is the events collection as the services name it, such as accounts. stale is true once news has arrived after the load began, and stays true until the screen loads again. changedAt is the server's time of the newest change, for the tooltip. A screen outside the provider hears nothing. The names a refdata list watches come from the registry (events on each resource).

4.9. What the screen does with it

The reload affordance carries the news.
  RefreshButton: gold and pulsing while stale, a tooltip that says what happened
  and when, and a click that reads again.

The rows that moved are marked, worked out from the reload, not from the event.
  useChangedRows compares each row with the same page read before. A whole table
  flashing says nothing; the rows that changed say everything.

An edit in progress learns of a newer version and is never reloaded.
  useNewerVersion keeps the version the edit started from. A warning lists the
  fields that moved and offers Keep my edit or Take the new values. A save claims
  the version the edit started from and is refused when the record has moved.

5. The visual language

Built. These are the treatments, so no screen invents a second language.

Small, and the same everywhere, because a notification language that varies by screen is not a language.

Element Treatment Why
Reload affordance, stale Gold, with a slow pulse of four cycles, then gold until used It is always in the same place, so a person learns one location
Reload tooltip, stale The words change to say what happened A pulse says "look here"; the tooltip says why
A changed row A gold left edge and three pulses, gone after four seconds A permanent highlight becomes decoration; a fading one stays news
A detail screen whose record moved A warning above the form that names the fields and offers Keep my edit or Take the new values The form cannot tint itself without hiding the fields

Colour. One gold (--color-stale, as in ores.qt) for "changed", used only for this. It is not the colour used for errors, not the colour used for warnings, and not the accent used for primary actions, because a thing that means one thing must not mean four.

Motion. A pulse is slow — 1.8 seconds a cycle — and stops after four cycles. A fast pulse reads as an error, and a pulse that never stops becomes wallpaper within a day.

Reduced motion. A person who has asked their system for less motion gets the gold and a still ring and no pulse. The information is the same; only the movement is dropped.

6. What the interface must not do

Recorded because each is the tempting shortcut.

Reload the list automatically. Discards scroll position, and any selection. The Qt client got this right by not doing it.

Show a toast per change. A busy system produces a stream of them, and a toast that cannot be dismissed is a toast that is ignored.

Reload the detail form. It discards what has been typed, and there is no undo.

Reload the list while a row's menu is open, or while a dialog is up. The person is mid-action on a specific record, and the row under their cursor moving is the worst moment to move it. Deferred, not dropped: the news is still there when the dialog closes.

Mark a row that did not change. A whole-table flash is the same as no flash, and it teaches people to ignore the mark.

7. Open questions

Named rather than assumed, because they change the shape.

Does the BFF subscribe per session, or once per deployment? Decided: one subscription for each tenant and entity, shared by the sessions that watch it, and closed with the last watcher.

Who may listen to which tenant and party? Decided: the subscriber enforces it from the envelope headers, and the protocol specification defines them. The broker does not enforce it, so a client with a certificate can still hear every tenant. Moving the tenant into the subject, or a broker account for each tenant, would change the grammar and is a later option. ores.qt lives outside this repository and still listens without filtering.

How does a screen hear that a role was granted? It cannot: the grant and the revocation are published inside the service only. A reporting line has no event of its own either.

How are changes to something a screen is not showing handled? A country referenced by a book, changed while somebody is editing the book. The event names the country; the book screen is affected and is not watching countries. Out of scope for now, as above, and the dependency graph is the work.

How long does a watcher survive? A session that navigates away stops watching, but a session that is idle with a list open keeps its subscription alive. Whether an idle session should keep one open, and for how long, is a question about cost rather than correctness; the honest default is to keep it, because tearing it down and back up is how a screen ends up missing a change.

Emacs 29.3 (Org mode 9.6.15)