Cross-Domain Resolution¶
Verified — minor divergences from the code — 3 finding(s) · 19d408be · 2026-08-25
The core routing mechanism (resolveApiBase/_sigil/sourceDomainKeyForBase in frontend/plugins/domain-routing.js, and apiFetch/getBaseURI/baseToDomain/loadRegistry in frontend/app.js) is described accurately and the quoted code excerpts match the code verbatim. However the domain inventory is stale (a 6th domain, 'document', now exists and is omitted) and essentially every cited app.js line number is wrong, pointing to unrelated code — the file has grown substantially since the doc's verified_at_commit.
Divergences from the code — details
| Sev | Where | Doc says | Code does | Evidence |
|---|---|---|---|---|
| HIGH | Overview — The Multi-Domain Problem / Domain identity signals | "In the default deployment there are five domains: product (primary), location, signal, type, and discipline" and the 'Domain identity signals' sigil table lists only + (location), ; (signal), & (discipline), % (type), - (product). | frontend/domains.json (generated from config/domains.yaml) now defines six domains: the same five plus document (prefix @, baseURI https://documents.ontoteq.com, internalPort 8097), added by the 'Documents → first-class domain' work (merged 2026-07-12) and extended by the DocuGraph FME import work (2026-08-19), both after the doc's verified_at_commit (2026-06-22). The sigil table and 'five domains' framing omit the @ document sigil entirely. |
frontend/domains.json:98-118 (config/domains.yaml:90-107) |
| MEDIUM | Stage 1: Request Initiated / Stage 3: Request Dispatch / Error Scenarios | Numerous specific app.js line-number citations for the described behaviour: navigateTo() at line 3946; graph view neighbour fetch at line 5942; relation target navigation apiFetch('/node/' + uuid, base) at line 5631; /node-by-ref fallback at line 5648; tree loading Promise.allSettled at line 2598; IAM domain lookup try/catch at line 2073; console.warn at line 5955. | None of these line numbers point to the described code in the current file. The actual locations are: navigateTo() at app.js:4552 (setting _panelDomainBase/_edit.selectedDomBase at lines 4558-4561); the resolveApiBase-driven graph-view neighbour fetch at app.js:6781 (with the paired console.warn at 6799); tree-loading Promise.allSettled with the 'Could not load remote trees' warning at app.js:3051/3066; an IAM getBaseURI try/catch fallback exists but at app.js:2381/2424/12223, not 2073. The referenced string-concatenation form apiFetch('/node/' + uuid, base) does not appear anywhere; call sites use template literals like apiFetch(\/node/${encodeURIComponent(uuid)}`, base). The described mechanisms and control flow are otherwise accurate — only the citations are stale. |frontend/app.js (see actual locations above)` |
|
| LOW | Overview — The Multi-Domain Problem | "Each domain runs its own backend service on its own internal port (8090–8095)." | The document domain's internalPort is 8097, outside the cited 8090-8095 range. |
frontend/domains.json:100 |
flowchart LR
%% generated_from: cross-domain-resolution
%% verified_at_commit: d50caf3143612a76bde48787104e90a4ac202ff3
%% description: Cross-Domain Resolution lifecycle
%% legend: drki=domain-routing-key-invariant
subgraph frontend
detect-cross-domain-ref["Detect Cross Domain Ref · drki"]
end
subgraph api
resolve-routing-key["Resolve Routing Key · drki"]
proxy-fetch["Proxy Fetch · drki"]
end
subgraph domain
secondary-resolution["Secondary Resolution"]
end
detect-cross-domain-ref --> resolve-routing-key
resolve-routing-key --> proxy-fetch
proxy-fetch --> secondary-resolution
Overview — The Multi-Domain Problem¶
EIDOS Explorer is a single-page application served from one primary host, but the data it manages spans multiple independent backend domains. In the default deployment there are five domains: product (primary), location, signal, type, and discipline. Each domain runs its own backend service on its own internal port (8090–8095) and maintains its own graph of nodes. A product node can hold a relation to a location node, a type node, or a signal node; the UI must be able to fetch, display, and navigate to any of those targets regardless of which backend owns them.
The core difficulty is that the SPA is served from the primary host. The browser can only make authenticated API calls back to that same host, not directly to secondary backends (which are not exposed to the internet). All cross-domain API traffic must therefore be routed through the primary backend's proxy, which forward-routes to the appropriate secondary.
The routing key that drives this proxy is the baseURI field from domains.json, e.g. https://location.ontoteq.com. Each domain has a distinct baseURI. The proxy path is /api/proxy/{domainKey}/{path}, where {domainKey} is the string key for the domain in domains.json (e.g. location).
What breaks with incorrect routing¶
The critical failure mode, documented in BL-INF-011 and BL-ARCH-024 and described in the header of frontend/plugins/domain-routing.js, is confusing baseURI with publicURI. Secondary domains are served through the primary SPA; their publicURI — the URL that appears in node links the backend emits — equals the primary host. If code resolves a cross-domain target by parsing its URL's origin (e.g. new URL(targetUrl).origin), it gets the primary host back. The resulting apiFetch call goes to the primary backend's own /node-by-ref endpoint, which knows nothing about location or signal nodes, and returns 404. This defect recurred multiple times and is the sole reason that domain-routing.js exists as a canonical, single-authority resolver.
Stage 1: Request Initiated¶
YAML stage id: detect-cross-domain-ref
Engine: frontend-crud
Zone: frontend
The workflow begins when the frontend must fetch a node that belongs to a domain other than the one currently selected in the detail panel. This happens in two contexts:
- The user clicks a relation target in the detail panel. The relation record contains either an explicit
targetDomain/externalDomainfield, or anexternalRefwhose first character is a domain sigil. - The graph view expands into neighbour nodes; it encounters
eidos:externalRelationsentries that reference nodes in other domains.
Domain identity signals:
Every cross-domain reference carries at least one of:
- targetDomain or externalDomain — an explicit string key matching a key in domains.json (e.g. "location").
- externalRef / ref / targetRef — a path-style reference whose first character is a non-alphanumeric, non-underscore sigil that uniquely identifies the owning domain:
- + — location
- ; — signal
- & — discipline
- % — type
- - — product (primary)
Unprefixed references are treated as belonging to the primary domain. The sigil check is implemented in _sigil() in frontend/plugins/domain-routing.js:
function _sigil(ref) {
const r = String(ref || '').trim();
return (r && !/[a-zA-Z0-9_]/.test(r[0])) ? r[0] : '';
}
The domain identity signal (explicit key or sigil) is what survives through every subsequent stage. The emitted URL host is never used for routing because it is unreliable — it reflects publicURI, not baseURI.
Active domain state: navigateTo() in app.js (line 3946) sets two module-level variables every time the user navigates to a node: _panelDomainBase and _edit.selectedDomBase. These hold the baseURI of the domain that owns the currently-displayed node. Both are seeded from BASE_URL (the primary) and updated as the user navigates. They drive all subsequent mutation and fetch calls in the detail panel so those calls always go to the backend that owns the node being edited.
Stage 2: Domain Lookup¶
YAML stage id: resolve-routing-key
Engine: domain-registry
Zone: api
Once the frontend has identified that a target is cross-domain, it must translate the domain identity (a key string or a sigil) into a baseURI routing key. This is the responsibility of resolveApiBase() in frontend/plugins/domain-routing.js, which is the single canonical resolver for this translation (Contract 02.01).
function resolveApiBase(target, registry, baseUrl) {
const domains = (registry && registry.domains) || {};
const t = target || {};
// 1) Explicit domain identity wins
const dk = t.targetDomain || t.externalDomain || '';
if (dk && domains[dk] && domains[dk].baseURI) return domains[dk].baseURI;
// 2) Sigil selects the owning domain
const sig = _sigil(t.externalRef || t.ref || t.targetRef);
if (sig) {
for (const d of Object.values(domains)) {
if (d && d.prefix === sig && d.baseURI) return d.baseURI;
}
}
// 3) Primary fallback
return baseUrl;
}
The function takes the full relation object as target, the loaded DOMAIN_REGISTRY (populated from domains.json at startup), and the primary BASE_URL as fallback. It returns a baseURI string — never an emitted URL host.
Resolution priority is strict:
1. Explicit targetDomain / externalDomain field — most reliable, never ambiguous.
2. Sigil prefix on the ref string — reliable as long as the sigil convention is enforced in the data.
3. Primary fallback — used for same-domain or unknown targets.
Why not publicURI? The domains.json structure (from frontend/domains.json) makes the distinction concrete. For the location domain:
"location": {
"isPrimary": false,
"internalPort": 8091,
"prefix": "+",
"baseURI": "https://location.ontoteq.com",
...
}
baseURI is the internal routing identity used by the proxy. publicURI (the URL stamped on emitted node payloads by the backend) equals the primary host because the SPA serves all domains through the primary's nginx layer. Using new URL(target).origin on an emitted location node URL would return https://product.ontoteq.com (the primary), not https://location.ontoteq.com.
Registry loading: loadRegistry() in app.js fetches domains.json at SPA startup and populates the module-level DOMAIN_REGISTRY object. getBaseURI(domainKey) provides direct lookup by key and throws "Domain not registered: <key>" if the key is absent. baseToDomain(baseURI) provides the reverse lookup (baseURI → key), which is used by apiFetch() to derive the proxy path segment.
Stage 3: Request Dispatch¶
YAML stage id: proxy-fetch
Engine: frontend-crud
Zone: api
With a baseURI routing key in hand, the frontend calls apiFetch(). The function signature is:
async function apiFetch(path, base = BASE_URL, timeoutMs = 8000, extraOpts = {})
The routing logic in apiFetch() is straightforward:
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}`;
}
When base is anything other than the primary BASE_URL, baseToDomain() converts it back to the domain key string, and the call is rewritten to /api/proxy/{domain}/{path} on the primary host. The primary backend's proxy then forwards the request to the secondary backend using the internal port.
Authentication: The call always goes to the primary host, so the user's Bearer token (added by authHeaders()) reaches only the primary. The primary backend is responsible for forwarding authentication to secondary services. The browser never sends credentials directly to a secondary backend URL. This is the correct security model for the architecture.
Cross-domain call examples from app.js:
- Graph view neighbour fetch (line 5942): apiFetch(path, apiBase) where apiBase comes from window.DomainRouting.resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL).
- Relation target navigation (line 5631): apiFetch('/node/' + uuid, base) where base is DOMAIN_REGISTRY.domains[dom]?.baseURI.
- /node-by-ref fallback (line 5648): apiFetch('/node-by-ref?ref=' + ref, base).
- Tree loading for secondary domains (line 2598): apiFetch('/tree', base) for each baseURI in the registry that differs from BASE_URL.
Unregistered domain guard: If baseToDomain() returns null — meaning the given base string is not a known baseURI in DOMAIN_REGISTRY — apiFetch throws "Unregistered domain base: <base>" immediately, before any network request is made. This prevents silent mis-routing to the primary.
Stage 4: Response Integration¶
YAML stage id: secondary-resolution
Engine: eidos-loader
Zone: domain
The secondary backend receives the proxied request at its own /node-by-ref or /node/{uid} endpoint and resolves it against its own EffectiveGraph — the secondary domain's complete local graph of nodes. It returns a standard node payload in the same JSON-LD shape the primary uses.
Back in the frontend, the returned data is integrated in two ways depending on the call context:
Navigation (full node detail): When the user navigates to a cross-domain node, navigateTo() sets _panelDomainBase to the secondary's baseURI. The detail panel renders the node's properties, relations, and spec values exactly as it would for a primary node. All subsequent mutation calls (preview and commit) use _edit.selectedDomBase, which holds the same secondary baseURI, so edits go to the correct backend.
Graph view (partial data): Cross-domain neighbours are added to nodesMap with an external: true flag. Their description strings are fetched via the proxy and stored in nodeCache keyed by the node's URI. If the fetch fails (e.g. the secondary is unreachable), the failure is logged with console.warn and the node is still added to the graph without a description. The graph therefore degrades gracefully — missing descriptions are surfaced in the console but do not crash the view.
Tree loading: Remote domain trees are loaded in parallel at startup using Promise.allSettled(). Each fulfilled tree array is tagged with domainBase set to that domain's baseURI and appended to allTrees. A rejected secondary tree load emits a console.warn and the primary tree remains usable. The tree tabs are re-rendered after all settled results are processed.
The Backend Proxy Layer: the canonical DomainClient¶
Everything above happens in the browser and ends at the primary backend's proxy route. Inside the primary backend, every outbound call to a secondary domain — the proxy fan-out, cross-domain node resolution, metrics/reconstruction/export fan-outs, IIS lookups, the write-engine forward — goes through one owner: backend/infra/domain_client.py (DomainClient). This is the server-side enforcement of Contract 02.01 (single canonical owner of cross-domain HTTP).
Before the client existed, the proxy helper _proxy_send declared itself the sole owner, but 28 raw httpx.AsyncClient(...) / urllib constructions across a dozen modules bypassed it — each re-inventing the service-token header (six omitted the trace-id), the timeout, and the error mapping. Hærdning trin 2.3 collapsed all of that into the client; the AST ratchet in tests/test_http_client_guard.py freezes the count at two legitimate constructions: the client itself, and the outbound Anthropic API call (a third-party, api-key surface — not cross-domain).
Config from the register, not the server module. The client takes bases from config.domain_maps(), the service token from config.service_token(), and its four timeout tiers from config.proxy_timeout_{fast,default,upload,export}() (the trin 2.2 config register). It never imports server; the domain→URL resolver and the self-domain identity are injected at construction. That keeps it re-liftable into eidos-core unchanged — the server owns the (self-excluding, test-monkeypatchable) allowlist, the client owns the HTTP mechanics.
Headers built in one place. service_headers() always attaches X-Service-Token; it adds X-Trace-Id whenever a trace_id is bound in the request's structlog contextvars (outbound correlation), and X-Eidos-Username when a call acts on behalf of a principal (IAM read-filtering / audit on the secondary). Centralising this gave the six previously trace-less call sites correlation for free.
Timeout tiers. FAST (5s) for health/stat/node lookups and ext-ref resolution; DEFAULT (15s) for standard proxy GETs and the write-engine forward; UPLOAD (30s) for larger transfers (raw proxy, domain export/import); EXPORT (120s) for long-running mutation POSTs. The historical hardcoded literals were each mapped to the nearest tier — never to a shorter wait.
Self-proxy guard (BL-INF-011). Before any request the client refuses to proxy to this instance's own domain (HTTP 421), defence-in-depth behind the build-time exclusion from the allowlist.
The error-mapping contract (Contract 05)¶
The client owns the mapping from a library or upstream failure to a user-safe status, so an httpx exception never escapes as an unhandled 500:
- Timeout (
httpx.TimeoutException) →504 secondary_timeout. - Other network error (
httpx.RequestError) →502 secondary_unreachable. Order matters —TimeoutExceptionis aRequestErrorsubclass, so it is mapped first. - Upstream 4xx is preserved as that status (a
404is special-casedsecondary_not_found, so a genuinely-missing target surfaces as "Unresolved relation", not a gateway error). - Upstream 3xx or 5xx collapses to
502 secondary_bad_status— the secondary itself is broken.
The upstream status is never echoed to the client body; it is carried on ExternalServiceError.upstream_status for server-side logging only (R14/R15), and the structured error response ({error, detail, trace_id} + X-Trace-Id header) is produced by the API boundary's _domain_error_response. A non-JSON upstream body is replaced with a generic detail, never forwarded raw.
The four _proxy_get / _proxy_get_raw / _proxy_request / _proxy_send helpers in server.py remain as thin delegates over the client, preserving the signatures that api/proxy_router.py and routes/documents.py still call. They are a migration scaffold, slated to retire into the client's own API at eidos-core P0.
The Critical Invariant: baseURI vs publicURI¶
This invariant is identified in the YAML as domain-routing-key-invariant and is enforced at three points in the codebase:
resolveApiBase()indomain-routing.js— the resolver explicitly readsd.baseURIfrom the registry entry and returns it. It never reads from the target URL.sourceDomainKeyForBase()indomain-routing.js— matches a node'sdomainBasefield againstd.baseURI, notd.publicURI. The comment for this function explicitly calls out issue #19: matching againstpublicURIresolved primary product nodes to a secondary domain, which caused the CONNECTIONS sub-panel to be hidden on product nodes.apiFetch()inapp.js— comparesbaseagainstBASE_URLto determine routing. OnlybaseURIvalues appear in this comparison;publicURIvalues are never stored in any variable that reachesapiFetch.
Concrete failure example:
Suppose a product node has a relation with externalRef: "+FFL/Pump Room A" and targetDomain: "location". The location domain has:
"baseURI": "https://location.ontoteq.com",
"publicURI": "https://product.ontoteq.com"
(publicURI equals the primary because the SPA proxies the display.)
If a developer writes new URL(rel.target).origin to derive the fetch base, they get "https://product.ontoteq.com" — the primary. apiFetch('/node-by-ref?ref=+FFL/Pump Room A', 'https://product.ontoteq.com') sends the request to the primary backend at https://product.ontoteq.com/node-by-ref?ref=+FFL/Pump Room A. The primary's graph contains no location nodes. It returns 404. The UI shows "Relation target not found."
The correct call: resolveApiBase(rel, DOMAIN_REGISTRY, BASE_URL) returns "https://location.ontoteq.com". apiFetch('/node-by-ref?ref=+FFL/Pump Room A', 'https://location.ontoteq.com') rewrites to https://product.ontoteq.com/api/proxy/location/node-by-ref?ref=+FFL/Pump Room A. The primary proxies it to the location backend on port 8091. The location backend finds the node and returns it.
Error Scenarios¶
Unknown domain¶
Trigger: getBaseURI(domainKey) is called with a key that does not exist in DOMAIN_REGISTRY.domains.
Behaviour: Throws "Domain not registered: <key>". The call site must catch this. In app.js line 2073, IAM domain lookup wraps getBaseURI in a try/catch with BASE_URL as fallback.
Root cause: The domains.json file on the frontend and the backend's catalog have drifted, or a relation record in the database references a domain that was decommissioned.
Detection: Check that domains.json (generated from config/domains.yaml by scripts/generate_domain_config.py) includes an entry for every targetDomain value stored in the relation payloads.
Unreachable secondary domain¶
Trigger: The secondary backend is down, its internal port is blocked, or the proxy misconfiguration causes the primary to fail the forward.
Behaviour at tree load: Promise.allSettled() catches the rejection for that domain. console.warn("Could not load remote trees: <message>") is emitted. The domain's tree tabs are absent from the UI but the primary tree is unaffected.
Behaviour at node fetch: apiFetch throws either a network error or an HTTP error. In the graph view, the cross-domain neighbour node is added to the graph without a description, and a console.warn is emitted (see line 5955). In the navigation path (navigateRelTarget), the error propagates to the caller which typically surfaces it in the status bar.
Detection: Check console.warn output for "cross-domain neighbour fetch failed" or "Could not load remote trees". Verify that the proxy route /api/proxy/{domain} is correctly configured on the primary backend.
Mis-routed request (baseURI/publicURI confusion)¶
Trigger: Code outside domain-routing.js derives a fetch base from an emitted node URL using new URL(target).origin or similar.
Behaviour: Request goes to the primary backend. Primary returns 404 for the foreign ref. UI displays "not found" for a node that exists on the secondary. No crash, no network error — the failure is silent unless the console.warn contract (BL-INF-011) is followed.
Detection: Search the codebase for new URL( followed by .origin in any context that constructs an apiFetch base argument. The only permitted way to derive a cross-domain fetch base is DomainRouting.resolveApiBase() or getBaseURI(domainKey). Direct calls to apiFetchAbsolute() are permitted only when the absolute URL is known to have come from a reliable source such as the baseURI field itself, not from an emitted node payload.
Blast radius: Every cross-domain relation navigation and graph expansion silently fails. The UI appears to work (no crash) but cross-domain nodes are unreachable. This is the most dangerous failure mode because it is invisible without explicit logging and has recurred multiple times (BL-INF-011, BL-ARCH-024, issue #19).
Mis-routed relation write/delete (target-domain vs owner-domain)¶
The ownership rule. A cross-domain relation is owned and stored in the SOURCE node's domain, never the target's. Example: a product node -TAA01 --isLocatedIn--> +FEM (a location node) is stored as an overlay relation in the product domain (product/mutations/relations/<id>.jsonld), with externalDomain: location recording only where the target lives. externalDomain/targetDomain is a target pointer for reads/neighbour-fetches — it is NOT the domain a write to the relation belongs to.
Trigger: a relation write or delete is routed by the relation's target domain (targetDomain/externalDomain, or the predicate's target tree base) instead of the source node's owning domain. Historically the Add/Edit-Information dialog's "Remove" button resolved its delete base from the predicate's target tree, so a product→location relation delete was proxied to the location backend.
Behaviour: the delete lands on a backend that does not own the relation (its overlay file lives in the source domain), so graph_crud_engine.delete_relation cannot find it and returns V-RELATION_NOT_FOUND → HTTP 404 "Relation not found (id=…)". The relation is perfectly valid and still visible — only the write was misdirected.
Correct routing: relation writes/deletes MUST route by the source node's domain base. In the frontend this is _aidSourceBase() (app.js) — DOMAIN_REGISTRY.domains[sourceDomainKey].baseURI || window._panelDomainBase || BASE_URL — the same source-domain resolution used for POST /documents attach and the detail-panel/workbench delete buttons. Never route a relation write by targetDomain/externalDomain.
Backend guard. When a relation delete carries a context_node_id (the source node) that is not a node in the backend's own effective graph, the request was mis-routed to a domain that does not own the source node. delete_relation returns the typed V-RELATION_WRONG_DOMAIN → HTTP 409 ("This relation belongs to a node in another domain — delete it from that node's own domain") instead of a misleading 404, so the failure is honest and actionable.
Stage Reference¶
Detect Cross Domain Ref¶
Engine: frontend-crud — Zone: frontend
identify cross-domain target via sigil prefix (+ location, ; signal, & discipline, % type) or explicit targetDomain / externalDomain field; unprefixed refs are primary-domain
Invariants enforced: domain-routing-key-invariant
Resolve Routing Key¶
Engine: domain-registry — Zone: api
map domain identity to baseURI (routing key) via DOMAIN_REGISTRY; NEVER derive apiFetch base from publicURI or new URL(target).origin — both collapse a secondary to the primary host
Invariants enforced: domain-routing-key-invariant
Proxy Fetch¶
Engine: frontend-crud — Zone: api
apiFetch to /api/proxy/{domain}/{path} using baseURI as routing key
Invariants enforced: domain-routing-key-invariant
Secondary Resolution¶
Engine: eidos-loader — Zone: domain
secondary backend resolves /node-by-ref or /node/{uid} against its own EffectiveGraph