Skip to content

Cross-User Sync

Layer: frontend

Status — shipped (2026-08-29): the transport is live. GET /version, the commit version field, and the client poller (frontend/cross-sync.js) are all in place and wired into app.js. It builds on the canonical view-refresh engine (Waves 0–3, merged) as that engine's designed-in cross-user seam.

Overview — the seam the view-refresh engine was built with

The View Refresh engine (view-refresh.explanation.md) keeps a user's screen correct after their own actions: commit → EidosCore.dispatchMutation(outcome) → reconcilers refresh. But if another user — or another request served by a different uvicorn worker — changes server data, the acting user's screen has no way to know. It stays stale until a manual browser reload. EIDOS also runs multiple workers (WORKERS=4) with per-worker in-process caches, so the same gap is visible from the server side as multi-worker cache incoherence — disk is the truth, but a stale worker cache can still be handed to a client's poll or refetch.

Cross-User Sync is the transport that closes this gap. It does not change the view-refresh engine — dispatchMutation was built as the single entry point precisely so that a server-change transport could become just another caller of it. This is the smallest such transport: a stateless poll, not a push architecture (SSE/WebSocket were deliberately rejected — no long-lived connections, no reconnect logic, no per-worker connection state; ~25s cross-user latency is an acceptable trade for that simplicity).

What already existed (why this transport is small)

  • _cache_version — a monotonic integer counter, one per domain, bumped on every mutation by mutation_store.invalidate() (BL-ARCH-004 slice 3, backend/persistence/mutation_store.py). It is file-based, so it is already cross-process — shared across all workers — and was already the source of truth for backend cache coherence.
  • The view-refresh engine's dispatchMutation seam and its canonical live reconcilers (tree-structure, tree-chips, detail-panel, graphs, documents-list).

The transport adds three small pieces on top: one read endpoint, one field on commit responses, and one client poller.

The three pieces

1. GET /version — new, read-only

backend/api/version_router.py exposes the running backend's domain _cache_version counter:

{ "version": 43 }

It is auth-gated exactly like every other route (get_auth_context) and adds no new persistence or write path — it reads mutation_store.get_version(). Each EIDOS backend serves one domain, so the frontend calls it once per open domain (apiFetch('/version', base) — primary direct, secondary via the cross-domain proxy), rather than a single aggregate call across all domains.

2. Commit responses carry version — new, trivial

The mutation-commit path already bumps and therefore knows the new counter value; the node /commit, node-update /commit, and relation /commit responses now include it as a version field alongside their existing id/etag-style fields (backend/api/mutations_router.py). No behaviour change — one extra field.

A delete, by contrast, returns a bare 204 with no response body, so there is no version for the deleting user's own client to correlate against — but this does not weaken the transport: mutation_store.invalidate() bumps the shared _cache_version counter on every mutation, deletes included, so other users' clients still detect the delete on their next /version poll exactly like any other external change. Only the deleting user's own client forgoes the same-tick self-correlation a create/update gets; it simply sees its own delete as an ordinary (harmless, idempotent) external-sync refresh on the next poll.

3. frontend/cross-sync.js — new, ~50 lines, dual-export

The only new frontend file, loaded from index.html after app.js (like the view-refresh engine itself). It owns:

  • lastSeen — a map {domainBase → version}.
  • syncDomain(domainBase) — fetches that domain's /version, compares against lastSeen, and on the first sight of a domain just records a baseline (never synthesizes a dispatch for it).
  • noteOwnCommit(domainBase, version) — wired at the 5 commit call-sites in app.js that receive a version in their response: node create (openAddNodeDialog), node update (openUpdateNodeDialog, saveNodeNote), and relation add/remove (openCreateRelationDialog, _aidDoCommit) — to set lastSeen[domainBase] to the commit's own returned version. Delete flows return no version (see piece 2 above), so they are not wired here.
  • On a detected advance that is not deferred, it synthesises one coarse external-sync outcome and calls dispatch (EidosCore.dispatchMutation).

app.js's _startCrossSync() wires it up: a setInterval poll every 25s across all open domain trees, gated on !document.hidden, plus an immediate poll on visibilitychange (tab refocus) and on dialog-close (window.__eidosCrossSyncPoll).

It is important that this file only ever calls dispatchMutation — it does not import, extend, or otherwise touch mutation-refresh.js internals. The canonical engine (GCF 02.01) has exactly one entry point for a reason: adding a new source of change notifications (this poller) is a matter of calling that entry point, never of teaching the engine a new code path.

