Skip to content

View Refresh

Layer: frontend

Status — Wave 3a done (2026-08-29): the engine is live and the CRUD core, bulk imports, and the admin editors + IAM are migrated onto it. Node CRUD, the relation family, imports (import-model / import-data), and now IAM grant/revoke (iam), rulebook rule save/delete (rulebook), and property-catalog save (property-catalog) all call EidosCore.dispatchMutation. Migrating the admin writes fixed a real gap — a rulebook rule change now refreshes the open node's inherited/propagated properties (it did not before). The legacy _postCommit function and the eidos:tree-refresh CustomEvent bridge are now DELETED — the per-tick coalescing + _refreshTreeFromServer's _initGen guard replace the old commitGen race guard. The 02.01 ratchet is at its floor for these: _postCommit and eidos:tree-refresh are both 0. Wave 3b then built the deferred-mode stale affordance: a deferred surface that is open when a related change lands elsewhere renders an accessible "stale — refresh" badge (via onStale + _mountStaleBadge) instead of silently showing old data — wired first to the Rule Editor. Remaining later waves: IIS/interfaces, search, and the rest of the report-like admin panels. The tree-chips reconciler still computes its own counts on refetch; it starts consuming the Metrics Engine catalog in that engine's Wave 3.

Overview — the canonical owner of post-mutation screen refresh

When a user creates, updates, imports, adds, or deletes something, the on-screen data must become correct without a browser reload. The View-Refresh engine (frontend/mutation-refresh.js) is the single canonical owner of that responsibility — the frontend counterpart to the backend's canonical engines (mutation_store, graph_merge, eidos_loader).

It exists because that responsibility had drifted across eight parallel refresh mechanisms and several duplicated per-call-site implementations of the same mutation (relation-create was refreshed three different ways depending on which dialog started it). That is a violation of the canonical-engines invariant (GCF Contract 02.01, R20–R24). This engine consolidates the responsibility behind one entry point so that no view is ever silently left stale and no two call-sites refresh the same change differently.

The engine is loaded from index.html after app.js, so it augments window.EidosCore (adding its methods) rather than being clobbered by app.js's own EidosCore setup.

The contract — declare the change, not the views

Every mutation, after a green commit, calls exactly one function:

window.EidosCore.dispatchMutation(outcome)

The outcome is a MutationOutcome — a declaration of what changed, never a list of which views to poke:

{
  kinds: [ /* one or more of the closed vocabulary below */ ],
  domainBase,                    // which domain the mutation targeted
  affects: {
    nodes:    [ids],             // nodes whose data changed
    parents:  [ids],             // parents whose child-set changed
    relations:[ids],             // relations added / removed
    treeStructure: bool          // insert / remove / move in the tree
  },
  optimistic?: (ctx) => void,    // synchronous local patch; applied at once, overwritable
  origin                          // call-site tag, for telemetry / debugging
}

kinds is a set. Most mutations are single-kind; some are composite — a relation-delete that also removes a document emits ['relation','document']; an importer that both creates a domain and fills it emits ['import-data','import-model'].

The closed change-vocabulary

Adding a view never touches this list; adding a new kind of change is a deliberate, reviewed extension. The eleven kinds:

kind meaning
node node create / update / delete
relation relation add / remove
property-value node engineering data changed (drives propagation + inventory)
property-catalog catalog definition changed (global)
rulebook a rule changed (global; also invalidates inherited properties)
iam access rule changed (global; badges, team, tree-banner)
document document attach / detach
import-model bulk content into an existing domain (Excel, DocuGraph) — domain/subtree
import-data domain-data import (JSON domain / snapshot) — creates/replaces a whole domain — global/domain
snapshot snapshot list changed
domain-config domain visibility / registry metadata changed

import-model vs import-data are split because their blast radius differs: import-model adds content inside an existing domain; import-data changes the set of domains/trees (so it also invalidates the domain tabs, domain-visibility, the snapshot list, and the domain registry).

Reconcilers — views declare their interest

Each view registers a reconciler for the surface it owns, with a declarative descriptor (not imperative filtering scattered in listeners):

window.EidosCore.registerReconciler('specifications', {
  interestedIn: ['node', 'property-value', 'rulebook'],
  scope: 'node',          // dirty only when the outcome touches the shown node
  mode: 'live',           // open panel → reconcile at once
  getNodeId: () => currentNodeId,   // scope resolvers
}, reconcileFn);

The dispatcher marks a surface dirty when outcome.kinds ∩ descriptor.interestedIn ≠ ∅ and the scope matches:

  • global → always;
  • domaindescriptor.getDomain() equals outcome.domainBase (or the outcome carries no domain);
  • nodedescriptor.getNodeId() is in outcome.affects.nodes.

A reconciler is authoritative: given the affected ids it re-fetches from its own endpoint and re-renders. Reconciles are coalesced per tick (one microtask), so rapid successive mutations produce one fetch per surface, and are idempotent.

