Domain Routing Key Invariant¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 5 finding(s) fixed or closed — the guarantee now states the apiFetchAbsolute exception (a non-IRI URI routes by its own origin, valid only for a registered baseURI, else 'Unregistered domain base'); _relUriBase sends IRI/bare-uuid input through resolveNodeBase and only /node/ URLs through resolveBaseForURI; app.js lines re-cited; sources.files names the real frontend/plugins/tests files. #1026 (b4e42da1): the app-cross-domain-routing guard-regex finding is closed in code (the regex matches EidosCore.module('DomainRouting') and the suite runs in CI; 3/3 pass at 4996d1ce); the page now says the u.origin guard matches only the 2-argument form. Re-verified against main 4996d1ce.¶
The Domain Routing Key Invariant states that the base argument passed to apiFetch must always be a domain routing key — the baseURI value from DOMAIN_REGISTRY — and never an emitted target host or a publicURI value. This is not a style preference. It is the only thing that prevents a class of silent 404 defects that has recurred at least four times: BL-FE-136, BL-FE-150, BL-INF-011, and the 2026-06-16 graph-view defect.
The Guarantee¶
Every call to apiFetch in frontend/app.js passes a base that is either BASE_URL (the primary domain's routing key) or an explicit entry.baseURI value looked up from DOMAIN_REGISTRY.domains. The apiFetch function uses this base to decide whether to call the server directly or through the proxy. No call to apiFetch may pass new URL(something).origin, window.location.origin, a raw publicURI value, or any URL that was not fetched directly from DOMAIN_REGISTRY.
One deliberate exception: apiFetchAbsolute (frontend/app.js:3092-3099), the helper for direct-URL call sites. An IRI or bare uuid takes its identity branch (resolveNodeBase, below), but any other absolute URI is fetched as apiFetch(u.pathname + u.search, u.origin, timeoutMs) — routed by that URI's own host. This is only safe for a URI whose origin is a registered baseURI (for example the ${dom.baseURI}/node-by-ref?… URL that navigateToRef builds); any other origin makes _resolveUrl throw Unregistered domain base. Callers must therefore hand it a URI built from a routing key, never an emitted target host.
The formal rule from the YAML entity (domain-routing-key-invariant.yaml) is:
An
apiFetchbase URL is always a domain routing key (baseURIfromDOMAIN_REGISTRY), derived from the target's domain identity — never from the target's emitted host (publicURI).
Where It Is Enforced¶
The apiFetch function¶
apiFetch is now a thin wrapper at frontend/app.js:3082-3088 that delegates to window.EidosApi.request (added by the #674 EidosApi refactor — see the eidos-api KM entity). The routing decision moved into frontend/eidos-api.js:_resolveUrl (lines 62-76):
// frontend/eidos-api.js:62-76 (shape)
function _resolveUrl(path, base) {
const primary = _firstStr(_deps.primaryBase);
if (!base || base === primary) return primary + path;
const domain = _deps.resolveDomain(base);
if (!domain) throw new Error(`Unregistered domain base: ${base}`);
return primary + "/api/proxy/" + encodeURIComponent(domain) + path;
}
When base equals primaryBase (or is falsy) the request goes directly to the primary server. When base is anything else, _deps.resolveDomain(base) (wired to baseToDomain in app.js) looks it up in DOMAIN_REGISTRY.domains by matching against entry.baseURI. If base is not a registered baseURI, resolveDomain returns null and _resolveUrl throws immediately with Unregistered domain base: <value> — a hard guard, not a fallback.
When base is a valid secondary baseURI, the request is routed as BASE_URL/api/proxy/<domain-key><path>. The primary server acts as a reverse proxy to the secondary. The browser never contacts secondary domain servers directly.
The canonical resolver: domain-routing.js¶
All cross-domain routing that involves a target URL or relation payload must go through window.EidosCore.module('DomainRouting'), registered by frontend/plugins/domain-routing.js via EidosCore.registerModule('DomainRouting', ...) (4.5c, module boundary). This module provides three functions:
resolveApiBase(target, registry, baseUrl) — Takes a relation descriptor ({targetDomain?, externalDomain?, externalRef?, ref?, targetRef?}). Resolution priority:
1. Explicit domain identity: target.targetDomain || target.externalDomain looked up in registry.domains[key].baseURI.
2. Ref sigil: the leading character of externalRef, ref, or targetRef matched against entry.prefix for each domain.
3. Primary fallback: returns baseUrl (never an emitted host).
resolveApiBaseForUrl(url, registry, baseUrl) — For URL-string call sites. Parses only the ref query parameter from the URL and delegates to resolveApiBase. The host portion of the URL is discarded entirely and never used in routing.
sourceDomainKeyForBase(registry, base) — Maps a node's routing base (a baseURI) to its domain key, returning '' for the primary. Matches only against baseURI, never against publicURI.
Call sites that use the resolver¶
In frontend/app.js:
_relUriBase(uri)(frontend/app.js:5991-6003): enriches relation pills. For/node-by-ref?ref=...targets it callswindow.EidosCore.module('DomainRouting').resolveApiBaseForUrl(uri, DOMAIN_REGISTRY, BASE_URL)(:5996). An IRI or bare-uuid URI (no/node/) resolves by identity throughresolveNodeBase(:5999, WP6 W3 #955). Only a/node/URL callsresolveBaseForURI(uri)(:6000), which also matches againstbaseURIvalues; every miss falls back toBASE_URL.- Signal pill description enrichment (
frontend/app.js:6208): callswindow.EidosCore.module('DomainRouting').resolveApiBaseForUrl(targetUrl, DOMAIN_REGISTRY, BASE_URL)before passing the result as thebasetoapiFetch. collectGraphData(frontend/app.js:7057, resolver at:7131): callswindow.EidosCore.module('DomainRouting').resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL)for each cross-domain graph neighbour.navigateToRef(frontend/app.js:6751-6763): resolves by sigil (d.prefix === r[0]) overObject.values(DOMAIN_REGISTRY.domains), then usesdom.baseURI— never the emitted host.navigateRelTarget(frontend/app.js:6790-6796): uses(DOMAIN_REGISTRY.domains || {})[dom]?.baseURIat line 6796 — domain key →baseURI._aidResolveEditTargetId(frontend/app.js:9950, resolver at:9959): resolves an edit-dialog target ID when the relation carries a ref but not a UUID. Callswindow.EidosCore.module('DomainRouting').resolveApiBase({ targetDomain: r.targetDomain, externalRef: ref }, DOMAIN_REGISTRY, BASE_URL)to route the/node-by-reflookup to the correct domain.
All four live resolver call-sites in frontend/app.js (lines 5996, 6208, 7131, 9959) access the resolver through window.EidosCore.module('DomainRouting') — the direct window.DomainRouting.* reference is gone post-4.5c.
Where publicURI appears legitimately¶
publicURI appears in frontend/plugins/domain-routing.js as a fallback only in the non-routing functions — specifically in detail-engineering.js line 267 and related spots — where it is used to construct display-facing URLs or hyperlinks, not as an apiFetch base. The pattern (dom.baseURI || dom.publicURI || '') in those contexts is for generating URLs that will appear in the browser's address bar or in <a> href attributes, not for making API calls.
The test file frontend/plugins/__tests__/domain-routing.test.mjs makes this split explicit: publicURI: PRIMARY appears only in the registry definition to document the collision, not in any assertion about routing.
The identity IRI is not a host¶
Since WP1 (#950) a node's identity is the IRI https://id.ontoteq.com/{uuid}, and since
954 WP5 S7 the backend sends it as every node @id. The IRI is identity only and not¶
dereferenceable: id.ontoteq.com is not a server, so its origin is never a routing key
and never an apiFetch base. The location comes from GET /resolve?id={iri|uuid}
(backend/server.py, #954 S2), which returns {iri, uuid, domain}.
resolveNodeBase in frontend/app.js (WP6 W3, #955) asks it only when the stored
base, the tree index and a known targetDomain all miss, and maps the answer through
DOMAIN_REGISTRY.domains[domain] to baseURI (BASE_URL for the primary). A bare
uuid is accepted as legacy input for the same identity until WP9 (#989) retires it.
See the Canonical Deep Link invariant for the full
resolution order.
// FORBIDDEN — the IRI host is not a server
await apiFetch(EidosPaths.nodeNodeId(uuid), new URL(iri).origin);
// Correct — route by the domain /resolve names
const base = await resolveNodeBase(uuid);
await apiFetch(EidosPaths.nodeNodeId(uuid), base);
Why the Split Exists¶
The network topology¶
In a running deployment, the EIDOS stack is a set of Docker containers on a private network. Each domain server listens on a distinct internal port:
| Domain | baseURI |
Internal port |
|---|---|---|
| product (primary) | https://product.ontoteq.com |
8090 |
| location | https://location.ontoteq.com |
8091 |
| signal | https://signal.ontoteq.com |
8093 |
| type | https://type.ontoteq.com |
8094 |
| discipline | https://discipline.ontoteq.com |
8095 |
These baseURI values are Docker-internal hostnames — they resolve inside the container network but are not reachable from a user's browser. The browser can only reach the primary server through its public hostname (for example, https://eidos-sys-dev.ontoteq.com). The primary server exposes a proxy at /api/proxy/<domain-key>/... that forwards to the secondary.
The publicURI of a secondary domain is therefore the same as the primary's public hostname — it must be, because the SPA is served from the primary and all browser-visible URLs point to the primary. A node whose data lives on location:8091 has its relations stamped with the public origin https://eidos-sys-dev.ontoteq.com, the same origin as product nodes.
What breaks if apiFetch receives a publicURI as base¶
If a developer writes:
const base = new URL(targetUrl).origin; // returns the primary publicURI
await apiFetch(`/node-by-ref?ref=${encodeURIComponent(ref)}`, base);
baseToDomain(base) receives the primary host. In baseToDomain, the loop over DOMAIN_REGISTRY.domains matches the product entry's baseURI, which equals the primary host. So baseToDomain returns 'product' — and apiFetch constructs the URL as either BASE_URL/node-by-ref?ref=... (if base === BASE_URL) or BASE_URL/api/proxy/product/node-by-ref?ref=.... Either way the request goes to the primary server. The primary server does not know about location refs — it returns 404. Because cross-domain failures were historically wrapped in catch {}, the 404 was swallowed silently and no enrichment appeared in the UI.
What breaks if an internal baseURI leaks to a browser fetch¶
If code were to call fetch('http://location:8091/node/...') directly from the browser (bypassing apiFetch), the browser cannot resolve location as a hostname. The request fails with a network error. Additionally, auth headers from the browser must never be sent directly to secondary servers — the proxy on the primary handles token forwarding. The functionally equivalent note now lives in frontend/eidos-api.js:58-61, where the module header documents the primary-proxy routing invariant (the browser never sees a Bearer token on a secondary host); the exact pre-#674 sentence "Browser must never send the user Bearer token directly to secondary services" is gone from the file in favour of the structural header note.
A Concrete Violation Scenario¶
The 2026-06-16 graph-view defect followed this exact path.
collectGraphData was building a list of external cross-domain neighbours. For each relation r in the graph, the old code extracted the target URL and derived the fetch base with:
const apiBase = new URL(r.target).origin;
const data = await apiFetch(u.pathname + u.search, apiBase);
A location node stored in the backend had its @id stamped as https://eidos-sys-dev.ontoteq.com/node/abc123 — the primary public host, because at import time the backend used publicURI to build node identifiers. new URL(r.target).origin therefore returned https://eidos-sys-dev.ontoteq.com. baseToDomain resolved this to product. apiFetch built the URL as https://eidos-sys-dev.ontoteq.com/node-by-ref?ref=%2BFEM.CPB05 — a request to the primary server for a location ref. The primary server returned 404. The surrounding catch {} swallowed the error. The graph rendered without the location node. No console error appeared. No user-visible message appeared.
The fix replaced the host-derivation with:
const apiBase = window.EidosCore.module('DomainRouting').resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL);
const data = await apiFetch(u.pathname + u.search, apiBase);
resolveApiBase reads r.targetDomain (present in the relation payload as 'location'), looks up DOMAIN_REGISTRY.domains['location'].baseURI, and returns https://location.ontoteq.com. apiFetch builds https://eidos-sys-dev.ontoteq.com/api/proxy/location/node-by-ref?ref=%2BFEM.CPB05. The primary proxy forwards to the location server. The node is found and returned.
Enforcement Points¶
Code review: the forbidden patterns¶
Any of the following patterns in a diff touching apiFetch call sites is a contract violation:
// FORBIDDEN: routing by emitted host
apiFetch(u.pathname + u.search, u.origin) // only apiFetchAbsolute may, see The Guarantee
apiFetch(path, new URL(target).origin)
apiFetch(path, resolveBaseForURI(crossDomainTarget))
// FORBIDDEN: matching against publicURI for routing decisions
if (entry.publicURI === base) { ... }
Object.values(DOMAIN_REGISTRY.domains).find(d => d.publicURI === someBase)
The correct patterns are:
// Correct: relation payload with explicit domain key
const base = window.EidosCore.module('DomainRouting').resolveApiBase(rel, DOMAIN_REGISTRY, BASE_URL);
await apiFetch(path, base);
// Correct: URL-string site (node-by-ref URL)
const base = window.EidosCore.module('DomainRouting').resolveApiBaseForUrl(url, DOMAIN_REGISTRY, BASE_URL);
await apiFetch(u.pathname + u.search, base);
// Correct: direct domain key lookup
const base = DOMAIN_REGISTRY.domains[domainKey]?.baseURI ?? BASE_URL;
await apiFetch(path, base);
// Correct: ref sigil resolution
const dom = Object.values(DOMAIN_REGISTRY.domains).find(d => d.prefix === ref[0]);
const base = dom?.baseURI ?? BASE_URL;
await apiFetch(`/node-by-ref?ref=${encodeURIComponent(ref)}`, base);
How to audit: automated guards¶
Two test files enforce this at the source-text level:
frontend/plugins/__tests__/app-cross-domain-routing.test.mjs reads app.js as a string and asserts (it runs in CI via .github/workflows/frontend-unit.yml since #1026):
- module('DomainRouting').resolveApiBase appears at least once — confirming the canonical resolver is wired for collectGraphData / neighbour fetch.
- module('DomainRouting').resolveApiBaseForUrl appears at least once — confirming URL-string sites use the canonical resolver.
- The exact two-argument call apiFetch(u.pathname + u.search, u.origin) does not appear. The three-argument form in apiFetchAbsolute (…, u.origin, timeoutMs)) does not match this regex.
- The pattern return new URL(uri).origin does not appear in _relUriBase.
frontend/plugins/__tests__/domain-routing.test.mjs exercises the resolver directly, including the regression case: a relation whose target URL host is the primary origin but whose targetDomain is 'location' must resolve to http://location:8091, not to the primary.
To audit manually:
grep -n "apiFetch" frontend/app.js | grep -v "BASE_URL\|baseURI\|resolveApiBase\|domainBase\|entry\.baseURI\|dom\.baseURI\|_panelDomainBase"
Any apiFetch call that survives this filter is a candidate for review.
grep -n "publicURI" frontend/app.js
All matches should be in comments explaining the invariant, or in display-URL code. Any match in an apiFetch argument is a violation.
Relationship to Domain Registry¶
How domains.json makes the two URIs explicit¶
frontend/domains.json is the runtime domain registry loaded by loadRegistry() at startup. It is generated from config/domains.yaml and must not be edited by hand. Each entry carries both fields with distinct roles:
"location": {
"baseURI": "https://location.ontoteq.com",
"internalPort": 8091,
"prefix": "+",
...
}
Note that domains.json does not contain a publicURI field for any domain in the current production registry — the publicURI field is referenced in the resolver code and in tests as documentation of the collision, but the production domains.json omits it because publicURI is identical to the primary BASE_URL for all secondary domains and is not needed by the routing logic. The routing logic needs only baseURI and prefix. The publicURI field appears only in test fixtures (domain-routing.test.mjs) to make the collision explicit.
Why they are different fields instead of computed from each other¶
baseURI and publicURI cannot be computed from each other because their relationship is deployment-topology-dependent, not structural. In a Docker deployment, baseURI is the Docker-internal service address and publicURI is the public hostname. In a local development setup where all services are running on localhost with different ports, baseURI might be http://localhost:8091 while publicURI is still the production hostname for certain testing scenarios, or vice versa.
Making publicURI a function of baseURI would require embedding deployment topology assumptions in the code. Making baseURI a function of publicURI is impossible: multiple secondary domains share the same publicURI (the primary host), so the mapping is not injective.
The two fields are also mutated independently across deployments: a DNS change affects publicURI; a Docker network reconfiguration or port change affects baseURI. Keeping them separate fields in domains.yaml lets each be changed independently without touching the other, and without touching the routing logic.
The invariant exists precisely because these two fields have different values for secondary domains in every non-trivial deployment. A developer who assumes they are the same — or who assumes that new URL(node['@id']).origin reliably identifies the owning domain server — will reintroduce the 404 defect that has appeared four times. The canonical resolver in domain-routing.js and the source-text guards in app-cross-domain-routing.test.mjs are the backstops that prevent the fifth recurrence.