Frontend CRUD¶
Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20
956 D1b-1 (2026-10-20): 13 finding(s) fixed — EidosMutations.preview/commit replace the absent previewMutation/commitMutation ops and the apiFetch commit samples, loadNodeDetail full signature, caches are plain objects (not LRU), apiFetchAbsolute IRI branch, eidos-api.js routing pins, app.js/detail-panel.js line pins (also the dynamic first-arg baseline 18 → 17 from #1008). Re-verified against main 421c63ba.¶
Layer: frontend
Overview — Frontend as the CRUD Surface¶
The Frontend CRUD engine is the browser-side layer that owns every user-initiated graph mutation in EIDOS Explorer. It is not a server-side Python module — it is a collection of JavaScript functions and React components in frontend/app.js and frontend/plugins/detail-panel.js that together implement the complete read/write surface of the graph UI.
The engine is responsible for:
- Routing all API calls through the EidosApi layer (
frontend/eidos-api.js), the single canonical HTTP surface that enforces authentication, timeout, cross-domain proxy routing, the canonical error shape, and 401 → login in one place.apiFetch/apiFetchAbsoluteare now thin wrappers overEidosApi.request(...)(4.1a), so a raw browserfetch(outside that layer is a guarded ratchet violation (tests/test_frontend_fetch_ratchet.py). As of 4.1c (#676) the migration is complete for the whole frontend: not onlyapp.jsbut every export/report plugin (iis-module,export-documentation-360,export-reference-whitelist,export-system-breakdown,export-system-context,report-ref-arch) now goes through the layer — the report/download plugins useEidosApi.raw(path, {base, method, headers, body})(the non-throwingResponsepassthrough, no browser-side timeout) so each keeps its own bespoke!resp.okerror copy, response-header reads (Content-Disposition,x-render-legible) and blob handling unchanged, while auth injection and primary-vs-proxy routing move into the one place.iis-module's internal_apiFetchis a thin wrapper overEidosApi.raw. Across all offrontend/**/*.jsthere is now exactly one raw browserfetch(— the sanctioned one insideeidos-api.js— pinned by the ratchet (test_the_only_raw_fetch_in_the_frontend_is_the_canonical_one). A secondary-domain call still passes itsbaseand the layer emits the/api/proxy/{domain}path (e.g. Documentation 360°'s_reportBase(domainBase)hands the layer the domain base instead of hand-building the proxy URL; URL parity is tested intests-frontend/eidos-api.test.js). - Implementing the preview-before-commit gate for every destructive or creative operation (add node, update node, create relation). No mutation reaches the server's write path without a prior
previewround-trip that returns apreviewId. As of 4.3a (#666) this two-phase protocol is owned by one layer —EidosMutations(frontend/eidos-mutations.js), on top of EidosApi. Every/mutations/{node,node-update,relation}/{preview,commit}call goes throughEidosMutations.preview(type, {base, timeoutMs, body})/commit(type, {previewId, extra, kinds, dispatch})/run(...): the layer builds the endpoint from the command type, appliespreviewId != nullas the universal "committable" signal, surfaces a version conflict aserr.status === 409unchanged, and firesEidosCore.dispatchMutationafter a commit whenkindsis given. A raw endpoint literal to those routes anywhere else is a ratchet violation (tests/test_frontend_mutations_layer_ratchet.py). The show step and the error display (dialog vs inline field vs toast) and any bespoke post-commit work stay with the caller — they are UI, not protocol. The file-import two-phase (domain-import,mutations/import/*,admin/*/import) is a different protocol (a dry-run summary + a re-sent FormData commit with nopreviewId) and is deliberately not routed through EidosMutations (owner decision, #666). - Maintaining four browser-side caches —
nodeCache,childrenCache,labelMap, andparentMap— plain objects with no eviction or size cap (EidosNodeStore._nodes = {},EidosTree._parentOf/_childrenOf = {},labelMap = {}inapp.js), kept consistent after commits via optimistic patches followed by authoritative reloads. As of hardening 5.2 (5.2b #891 for the node store, 5.2d–5.2e #899/#902 for the tree),nodeCache/childrenCache/parentMapare live module-level aliases insideapp.jsto the canonicalEidosNodeStoreandEidosTreebacking maps;labelMapremains anapp.js-owned helper. Reads useEidosNodeStore.get(uid)/EidosTree.parentOf(uid)/EidosTree.childrenOf(uid)from any file outsideapp.js; writes go throughEidosNodeStore.upsert/EidosNodeStore.patch/EidosTree._cacheChildren. As of the view-refresh engine (Wave 1), that "optimistic patch → authoritative reconcile" is no longer hand-coded per call-site: every CRUDonConfirmcallsEidosCore.dispatchMutation(outcome)and the canonical engine drives the refresh. See the View Refresh engine page. - Enforcing invariant I-2 from the CRUD model: the Confirm button is disabled whenever
previewIdis falsy orerrorsis non-empty. This check is performed inside_gidShowPreview(line 9544; the check is at line 9578):primaryBtn.disabled = !!(res.errors?.length) || !res.previewId.
The engine is the only place that ever calls the /mutations/* endpoint family. The detail panel plugin (detail-panel.js) uses the same EidosMutations.preview('node-update', …) / EidosMutations.commit('node-update', …) calls for inline edits (note field, engineering properties). There is no second mutation path.
apiFetch — Full Signature and Semantics¶
Since 4.1a (#674): the routing / auth / timeout / error semantics below live in the canonical EidosApi layer (
frontend/eidos-api.js).apiFetchis a thin wrapper that callsEidosApi.request(path, {base, timeoutMs, ...extraOpts, as:'json', auth:true, on401:'login'}); its signature and behaviour are unchanged.EidosApi.request(path, opts)is the one entry point —opts.asselects'json'(default) /'blob'/'text'/'response',opts.auth:falseomits the Bearer header (bootstrap/auth endpoints),opts.on401:'throw'suppresses the login overlay (so the login call itself does not loop), andopts.throwOnError:false(seeEidosApi.raw()) returns the rawResponsefor any status so a call site can keep its own bespoke error UI while the rawfetch(still moves into the layer.request()rejects an unknown option key (anything outsidebase/method/body/headers/as/auth/on401/throwOnError/timeoutMs) so a strayfetchinit key can never be silently dropped (4.1b-1). On 403 (and any non-2xx) the thrown error carries the backend's owndetail/messageonly when it is a string, falling back toHTTP <status>— a structured (object) detail never surfaces as"[object Object]"(Contract 05 R31).opts.base:'same-origin'fetches from the page's own origin (the path is used as-is, not prefixed with the APIprimaryBase) — used for same-origin assets that must not be routed to a possibly-split API host (domains.json, the/api/insights/*calls); when the API base equals the page origin (DEV/pilot) it is identical to the primary path (4.1b-2). Note:base:'same-origin'withauth:true(the default) DOES send the Bearer token to the page's own origin. As of 4.1b, every app.js backend call goes through the layer — binary downloads useEidosApi.raw(path)(as:'response', no timeout) so the caller can readContent-Dispositionfor the filename and takeres.blob(); uploads (FormDataorapplication/octet-stream) useEidosApi.raw(path, {method:'POST', body})(no timeout, no auto Content-Type for FormData). Abasethat is not the primary and not inDOMAIN_REGISTRYnow throwsUnregistered domain base: …rather than silently falling back to the primary — routing is never guessed (Contract 02.01).
Full JavaScript signature¶
async function apiFetch(path, base = BASE_URL, timeoutMs = 8000, extraOpts = {})
Returns a parsed JSON value, or null for HTTP 204. Throws on any non-2xx status.
Parameter table¶
| Param | Type | Description |
|---|---|---|
path |
string |
The URL path starting with /, e.g. /mutations/node/preview. Must not include the origin. |
base |
string |
The domain base URI. Defaults to BASE_URL (the primary server origin). Pass a secondary domain's baseURI to route the call through the proxy. Pass '' or null to force the primary domain. |
timeoutMs |
number |
Request timeout in milliseconds. Default 8000. CRUD operations use 15000 because preview round-trips may be slow on large subgraphs. |
extraOpts |
object |
Passed as additional fetch init options (e.g. method, headers, body). The function merges authHeaders() into any caller-supplied headers, so callers do not need to add the Bearer token themselves. |
What "base" means here¶
base maps to a domain's baseURI as declared in domains.json (loaded into DOMAIN_REGISTRY by loadRegistry at startup). For the primary domain, base equals BASE_URL and the URL is constructed as:
url = `${BASE_URL}${path}`
For a secondary domain (a reference domain such as location or type), base is that domain's baseURI. The function calls baseToDomain(base) (app.js:3051, injected into EidosApi as resolveDomain) to reverse-look up the domain name key from DOMAIN_REGISTRY.domains, then constructs:
url = `${BASE_URL}/api/proxy/${encodeURIComponent(domain)}${path}`
This proxy routing is a hard security boundary. The routing lives in frontend/eidos-api.js (proxy-URL build and the Unregistered domain base throw at :73-75), and its comment states the invariant explicitly: the browser must never send the user's Bearer token directly to secondary services. All cross-domain calls are funnelled through the primary server's /api/proxy/ endpoint, which re-signs the request on behalf of the user.
If base is non-null and non-primary but cannot be found in DOMAIN_REGISTRY, baseToDomain returns null and apiFetch throws Error("Unregistered domain base: " + base) immediately, before any network call.
Error handling and timeout behavior¶
apiFetch wraps the fetch call in an AbortController timer. If timeoutMs elapses, controller.abort() fires and the underlying fetch rejects with an AbortError.
For HTTP errors, apiFetch branches as follows:
- 401: calls
showLogin()and throwsError("Not authenticated"). - 403: throws
AccessDeniedError("Access denied"). - 404: throws
NotFoundError("HTTP 404 for {url}"). - All other non-2xx: attempts to parse the response body as JSON and extracts a detail string using the priority chain
j.detail || j.message || j.errors?.[0]?.message. The resolved string becomes the error message. Crucially, the raw HTTP status code is attached to the thrownErrorobject ase.status(frontend/eidos-api.js:252). Callers that need to distinguish 409 (version conflict) from other errors branch on this property directly. - 204: returns
nullwithout attempting to parse JSON.
apiFetchAbsolute¶
async function apiFetchAbsolute(absoluteUri, timeoutMs = 8000)
Defined at line 3089 of frontend/app.js. Since #955 W5 it first checks whether the argument is an identity IRI or a legacy bare uuid (no EidosIRI.nodeBaseOf, but uriToId yields an id). Those are identity only, never a fetch target: it fetches EidosPaths.nodeNodeId(id) from resolveNodeBase(id) (falling back to BASE_URL). Any other URL is parsed with the URL constructor to extract pathname + search, then delegated to apiFetch with u.origin as the base argument.
This function exists for call sites that already have a fully-qualified URI — for example when navigating to a node whose @id is an absolute URI and the caller does not know in advance whether it belongs to the primary or a secondary domain. For a non-IRI URL, apiFetchAbsolute handles the routing decision internally by passing u.origin as base, which apiFetch will look up in DOMAIN_REGISTRY and proxy if necessary.
Use apiFetch when the path and domain are known separately (the common case for mutation calls). Use apiFetchAbsolute when the call site only has a full URI string — for instance when following a relation target URL that arrived from the server (lines 6859, 6948 in app.js).
Request paths come from the OpenAPI contract (4.4a + 4.4b, #667)¶
The paths the frontend requests are derived from the API contract, not hand-written strings scattered through the JS. scripts/gen_frontend_paths.py reads docs/api/openapi.yaml and emits frontend/eidos-paths.js — a generated, committed artefact (the same pattern as detail-panel.transpiled.js: generated, committed, guarded). It exposes window.EidosPaths, a frozen registry with one entry per unique contract path: static paths are string constants (EidosPaths.nodes === '/nodes'), templated paths are builder functions that URL-encode each param in path order (EidosPaths.adminUsersUserIdInvite(userId) → /admin/users/${encodeURIComponent(userId)}/invite). Methods are not in the name — the frontend keys on the path and passes the method to EidosApi.raw separately. The six EidosMutations-owned two-phase paths (/mutations/{node,node-update,relation}/{preview,commit}) are deliberately omitted — they are reached only through the EidosMutations layer (built as /mutations/${type}/${kind}), never as a direct EidosPaths constant, so the two-phase protocol can't be bypassed (self-guarding: if one reappears in the module, test_frontend_mutations_layer_ratchet.py fails on it). The import 2-phase (/mutations/import/*) is a different protocol and is kept.
Two guards make a backend route rename break a test instead of breaking silently in the browser:
- Freshness (
tests/test_frontend_paths_generated_fresh.py): the committedeidos-paths.jsmust equal a fresh generation fromopenapi.yaml. A rename flowsscripts/export_openapi.py→scripts/gen_frontend_paths.py→ this file; skip a step and the test goes red. Never hand-editeidos-paths.jsoropenapi.yaml. - Contract drift, end state (
tests/test_frontend_paths_in_contract.py): after 4.4b the invariant is the strong one — no hand-written API-path literal at an HTTP-layer call site outsideeidos-paths.js. Every first argument toapiFetch/_apiFetch(iis-module's wrapper) /EidosApi.raw/EidosApi.requestis classified:EidosPaths.<name>(migrated, good), a'/…'literal (forbidden, except the frozen exceptions), or a runtime variable/expression (dynamic). A hand-written literal is a violation; a genuinely new path is regenerated into the module (scripts/gen_frontend_paths.py), never hand-written. - Runtime complement, DEV/TEST only (4.4c, #713):
EidosApi.raw()— the 4.1a chokepoint every request goes through — holds a second, complementary check. In dev/test mode (gated bydevMode()injected viaEidosApi.configure(); wired to_isDevHostinapp.jsso it fires onlocalhost/127.0.0.1/*-dev/dev.*hostnames and viaconfigure({devMode:()=>true})in vitest) it extracts the request pathname (new URL(path,'http://x').pathname, so a query string or full URL is handled) and asserts it matches the frozen EidosPaths register — static keys as fixed pathnames, builder templates matched via a placeholder-slot regex ([^/]+per param). An unregistered pathname throws[EidosApi] DEV: "<pathname>" is not in the EidosPaths register. Exceptions mirror the static guard's_EXCEPTIONS:base:'same-origin'(for/api/insights/*and same-origin bootstrap assets) and both spellings ofdomains.jsonare always allowed. PROD behaviour is unchanged (Contract 15) — the check is a single boolean AND (_deps.pathRegistry && _deps.devMode()) that short-circuits to a no-op on non-dev hostnames, so there is no throw and no hot-path latency. The value of the runtime check is that it is immune to the two structural holes the static grep cannot see: an aliased HTTP-layer call (const af = EidosCore.apiFetch, which renames the entry point away from the grep pattern) and a file outside the static guard's scan set.EidosMutationspaths (/mutations/{node,node-update,relation}/{preview,commit}) never enterraw()— they go throughEidosApi.request()— so no mutation-path exception is needed here.
Migration complete (4.4b). Every request path across the deployed frontend now comes from EidosPaths — 165 at the call-site first-argument and another 12 one variable-hop away (a literal migrated at the const path = … assignment; the call passes the variable), for 181 EidosPaths references in all; static literals became constants, templated paths became builder calls (byte-identical URLs, each transform proven equivalent for a special-character input before applying, so no request changed). The only path literals left are the frozen exceptions. The two dynamic-segment builders are now explicit: /auth/${ep} became the concrete EidosPaths.authResetCheck / authInviteCheck / authResetPassword / authSetPassword, and adminToggle maps to EidosPaths.adminUsersUserIdEnable / …Disable — so they are no longer exceptions. eidos-paths.js is loaded before app.js in index.html (and uploaded by deploy.py); the #703 deployed-HTML guard requires that ordering. Guard scope: the drift guard scans every deployed script — the <script src> tags in index.html (shortcuts, cross-sync, mutation-refresh, relation-catalog, …) unioned with the dynamically-loaded plugins/*.js, minus the layers and vendor — so a new deployed file can't fall outside it. The scan sees member calls (window.EidosCore.apiFetch(…)) too, aliasing the HTTP layer to a new name (const af = …apiFetch, incl. &&/?./ternary) is forbidden (it would hide a call), and every referenced EidosPaths.<key> must be a real key (statics used bare, builders called). Reach, stated not hidden: the genuinely-dynamic first-args (apiFetch(url), u.pathname + u.search, a path chosen at runtime) are out of static reach and frozen downward-only at 17 (4.4b drove it 38 → 18; #1008 → 17) — the static guard does not follow a variable to its definition (that is #711), so the 11 EidosPaths-fed variable-hops still counted plus 6 layer-plumbing/runtime forwards (the apiFetch/apiFetchAbsolute/_apiFetch wrappers, a Response-URL retry) are counted here; every survivor is listed with what feeds it in the guard's docstring, so no hand-written literal can hide behind a variable. apiFetchAbsolute is excluded — it takes an absolute node/target URI, not a contract path. The only remaining literal is the frozen same-origin bootstrap asset (domains.json / /domains.json, include_in_schema=False), which each must stay used or be removed.
The Preview → Commit Lifecycle¶
Every user-initiated graph mutation follows a strict two-step protocol. There is no direct write path. This section describes the lifecycle as implemented, not as a specification.
Step 1: POST /mutations/{entity}/preview¶
The frontend assembles a command object and POSTs it to the appropriate preview endpoint:
- Add node:
POST /mutations/node/preview - Update node (label, description, product type, engineering properties, shared note):
POST /mutations/node-update/preview - Create relation:
POST /mutations/relation/preview
The body is always a JSON object with a commandType field (e.g. "AddNode", "UpdateNode", "CreateRelation") and the mutation-specific fields. For example, the Add Node preview body assembled at line 11428:
{
commandType: "AddNode",
parentNodeId: sel,
label: values.label,
description: values.description,
idempotencyKey: idemKey
}
The idempotencyKey is a client-generated UUID4 (line 11391: const idemKey = _uuid4()). It is embedded in the preview command and carried forward into the commit body, allowing the server to detect and reject a duplicate Add Node that was previewed but whose commit was sent twice.
The server responds with a preview result object. The fields the frontend depends on are:
| Field | Type | Meaning |
|---|---|---|
previewId |
string |
An opaque server-issued token that identifies this specific pending mutation. The client must echo this value back in the commit call. |
status |
string |
"valid" if the mutation can proceed; other values indicate the mutation is rejected at preview time. |
errors |
array |
Validation errors. If non-empty, previewId may still be present but the Confirm button will be disabled (I-2). |
warnings |
array |
Non-blocking messages shown to the user in the diff panel. |
diff |
object |
Shape of the proposed change. Used by renderDiff callbacks to populate the preview UI. For node creates: diff.nodesCreated; for updates: diff.nodeUpdated; for relations: diff.relationsCreated. |
snapshotScope |
object | null |
If the server took a safety snapshot before the mutation, describes its scope (node count, path). Displayed below the diff. |
Step 2: User sees diff¶
After a successful preview call, _gidShowPreview (line 9544) renders the preview panel. It calls the operation's renderDiff callback to populate the diff container, sets the alert state (error/warning/ok), and conditionally disables the primary button:
primaryBtn.disabled = !!(res.errors?.length) || !res.previewId;
The previewData object (the full server response) is stored in _gid.previewData (line 9536). This is the only client-side storage of previewId. There is no localStorage, no session storage, no URL parameter. The token lives exclusively in the in-memory _gid.previewData object for the duration of the dialog's lifetime.
Step 3: POST /mutations/{entity}/commit¶
When the user clicks Confirm, _gidDoCommit (line 9590) calls the operation's onConfirm callback, passing _gid.previewData as the argument. Each onConfirm implementation extracts pd.previewId and commits through EidosMutations.commit (a raw /mutations literal via apiFetch is a ratchet violation):
// Add node commit (line 11451):
const res = await EidosMutations.commit("node", { base: _edit.selectedDomBase, timeoutMs: 15000, previewId: pd.previewId });
For node updates, the commit body also includes expectedRev (the last-seen eidos:rev from the EidosNodeStore entry for sel — read inside app.js via the module-level nodeCache alias to EidosNodeStore._nodes) to detect concurrent edits:
EidosMutations.commit("node-update", { base: _edit.selectedDomBase, timeoutMs: 15000,
previewId: pd.previewId, extra: { expectedRev: nodeCache[sel]?.["eidos:rev"] } });
The previewId is single-use. The server invalidates it on first commit. Attempting to commit the same previewId a second time yields a 404 (previewId-not-found). After _gidDoCommit calls closeDialog(), the _gid.previewData object is no longer accessible, so accidental double-submission from the UI is structurally impossible — the dialog is closed and the data discarded before any user action could trigger a second commit.
CRUD Operations¶
Add node¶
Triggered by openAddNodeDialog (line 11385; keyboard shortcut add-node bound at line 8943). The dialog collects label, description, and optionally productType. The onPreview callback (line 11427) assembles an AddNode command and calls EidosMutations.preview("node", …) with _edit.selectedDomBase as the base. On confirm (line 11450), commits with EidosMutations.commit("node", …). The server response includes entity_id (the new node's UUID), which the frontend uses to navigate to the new node and insert it into the tree in-place (BL-FE-118).
Before calling preview, the frontend must have: the parent node ID (sel = _edit.selectedNodeId), a UUID4 idempotency key, and the selected domain base. The label format is validated against labelPattern before the preview call is made.
Update node¶
Triggered by openUpdateNodeDialog (line 11480). Reads label, description, and productType from EidosNodeStore.get(sel) (accessed inside app.js via the nodeCache[sel] alias) as initial values. The onPreview callback (line 11546) detects which fields actually changed (label, description, product type) and assembles an UpdateNode command. Only changed fields are included in the body; an unchanged form throws Error("Nothing to save") before the network call. Calls EidosMutations.preview("node-update", …) and EidosMutations.commit("node-update", …). The commit includes expectedRev for optimistic concurrency.
Create relation¶
Triggered by openCreateRelationDialog (line 12299). The dialog collects targetId (via a node search field) and relationType. The onPreview callback (line 12311) sends commandType: "CreateRelation" with sourceId, targetId, and relationType. Calls EidosMutations.preview("relation", …) and EidosMutations.commit("relation", …). For cross-domain relations (e.g. linking a product node to a location node), the Add Information Dialog (_aidDoPreview, line 10340) adds externalDomain and externalSourceDomain fields to the command before calling the same preview endpoint.
Add Information Dialog — launch points and predicate scoping¶
window.openAddInformationDialog(sourceNodeId, sourceDomainKey, opts) is the single canonical entry point for adding data to a node in data-language terms — its buttons read "Location", "Type", "Document", and so on, while the underlying predicate id is kept only in each button's title/data-pred-id and never shown as prose. Several launch points open this same dialog:
- An empty identity coordinate (an unassigned Location or Type slot in the header) opens it scoped to that one predicate (
isLocatedIn/hasType), for editors only; a coordinate with no resolvable predicate stays a static "Unassigned" label rather than a dead button. - Each editable sub-panel's "+" launches it scoped to the kind of data that panel holds.
- The admin "Related information" control room (below) carries an unscoped "Add information" entry — the full Phase-1 type selector — for adding any offered type the header slots don't cover.
The node header itself carries no catch-all "Add information" button — that entry was removed so the header stays operational rather than a control surface. Editors reach every offered type either through the scoped slot/sub-panel affordances or through the Related-information control room.
Filled coordinates are edited in place: on a Location/Type slot that already has a value, the leading glyph is an icon-only <button> (aria-label "Edit {label}") that calls window.openEditInformationDialog(anchorEl, rel, sourceNodeId, sourceDomainKey) — the value text still links to its target. _resolveCoordinates carries the underlying relation record (_rel) onto each value so the glyph has the edge identity. The edit dialog opens directly at Phase 2 (target picker) and, in edit mode, adds a destructive "Remove {label}" button that hands off to the shared relation-delete confirm (openDeleteRelationDialog) — one delete pattern app-wide. Removing a coordinate reference is reversible: the slot reverts to its scoped "Add {label}" affordance.
Existing target identity (edit mode). The dialog pre-selects the current target so "Preview" is enabled immediately, but only when the target's node id (its uuid, the local part of the node IRI) can be established — _aidResolveEditTargetId(rel) tries, in order, the UUID in the relation's target URI, the stored externalUUID, and finally a live /node-by-ref?ref=… lookup routed to the target's own domain (never the emitted host). A ref-addressed cross-domain target (…/node-by-ref?ref=…) carries no UUID in its URI, and a raw URI is never used as a node id: if nothing resolves, no target is pre-selected and the user picks again (Remove still works — it needs only the relation id). This closes the 2026-09-04 fix for the 2026-08-26 defect where re-saving a Location relation from the header sent the proxy URL as targetId, replaced the good relation, and left the header showing the URL; the backend now also rejects such a targetId with TARGET_ID_IS_URI.
opts.scopePredicates (an array of predicate ids) restricts the offered types. The pure helper _aidScopePreds(preds, scopePredicates) filters the catalog: an absent or empty scope returns the full set unchanged; a scope resolving to several predicates renders a filtered Phase-1 selector; a scope resolving to exactly one predicate skips Phase 1 — _aidSelectPred opens the target picker (Phase 2) directly, the same path openEditInformationDialog uses for editing an existing relation. If the scope filters out every predicate the dialog shows a toast and does not open, and when a single-predicate scope is used the overlay is revealed only after the target picker has rendered, so a missing target tree never leaves an empty dialog on screen.
Admin control room — the "Related information" zone¶
The raw, full relation list (RelatedInformationZone in detail-panel.js) renders only for admins — the gate is the precomputed isAdmin boolean (from /me's is_admin), layered on top of the existing per-domain _zoneHidden gate. Editors and viewers never see this panel at all: they get a decluttered node panel and interact with data through the header affordances and the meaningful sub-panels instead. Because non-admins never render the list, they never see the amber "not yet on record" (unresolved) rows a cross-domain relation can produce — an admin, who can resolve those targets, sees the full list without that ghosting. This is a UI-visibility choice for clarity, not an access-control boundary: the relation payload is still delivered to every viewer's browser, and only its rendering is suppressed.
Inline note save (detail panel)¶
NoteField (defined at frontend/plugins/detail-panel.js:1770; the save method sits at :1782 inside the component) follows the same pattern but outside the generic dialog: it calls EidosMutations.preview('node-update', { base: mutationBase, timeoutMs: 10000, body }), checks pre.errors, then calls EidosMutations.commit('node-update', { …, previewId: pre.previewId }). The optimistic cache patch at the end of the method mirrors the shape of the dialog-dispatch commits in app.js. The node-note persistence path runs through NoteField.save on current main — there is no standalone saveNodeNote function in frontend/.
Engineering properties (detail panel)¶
AddPropForm.save (component at frontend/plugins/detail-panel.js:2335; save at :2351) and EngGroupEditForm.save (component at :2418; save at :2445) follow the same two-step EidosMutations protocol: preview with the full updated engineering array, then commit with pre.previewId. They assemble the full replacement array by merging existing properties with the new/edited entry, because the UpdateNode command replaces the engineering list in full, not incrementally. (The symbol is AddPropForm — no Eng prefix; EngGroupEditForm keeps the prefix.)
Zone-header counts come from the metrics engine (4.2b, #665)¶
Two of the four zone headers — What's inside, Specifications — no longer count rows themselves; they display the metrics catalog's numbers (subitems / specifications). ConnectionsZone stays local (#700): the panel is an isConnectedTo allowlist (a connectivity-aspect view), whereas the catalog connections metric counts all internal relations — a different quantity; the resulting chip-vs-panel divergence is fixed by a connectivity-scoped catalog metric, not in the panel. NodeDetailPanel fetches those two metrics once per node view through EidosCore.getNodeMetrics(uid, …), a page-lifetime /metrics/batch cache shared with the tree chips and invalidated by the tree-chips reconciler on every mutation. Each zone renders its header through _headerCount(metricValue, rowCount, zone): it shows the engine value; on a disagreement with the local row count it still shows the engine value and warns on a dev host (panel/catalog disagreement, surfaced not hidden); and on a partial response (metric absent) it falls back to the local row count — never a false 0. The zones still render their rows from the JSON-LD node; only the header number is engine-sourced. DocumentsZone stays local (#698): the documents catalog metric counts doc-URL engineering properties, not the attached document nodes the zone shows (most DEV nodes get their docs via rulebook channels, not direct hasDocument), so migrating it would change the visible count; a correct attached_documents metric needs cross-domain, IAM-filtered channel resolution. The BL-FE-095 heatmap likewise keeps its local _computeDescendantCounts (descendants is held out of _BATCH_SAFE for IAM reasons; the local walk over the visible tree is IAM-correct by construction). See the Metrics Engine page.
Shared UI primitives on EidosCore (4.3b, #666)¶
Two client-side UI helpers used across the app shell and its plugins were consolidated onto the canonical window.EidosCore registry so there is one name to call, no scattered aliases or copy-pasted DOM:
-
EidosCore.showToast(msg)— the single toast entry. The transient confirmation toast (showToast, inapp.js) is now the sole entry point. The three redundant shapes that grew around it are retired: thewindow.fireToastalias, the detail-panel localtoast()wrapper, and thewindow.showToast || window.toastor-fallback. Every plugin confirmation (Note saved,Save failed,pinned …, connection errors) callswindow.EidosCore.showToast(or the safewindow.EidosCore?.showToast?.(…)where the registry may not yet exist). Same text, same 2 s duration — no visible change. Ratchet (tests/test_frontend_ui_primitives_ratchet.py): the number of toast entry-point definitions acrossfrontend/**/*.jsis frozen at 1 (only the canonicalfunction showToast) and may only fall — a re-added alias/wrapper is a two-sided violation. -
EidosCore._downloadBlob(blob, filename)— the canonical click-download. This carries the BL-FE-049 fix: it defers the<a>removal andURL.revokeObjectURLby 60 s, because some Chrome/Edge versions consume the anchor + blob URL asynchronously after.click()and a same-tick revoke drops thedownloadattribute — saving the file under the blob UUID instead of its real name. The four click-download export plugins (report-ref-arch,export-reference-whitelist,export-system-breakdown,export-system-context) now call it instead of hand-rolling the anchor, so they inherit the filename fix. Ratchet:URL.createObjectURLoutsideapp.jsis frozen at 0, with one documented exception —export-documentation-360.js, whose blob URL is not a click-download but a viewer URL (window.openin a new tab plus a persistent download link, revoked on popover close via_revokeBlobUrl). It has no early-revoke bug and does not fit_downloadBlob's auto-click-then-revoke, so it stays as-is (owner decision, #666); that single exception is itself frozen at 1.
Modal is deliberately not consolidated here. A survey for #666 found 19 static overlay/dialog/modal containers in index.html plus a handful of dynamically-built ones, across two different show/hide mechanisms (classList('hidden') vs style.display), no native <dialog>, and inconsistent Escape-key / backdrop-click closing. That is well past a handful of variants, so an EidosModal primitive is scoped as its own step rather than folded into this change.
Error Handling¶
409 Conflict (version conflict)¶
When the server detects that expectedRev in the commit body does not match the current revision stored on the node (i.e. another session modified the node between the user's last load and their commit), the server returns HTTP 409. apiFetch attaches err.status = 409 to the thrown error. Callers that handle 409 explicitly:
openUpdateNodeDialog.onConfirminapp.js: on 409, dispatches anodeMutationOutcomewith originopenUpdateNodeDialog.409(no optimistic patch → full authoritative reconcile of the node's tree row, detail, and chips) and rethrows with a user-readable message so_gidDoCommitcan surface it in the preview alert panel. (Before the view-refresh engine this called_postCommitdirectly. The pre-#674saveNodeNotefunction cited in earlier docs no longer exists.)NoteField.saveindetail-panel.jsdoes not explicitly handle 409; the genericcatchsurfaces the error message viatoast.
422 Validation error¶
The server returns 422 when the mutation fails business-logic validation (e.g. the node label already exists under the same parent). apiFetch extracts the detail from j.detail || j.message || j.errors?.[0]?.message and throws with that string as the message. In the generic dialog, _gidDoCommit catches this and writes it to the preview alert element.
Validation can also fire at preview time, in which case the server returns a 200 with errors populated and a falsy or absent previewId. _gidShowPreview disables the Confirm button in this case. The user sees the error in the preview panel and must correct their input before re-running preview.
404 previewId-not-found¶
If the frontend sends a commit with a previewId the server no longer holds (expired, already committed, or server restarted), the server returns 404. apiFetch throws a NotFoundError. The commit attempt fails and the dialog remains open. The user must click Back, re-enter the form, and run preview again to obtain a fresh previewId.
Integration¶
Dependency on domain-registry for routing¶
apiFetch depends on DOMAIN_REGISTRY being populated before any cross-domain call is made. loadRegistry (line 2988) fetches domains.json at application startup. Every mutation call that targets a secondary domain passes _edit.selectedDomBase as the base argument. This value is set when the user selects a node in the tree — the tree panel stores the domain base of the active tree tab in _edit.selectedDomBase. If the tree is displaying a secondary domain and the user initiates a mutation, apiFetch will route the call through the proxy automatically.
The baseToDomain function (line 3051) is the lookup that makes this work. It normalises trailing slashes and iterates over DOMAIN_REGISTRY.domains to find the entry whose baseURI matches the supplied base. window.baseToDomain = baseToDomain (line 3060) exposes it to plugins so the Add Information Dialog (in app.js) and the detail panel (in detail-panel.js) can perform the same lookup without importing from a separate module.
The detail panel receives mutationBase as a prop. This is the base it passes to EidosMutations for all inline CRUD operations. It is destructured from opts in the NodeDetailPanel component (detail-panel.js:423), which is populated by app.js when it mounts the panel. This means the detail panel never reads _edit.selectedDomBase directly — it receives the domain context from its caller, which keeps the panel stateless with respect to domain routing.
How it enforces the preview-before-commit invariant¶
The invariant is structural, not checked at runtime by a guard clause. The commit functions never hold a previewId except as pd.previewId where pd is the server response from a preview call. The flow is:
onPreview(values)is called → returns a preview result → stored in_gid.previewData._gidShowPreview(res)disables Confirm if!res.previewId || res.errors?.length._gidDoCommit()callsonConfirm(_gid.previewData)→onConfirmreadspd.previewId.
There is no code path that calls an onConfirm without first populating _gid.previewData from a preview response. The dialog state machine enforces this: _gid.phase starts as "edit", transitions to "preview" only inside _gidShowPreview, and _gidPrimary (line 9507) routes to _gidDoPreview in "edit" phase and _gidDoCommit in "preview" phase. A commit call with a missing or invalid previewId will result in a 422 or 404 from the server, surfaced as an error in the preview alert panel.
Related: modal dialogs¶
The show/hide of a CRUD dialog — opening the overlay, trapping focus, Escape,
backdrop, restoring focus on close — is not the CRUD engine's job. Since Wave 4
trin 4.6a (#706) it belongs to the UI Primitives (EidosModal) engine:
EidosCore.openModal(el, opts) / EidosCore.closeModal(el) in
frontend/eidos-modal.js. The CRUD engine owns the protocol (preview → commit)
and the content; EidosModal owns the dialog lifecycle and accessibility. See
the UI Primitives (EidosModal) engine page.