The two reconcile modes

  • live — reconcile immediately (used when the surface is mounted, and always for the direct target of the user's own action, e.g. the repo list after the user imports a repo).
  • deferred — for report-like / aggregate / heavy surfaces (model statistics, activity heatmap, audit tables, interface lists). They do not auto-refetch on every mutation even while open; they show a visible "stale — refresh" affordance and reconcile on explicit refresh or next open.

The default rule subsumes both: a surface that is mounted + interested goes live; a surface that is unmounted is marked dirty and reconciles on next open via reconcileDirty(surfaceId). The visible stale affordance on a deferred surface satisfies the UX-orientation contract (15, R26–R33) and the no-silent-failure contract (05): a stale view is marked, never a silent lie.

Data flow

  1. Commit returns 2xx → the call-site builds a MutationOutcome and calls dispatchMutation. This replaces the bespoke _postCommit / loadNodeDetail / refreshTreeChips / _eidosRefreshGraph combinations.
  2. dispatchMutation runs outcome.optimistic?(ctx) synchronously — instant local feedback (the in-place tree/chip primitives become the optimistic building blocks, gathered in one place). The patch may return an array of surface-ids it fully reconciled in place — a successful _insertTreeNodeInPlace produces exactly what a /tree refetch would, so it returns ["tree-structure"] and that surface skips the authoritative reconcile below (preserving the BL-FE-118 in-place optimisation). Returning [] means "reconcile everything".
  3. It partitions the remaining interested surfaces into live (queued, coalesced) vs deferred/unmounted (dirty-flagged).
  4. On the next tick the live queue flushes: each reconciler fetches authoritatively and re-renders, overwriting the optimistic state.

The engine is the single invalidation owner of nodeCache: optimistic writes are provisional, reconcile does the authoritative replace.

Registered CRUD-core reconcilers (Wave 1)

app.js _registerViewRefresh() (called from init) seeds the optimistic ctx with the in-place primitives and registers five reconcilers:

surface-id interestedIn reconcile
tree-structure node full /tree reconcile (_refreshTreeFromServer) — skipped when the optimistic in-place tree edit satisfied it. Relations never restructure the tree, so they are not interested here.
tree-chips node, relation, property-value _loadChipsForCurrentTree + _scheduleTreeChips
detail-panel node, relation, property-value, document loadNodeDetail(shownNode)
graphs relation, document window._eidosRefreshGraph (header Cytoscape + React spider)
documents-list document, relation window._eidosRefreshDocuments — the DocumentsZone owns a private useNodeDocuments fetch that loadNodeDetail does not touch, so it exposes its reload for the engine to poke.

This is the convergence: every relation write refreshes the same set (chips + detail + graphs + documents-list) regardless of which of the three entry points started it, and none of them does a needless full /tree refetch.

Bulk imports (Wave 2)

The five reconcilers also declare interest in import-model and import-data, so a bulk import refreshes everything with one outcome — the tree, the shown node's detail, chips, graphs, and documents-list all reconcile authoritatively (no optimistic patch). An earlier revision wiped the whole nodeCache on import, but that blanked node labels in the Add-Information picker and the breadcrumb (they resolve names from nodeCache), so it was dropped — loadNodeDetail re-fetches any node the moment it is opened, so no full wipe is needed:

  • import-model — relation / property / Excel / product import (content into an existing domain). Outcome carries domainBase + affects.nodes = [selectedNode]
  • treeStructure: true.
  • import-data — a domain-snapshot import (/domain-import/commit). Before Wave 2 this refreshed only the snapshot list, so the tree stayed stale; the import-data outcome now drives the full reconcile.

Admin editors + IAM (Wave 3)

Admin writes name no specific node, so they use global-scoped reconcilers that refresh whatever is on screen:

  • iam (grant/revoke) → tree-access refetches the rule's-domain tree (access banners / effective_role), and detail-on-global-admin reloads the shown node's role badge + team-users.
  • rulebook (rule save/delete) → detail-on-global-admin reloads the shown node so its inherited/propagated properties recompute; tree-chips refreshes.
  • property-catalog (catalog save) → the property-editor reconciler drops the detail-panel hook caches and fires eidos:catalog-updated so the property-editor picker re-derives.

With the admin writes migrated, the legacy _postCommit function and the eidos:tree-refresh CustomEvent bridge are deleted — the last dispatchers were retired here. Staleness from rapid mutations is handled by the engine's per-tick coalescing plus _refreshTreeFromServer's _initGen guard.

Error handling (GCF 05)

  • A reconciler that throws is caught, logged, and does not block the other reconcilers in the same flush.
  • A throwing optimistic patch is caught and logged, and reconcilers still run.
  • Logging itself never throws.

This deliberately mirrors the backend's _post_invalidate_hooks pattern (swallow + log), so one bad surface cannot poison the refresh of the others.

The 02.01 ratchet

Because migration is incremental, both the engine and the legacy mechanisms coexist during Waves 1–4. tests/test_frontend_refresh_canonical.py is the enforcement: it freezes the baseline occurrence counts of the legacy refresh primitives across app.js and the detail-panel plugins. A new occurrence (a fresh bypass) fails the build; migration lowers the baselines toward zero. When every baseline reaches zero, 02.01 is fully satisfied for view refresh. The count is deliberately not line-based, so unrelated churn in app.js never breaks it.

Cross-user seam (not built)

The engine covers only the acting user's own actions. The same dispatchMutation entry point can later be fed by a transport (version-poll / SSE) that synthesizes MutationOutcomes from server change notifications; the reconcilers would not change. No transport is built yet.

  • frontend/mutation-refresh.js — the engine.
  • tests-frontend/mutation-refresh.test.js — behavioural unit tests.
  • tests-frontend/mutation-refresh.wiring.test.js — page-wiring invariants.
  • tests/test_frontend_refresh_canonical.py — the 02.01 ratchet.
  • docs/canonical-view-refresh/design.md — the full design spec.
  • frontend-crud.explanation.md — the preview→commit write path that produces the commits this engine reacts to.