The four settled design decisions

  1. Auto-refresh, not a badge. An external change auto-refreshes the affected live surfaces (coarse, domain-level) — there is no "stale — refresh" affordance for cross-user changes. (The Wave 3b deferred-mode stale badge is unrelated and remains for its own in-app, report-like surfaces.)
  2. Defer while editing. If a mutation dialog (or other active edit) is open when an external change is detected, the poller skips applying it — it leaves lastSeen behind so the same change is re-detected and applied on a later poll. isEditing() checks the generic-interaction-dialog overlay's open class. Closing the dialog triggers an immediate poll (__eidosCrossSyncPoll), so there is no ~25s wait after finishing an edit. This guarantees a half-finished action is never yanked out from under the user.
  3. Own-change correlation via commit-returns-version. Every mutation commit response carries the resulting version; noteOwnCommit sets lastSeen to exactly that value. A user's own edits therefore never trigger a redundant "external" refresh of themselves — which would otherwise, ~25s later, force a coarse full /tree-style refetch and undo the in-place optimisations the engine already applied. A genuinely concurrent other-user change (a version higher than the one just recorded) is still detected on the next poll — no change is swallowed.
  4. Poll only when the tab is visible. The poll loop no-ops while document.hidden is true, and resumes with an immediate poll on visibilitychange when the tab regains focus. No point refreshing a hidden tab; it also caps server load to one poll per visible client per interval.

Why this also fixes multi-worker cache incoherence

_cache_version is a shared file-based sentinel, so whichever of the four workers happens to serve a client's /version poll reports the true latest value, and the client's subsequent authoritative refetch reads from disk (the truth) rather than from any one worker's in-process cache. The two problems — "another user changed something" and "another worker's cache is behind" — are the same signal from the client's point of view, and this one transport closes both.

Error handling & robustness (GCF 05)

  • Poll failure (network / 5xx): caught and swallowed in syncDomain; lastSeen is left unchanged so nothing is missed — the next successful poll catches up.
  • Editing in progress: skip apply, do not advance lastSeen; an immediate poll fires on dialog-close.
  • Tab hidden: polling pauses; resumes (immediately) on focus.
  • The synthesised dispatchMutation call goes through the engine's existing reconciler error isolation — a throwing reconciler still cannot poison the others.
  • Clock/skew is irrelevant: the comparison is on a monotonic integer, never on wall-clock time.

Security (GCF 09)

GET /version requires the same bearer auth as every other endpoint and returns only the calling backend's own domain counter. The value is an opaque monotonic integer — it reveals only that the domain changed and how many times, never what changed or who changed it. No new write path; nothing user-supplied is persisted.

Testing (GCF 04)

  • Frontend TEST-UNIT (vitest): tests-frontend/cross-sync.test.js covers the pure poller logic — version compare (advance vs. no-advance), first-sight baselining, own-change suppression, defer-while-editing, and the coarse outcome shape — against a fake fetchVersion/dispatch/isEditing. tests-frontend/cross-sync.wiring.test.js pins the app.js wiring (interval, visibility gating, dialog-close hook, the commit call-sites' noteOwnCommit calls).
  • Backend TEST-UNIT (pytest): tests/test_version_endpoint.py covers GET /version's auth gate and counter value; tests/test_bl_arch_004_* cover the underlying _cache_version counter semantics.
  • TEST-CONTRACT: the transport only calls dispatchMutation — it does not touch mutation-refresh.js internals or any legacy refresh primitive, so the 02.01 ratchet (tests/test_frontend_refresh_canonical.py) is unaffected by this feature.
  • No new UI surface, so no additional A11y/responsive test is needed — the auto-refresh reuses the same reconcilers view-refresh already renders and tests.

Out of scope / future

  • Precise change lists (the server telling the client exactly what changed, for a surgical rather than coarse refresh) — deliberately excluded to keep the transport simple.
  • Push (SSE/WebSocket) — excluded; the owner's explicit constraint was not to complicate EIDOS with long-lived connections for a ~25s-latency need.
  • Presence / "who else is here" — out of scope.
  • The poll interval (25s) and the visibility/defer hooks are the only tuning knobs; the interval can be revisited if it proves too slow or too chatty.
  • frontend/cross-sync.js — the poller (pure logic, dual-export).
  • frontend/app.js_startCrossSync() wiring + the noteOwnCommit call-sites.
  • backend/api/version_router.py — the GET /version endpoint.
  • backend/persistence/mutation_store.py — the underlying _cache_version counter (get_version() / invalidate()).
  • tests-frontend/cross-sync.test.js, tests-frontend/cross-sync.wiring.test.js — behavioural + wiring unit tests.
  • tests/test_version_endpoint.py — the endpoint's backend test.
  • docs/canonical-view-refresh/cross-sync-design.md — the full design spec.
  • view-refresh.explanation.md — the canonical engine this transport calls into (see its "Cross-user seam" section).