Domain Routing Key Invariant¶
Verified — minor divergences from the code — 8 finding(s) · 19d408be · 2026-08-25
The core guarantee, enforcement mechanism (apiFetch's branch/throw logic, baseToDomain, the DomainRouting resolver functions, and the domains.json baseURI/publicURI split) all match the code exactly in behaviour and identifier names. However, every specific line-number citation in the compiled doc page is stale — the actual locations in frontend/app.js have shifted by roughly 700-870 lines since the doc was last verified (verified_at_commit d50caf31), and the KM YAML's sources.files lists only a reference doc (docs/architecture/cross-domain-base-resolution.md), omitting the actual code files (frontend/app.js, frontend/plugins/domain-routing.js) that the compiled page's claims depend on.
Divergences from the code — details
| Sev | Where | Doc says | Code does | Evidence |
|---|---|---|---|---|
| LOW | The apiFetch function | apiFetch is defined at line 2279 of frontend/app.js | apiFetch is defined at line 2609 of frontend/app.js (function body, branch, and throw message all match the doc's description otherwise) | frontend/app.js:2609 |
| LOW | What breaks if an internal baseURI leaks | The comment 'Browser must never send the user Bearer token directly to secondary services' is at apiFetch line 2281 | That comment is at line 2611 (inside apiFetch, which itself starts at 2609) | frontend/app.js:2611 |
| LOW | _relUriBase(uri) (line 4771) | _relUriBase(uri) is at line 4771 | _relUriBase is defined at line 5542; logic (node-by-ref branch to resolveApiBaseForUrl, else resolveBaseForURI, else BASE_URL) matches the doc's description exactly | frontend/app.js:5542 |
| LOW | Signal pill description enrichment (line 5021) | Signal pill description enrichment resolveApiBaseForUrl call is at line 5021 | That call site is at line 5792 | frontend/app.js:5792 |
| LOW | collectGraphData (line 5937) | collectGraphData's resolveApiBase call is at line 5937 | collectGraphData begins at line 6712 and its resolveApiBase call is at line 6781 | frontend/app.js:6781 |
| LOW | navigateToRef (line 5586) | navigateToRef is at line 5586 | navigateToRef is defined at line 6417; sigil-resolution logic matches the doc's description exactly | frontend/app.js:6417 |
| LOW | navigateRelTarget (line 5622) | navigateRelTarget is at line 5622 | navigateRelTarget is defined at line 6456; the baseURI lookup (DOMAIN_REGISTRY.domains \|\| {})[dom]?.baseURI is at line 6461, matching the doc's quoted pattern exactly |
frontend/app.js:6456 |
| LOW | sources.files | sources.files for this KM entity lists only docs/architecture/cross-domain-base-resolution.md | The compiled doc page's claims are almost entirely about frontend/app.js and frontend/plugins/domain-routing.js, neither of which is listed in sources.files, so the KM entity under-declares the code surface it actually documents | docs/manual/knowledge/invariants/domain-routing-key-invariant.yaml:22-25 |
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.
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 defined at line 2279 of frontend/app.js:
async function apiFetch(path, base = BASE_URL, timeoutMs = 8000, extraOpts = {}) {
let url;
if (!base || base === BASE_URL) {
url = `${BASE_URL}${path}`;
} else {
const domain = baseToDomain(base);
if (!domain) throw new Error(`Unregistered domain base: ${base}`);
url = `${BASE_URL}/api/proxy/${encodeURIComponent(domain)}${path}`;
}
// ... fetch, timeout, error handling
}
The branch at line 2283 is the routing decision. When base equals BASE_URL (or is falsy), the request goes directly to the primary server. When base is anything else, baseToDomain(base) looks it up in DOMAIN_REGISTRY.domains by matching against entry.baseURI. If base is not a registered baseURI, baseToDomain returns null and apiFetch throws immediately with Unregistered domain base: <value> — this is 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.DomainRouting, defined in frontend/plugins/domain-routing.js. 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)(line 4771): enriches relation pills. For/node-by-ref?ref=...targets it callswindow.DomainRouting.resolveApiBaseForUrl(uri, DOMAIN_REGISTRY, BASE_URL). For other URIs it callsresolveBaseForURI(uri), which also matches againstbaseURIvalues.- Signal pill description enrichment (line 5021): calls
window.DomainRouting.resolveApiBaseForUrl(targetUrl, DOMAIN_REGISTRY, BASE_URL)before passing the result as thebasetoapiFetch. collectGraphData(line 5937): callswindow.DomainRouting.resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL)for each cross-domain graph neighbour.navigateToRef(line 5586): resolves by sigil (d.prefix === r[0]) then usesdom.baseURI— never the emitted host.navigateRelTarget(line 5622): uses(DOMAIN_REGISTRY.domains || {})[dom]?.baseURI— domain key →baseURI.
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 266 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.
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. This is documented in the comment at apiFetch line 2281: "Browser must never send the user Bearer token directly to secondary services."
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.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)
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.DomainRouting.resolveApiBase(rel, DOMAIN_REGISTRY, BASE_URL);
await apiFetch(path, base);
// Correct: URL-string site (node-by-ref URL)
const base = window.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:
- DomainRouting.resolveApiBase appears at least once (confirming the resolver is wired).
- DomainRouting.resolveApiBaseForUrl appears at least once.
- The exact pattern apiFetch(u.pathname + u.search, u.origin) does not appear.
- 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.