Cross-User Sync¶
Layer: frontend
Status — shipped (2026-08-29): the transport is live.
GET /version, the commitversionfield, and the client poller (frontend/cross-sync.js) are all in place and wired intoapp.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 bymutation_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
dispatchMutationseam 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 againstlastSeen, 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 inapp.jsthat receive aversionin their response: node create (openAddNodeDialog), node update (openUpdateNodeDialog,saveNodeNote), and relation add/remove (openCreateRelationDialog,_aidDoCommit) — to setlastSeen[domainBase]to the commit's own returnedversion. Delete flows return noversion(see piece 2 above), so they are not wired here.- On a detected advance that is not deferred, it synthesises one coarse
external-syncoutcome and callsdispatch(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¶
- 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.) - 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
lastSeenbehind so the same change is re-detected and applied on a later poll.isEditing()checks the generic-interaction-dialog overlay'sopenclass. 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. - Own-change correlation via commit-returns-version. Every mutation commit
response carries the resulting
version;noteOwnCommitsetslastSeento 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. - Poll only when the tab is visible. The poll loop no-ops while
document.hiddenis true, and resumes with an immediate poll onvisibilitychangewhen 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;lastSeenis 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
dispatchMutationcall 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.jscovers 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 fakefetchVersion/dispatch/isEditing.tests-frontend/cross-sync.wiring.test.jspins theapp.jswiring (interval, visibility gating, dialog-close hook, the commit call-sites'noteOwnCommitcalls). - Backend TEST-UNIT (pytest):
tests/test_version_endpoint.pycoversGET /version's auth gate and counter value;tests/test_bl_arch_004_*cover the underlying_cache_versioncounter semantics. - TEST-CONTRACT: the transport only calls
dispatchMutation— it does not touchmutation-refresh.jsinternals 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.
Related files¶
frontend/cross-sync.js— the poller (pure logic, dual-export).frontend/app.js—_startCrossSync()wiring + thenoteOwnCommitcall-sites.backend/api/version_router.py— theGET /versionendpoint.backend/persistence/mutation_store.py— the underlying_cache_versioncounter (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).