Skip to content

Domain Registry

Diverges from the code — 5 finding(s) · 19d408be · 2026-08-25

Diverges confirmed: the doc's 'Backend proxy routing' section describes a get_domain_base_uri(domain_key) function implemented in tools/backend/domain_catalog.py that does not exist anywhere in the codebase (grep confirms zero matches outside the docs themselves) — the real backend-side resolver is an unrelated _SECONDARY_DOMAINS dict built in backend/server.py from backend/domains.json, and the cited file path tools/backend/domain_catalog.py doesn't exist (only tools/backend/domains.json and access_rules.db are there; the real module is tools/bootstrap/domain_catalog.py). Secondary, lower-severity issues survive too: a fictional initApp() frontend entry point (real one is init(), line 3003) plus stale line numbers for loadRegistry/getBaseURI/resolveBaseForURI/baseToDomain; a misnamed mutation-gate function (doc says _mutationBase, code has _mutationApiBase at line 5490 — but its described validate-and-return-null behaviour is otherwise accurate); and the 'Adding a New Domain' port/prefix checklist omitting the existing document domain (port 8097, prefix @).

Divergences from the code — details
Sev Where Doc says Code does Evidence
HIGH canonical_operations: get_domain_base_uri(domain_key) -> str canonical_operations lists get_domain_base_uri(domain_key) -> str as a function of the domain registry engine, also restated in domain-registry.md's 'Backend proxy routing' section. Confirmed by repo-wide grep: no function named get_domain_base_uri exists anywhere in the codebase (only appears in docs/manual/** and knowledge.json). tools/bootstrap/domain_catalog.py defines only load_catalog() and enrich() — verified by reading the file in full. At request time the backend resolves domain->internal-URL via a module-level _SECONDARY_DOMAINS dict built directly in backend/server.py (lines 582-591, used at 776/818/834/1407/2433/3531), not through domain_catalog.py at all. The doc's entire narrative of a request-time Python resolver function is fabricated. tools/bootstrap/domain_catalog.py:37-81 (only load_catalog/enrich present); backend/server.py:582-591
MEDIUM Backend proxy routing domain-registry.md ('Backend proxy routing' section) states the canonical Python operations are 'implemented in tools/backend/domain_catalog.py'. Confirmed by Glob: tools/backend/ contains only domains.json and access_rules.db, no domain_catalog.py. The real module is at tools/bootstrap/domain_catalog.py, which is (correctly) what the KM YAML's own sources.files lists. tools/backend/ (domain_catalog.py absent, confirmed via Glob); actual file tools/bootstrap/domain_catalog.py
MEDIUM Domain Resolution / Frontend registry load 'Domain Resolution' section: frontend loads the registry 'During application startup (initApp() in frontend/app.js, line 2565)', and cites loadRegistry() at 2225, getBaseURI(domain) at 2230, resolveBaseForURI(uri) at 2237, baseToDomain(base) at 2268. Confirmed by grep: no function named initApp exists anywhere in frontend/app.js. The actual startup function is async function init() at line 3003. The cited helper functions exist with matching behaviour but at different lines: loadRegistry at 2532, getBaseURI at 2538, resolveBaseForURI at 2545, baseToDomain at 2586 — roughly 300 lines later than documented. frontend/app.js:3003 (init, not initApp); frontend/app.js:2532,2538,2545,2586
LOW Integration / mutation-store 'mutation-store' integration section: mutation base is validated by _mutationBase() in app.js (line 4717), which verifies the secondary domain key appears in the registry before permitting the mutation call, returning null (blocking the edit path) if not found. No function literally named _mutationBase exists, and line 4717 is not it — but a function with exactly the described behaviour does exist: _mutationApiBase() at frontend/app.js:5490-5495 (if (!_panelDomainBase) return BASE_URL; if (_panelDomainBase === BASE_URL) return BASE_URL; return baseToDomain(_panelDomainBase) ? _panelDomainBase : null;), called at line 5505 and threaded into renderDetail's mutationBase/canEdit gating. The doc's described behaviour is accurate; only the function's name and line number are wrong, so this is naming drift, not a missing/absent gate. frontend/app.js:5490-5495 (_mutationApiBase, actual gate function)
LOW Adding a New Domain 'Adding a New Domain' step 2 and step 3 list existing internalPort assignments (8090/8091/8093/8094/8095) and sigil prefixes (-, +, ;, %, &) a new domain must avoid colliding with. config/domains.yaml defines a sixth active domain, document, with internalPort 8097 and prefix '@' (confirmed at config/domains.yaml:100-101) — neither value appears in either checklist, even though the doc's own overview (line 28) correctly states there are 'six domains' including document. A reader following only the checklist could pick an already-used port/prefix. config/domains.yaml:100-101 (document: internalPort 8097, prefix "@")

Layer: api

Overview — What a "Domain" Is

EIDOS Explorer partitions its knowledge graph into isolated domains. Each domain is an independent graph store running its own backend server process (on its own internal port) and owning a disjoint set of nodes. Nodes in one domain can reference nodes in another via cross-domain relations, but each domain's data is read, written, and traversed through its own server.

From the frontend's perspective, a domain has two distinct identities:

  • Routing identity: where apiFetch sends requests to reach that domain's server. This is the baseURI — always an internal network address (e.g., https://product.ontoteq.com, http://location:8091).
  • Display identity: what end-user-facing links and emitted node @id URIs use as their host. In a served-stack deployment, all secondary domains share the primary domain's public hostname as their publicURI. This is purely a presentation concern.

The registry is the single authority that maps a domain name key (e.g., "location") to these two identities. No other module is permitted to construct or infer a domain's routing address.

The registry is defined in frontend/domains.json. This file is generated from config/domains.yaml by scripts/generate_domain_config.py — it carries a _comment header warning that direct edits will be overwritten. The frontend loads it at startup via loadRegistry() in frontend/app.js (line 2225) and stores it in the module-level variable DOMAIN_REGISTRY.


Domain Schema

frontend/domains.json holds a top-level domains object. Each key is the domain name (e.g., "product", "location") and maps to a domain entry. The current registry contains six domains: product, location, signal, type, discipline, and document.

Every field in a domain entry:

Field Type Required Description
isPrimary boolean yes true for exactly one domain. The primary domain is the one the SPA is served from. Its baseURI equals BASE_URL. All other domains are secondary.
internalPort integer yes The port the domain's backend server listens on internally (e.g., 8090 for product, 8091 for location). Used when constructing proxy URLs.
prefix string (single char) yes The sigil character that prefixes external refs belonging to this domain. Examples: "-" for product, "+" for location, ";" for signal, "%" for type, "&" for discipline. Must be non-alphanumeric and non-underscore.
label string yes Human-readable display name (e.g., "Products", "Locations"). Used in UI tabs, IAM dropdowns, and status messages.
icon string yes Lucide icon name used in domain tabs (e.g., "package", "map-pin", "activity").
color string (CSS hex) yes Primary brand colour for this domain's UI stripe.
gradient string (CSS gradient) yes Linear gradient used for domain tab headers.
predicate string yes The default RDF-style predicate name for this domain's primary containment relation (e.g., "hasPart", "isLocatedIn").
baseURI string (URL) yes The routing key. The internal base URL of this domain's server. apiFetch uses this value as the routing base. This is the only address you should ever pass to apiFetch.
identity_coordinates array of strings no Domain keys that this domain uses to identify nodes by position (e.g., product uses ["location", "type"]). Present on product only in the current registry. See Per-domain UI configuration.
hidden_zones array of strings no Detail-panel sub-panel "zone" slugs to hide for this domain (blocklist; absent ⇒ all zones shown). Present on document only (["whats_inside"]) in the current registry. See Per-domain UI configuration.
offered_relations array of objects yes Cross-domain relation types this domain offers. Each entry has predicate (string), label (string), and target_domain (string — the key of the domain the relation points to). Used by the relation editor to populate available relation types.

Per-Domain UI Configuration

Most registry fields govern routing and identity. A small, growing set instead configure how the frontend presents a domain — they are declared once in config/domains.yaml, flow to the browser as ordinary domCfg fields, and are read by the detail panel. Two exist today: identity_coordinates and hidden_zones. Both follow the same rule: the SSOT config/domains.yaml is the single canonical registry for domain UI config; the frontend consumes by reference — there is no parallel frontend config map (Contract 02.01 §3.6).

domCfg is the registry entry the detail panel resolves for the current node's domain (matched by baseURI in NodeDetailPanel).

identity_coordinates — orienting coordinate card

A list of domain keys whose relations are surfaced as the node's coordinate card (the business-card rows under the header). product declares ["location", "type"], so a product node shows its Location and Type. Read by _resolveCoordinates(node, domCfg) and rendered by CoordinatesZone, which self-hides when a node has no such relations.

hidden_zones — generic sub-panel visibility

The detail panel is composed of zones (sub-panels). Any domain hides zones it does not need by listing their slugs in hidden_zones (a blocklist — an absent field shows every zone). The gate is a single generic helper in frontend/plugins/detail-panel.js:

function _zoneHidden(domCfg, slug) {
  return ((domCfg && domCfg.hidden_zones) || []).includes(slug);
}

NodeDetailPanel wraps each configurable zone in !_zoneHidden(domCfg, '<slug>'), so any domain can hide any configurable zone from config alone, with no code change. This replaced the previous ad-hoc pattern of hardcoded per-zone domain checks.

Configurable zone slugs:

Slug Zone
connections Connections workbench (also gated to the primary domain by sourceDomainKey)
specifications Specifications (engineering properties)
whats_inside "What's inside" — has-part / sub-element structure
documents Attached documents
related_information Related information (all relations)

Current use: the document domain sets hidden_zones: ["whats_inside"] — a file is a leaf artifact with no has-part structure, so the zone is noise.

To hide a zone for another domain, add its slug to that domain's hidden_zones in config/domains.yaml and regenerate — that is the entire change.

Field pass-through discipline (regression class)

A new UI-config field must reach the frontend through two generation paths, or it is silently dropped:

  1. Committed artifactscripts/generate_domain_config.py copies every field except id/enabled verbatim into backend/ + frontend/domains.json. scripts/check_domain_drift.py fails CI if the JSON drifts from the YAML, so you must regenerate, never hand-edit the JSON.
  2. Deploy-time enrichmenttools/bootstrap/domain_catalog.py (_DEFINITION_FIELDS whitelist) and tools/bootstrap/bootstrap.py (generate_frontend_domains_json field-copy loop) rebuild the registry at deploy time from deploy.json reference entries. A new field absent from these two whitelists is dropped by the deploy path even though the committed JSON is correct. This is exactly how identity_coordinates regressed on 2026-06-22. When adding a UI-config field, add it to both whitelists and cover it with a pass-through test (see tests/test_domain_zone_config.py and tests/test_domain_identity_coordinates.py).

baseURI vs publicURI — The Critical Distinction

This is the most important concept in the registry and the source of a class of recurring 404 defects (tracked as BL-INF-011 / BL-ARCH-024 / issue #19).

baseURI

baseURI is the routing key — the internal network address of a domain's server. It is what apiFetch accepts as its base parameter to route requests through the primary proxy.

When apiFetch receives a base that is not BASE_URL, it calls baseToDomain(base) to reverse-map the baseURI back to a domain key, then constructs the proxy URL:

${BASE_URL}/api/proxy/${encodeURIComponent(domain)}${path}

This is the only URL pattern that actually reaches a secondary domain's server from the browser.

publicURI

publicURI is the emitted display host — the host that appears in node @id URIs as exposed to end users. In a served-stack deployment (the standard deployment), all secondary domains share the primary domain's hostname as their publicURI. This is because the SPA is served from the primary domain, and secondary backends are only reachable via the primary's reverse proxy.

The test fixture in frontend/plugins/__tests__/domain-routing.test.mjs makes this concrete:

const REGISTRY = { domains: {
  product:  { prefix: '-', baseURI: 'https://eidos-sys-dev.ontoteq.com', publicURI: 'https://eidos-sys-dev.ontoteq.com', isPrimary: true },
  location: { prefix: '+', baseURI: 'http://location:8091',              publicURI: 'https://eidos-sys-dev.ontoteq.com' },
  signal:   { prefix: ';', baseURI: 'http://signal:8093',                publicURI: 'https://eidos-sys-dev.ontoteq.com' },
}};

location.publicURI === product.publicURI === PRIMARY. The two fields are not interchangeable.

The domain-routing-key invariant

apiFetch must always receive a baseURI as its base argument, never a publicURI.

If code resolves a cross-domain target by inspecting the host in its emitted @id or target URL and passes that host to apiFetch, the following happens:

  1. The emitted URL's host is the primary publicURI (e.g., https://eidos-sys-dev.ontoteq.com).
  2. baseToDomain finds product for that host (the primary domain).
  3. apiFetch routes to BASE_URL/node-by-ref?ref=+FEM.CPB05 — the primary server.
  4. The primary server does not own location nodes: HTTP 404, "not found".

The correct resolution path never looks at the URL host. It looks at the relation payload's targetDomain / externalDomain field, or at the sigil character in the externalRef string. These are set when the relation is stored and survive the publicURI aliasing.

This invariant is enforced exclusively by frontend/plugins/domain-routing.js. Call sites that need to resolve a cross-domain base must go through DomainRouting.resolveApiBase(target, DOMAIN_REGISTRY, BASE_URL) or DomainRouting.resolveApiBaseForUrl(url, DOMAIN_REGISTRY, BASE_URL) — never by parsing a URL's origin.


Domain Resolution

Frontend registry load

During application startup (initApp() in frontend/app.js, line 2565), the frontend:

  1. Calls loadRegistry() which fetches domains.json relative to the page origin with a plain fetch("domains.json").
  2. Stores the parsed object in the module-level DOMAIN_REGISTRY variable.
  3. Extracts every distinct baseURI that differs from BASE_URL — these are the remote domains.
  4. Fires apiFetch("/tree", base) in parallel for each remote baseURI, stamping each returned tree with t.domainBase = base.

If loadRegistry() fails the application continues with local-only mode. If a specific remote domain tree fails to load, that domain is skipped with a console warning.

Key resolution functions in app.js

getBaseURI(domain) (line 2230) — throws if the domain key is not in DOMAIN_REGISTRY.domains. Returns entry.baseURI. Use when you have a domain key and need a routing base.

baseToDomain(base) (line 2268) — reverse lookup: given a baseURI string, returns the domain key (or null for the primary / unknown). Normalises trailing slashes. Exposed on window.baseToDomain for plugin use.

resolveBaseForURI(uri) (line 2237) — given a full node URI (e.g., https://eidos-sys-dev.ontoteq.com/node/abc123), returns the baseURI of the matching domain by prefix-testing against every registered baseURI. Returns null if no domain matches. This function is safe for same-domain (@id starts with BASE_URL) and direct-URI cases, but is NOT safe for cross-domain node-by-ref URLs where the host is the primary publicURI. In that case the URI host is the primary, so resolveBaseForURI would return BASE_URL. Use DomainRouting.resolveApiBaseForUrl instead.

How domain-routing.js routes requests

frontend/plugins/domain-routing.js exports three functions. It is a UMD module: available as window.DomainRouting in the browser, and via require() in Node.js test runners.

resolveApiBase(target, registry, baseUrl)

function resolveApiBase(target, registry, baseUrl)
Param Type Description
target object A relation payload or similar object. May contain targetDomain, externalDomain, externalRef, ref, or targetRef.
registry object DOMAIN_REGISTRY{ domains: { <key>: { prefix, baseURI } } }.
baseUrl string The primary BASE_URL — used as the fallback return value for primary-domain or unknown targets.

Returns: a baseURI string. Resolution order: 1. If target.targetDomain or target.externalDomain is set and found in the registry, return that domain's baseURI. 2. If the ref string (externalRef / ref / targetRef) starts with a non-alphanumeric, non-underscore character, find the domain whose prefix matches that sigil and return its baseURI. 3. Return baseUrl (primary fallback).

Never returns an emitted target host. If targetDomain is present but not found in the registry, falls through to sigil resolution, then to the primary fallback.

resolveApiBaseForUrl(url, registry, baseUrl)

function resolveApiBaseForUrl(url, registry, baseUrl)
Param Type Description
url string An absolute URL, typically a stored node-by-ref URL whose host may be the primary publicURI.
registry object DOMAIN_REGISTRY.
baseUrl string Primary BASE_URL fallback.

Extracts the ref query parameter from url and delegates to resolveApiBase({ ref }). The host is discarded entirely.

sourceDomainKeyForBase(registry, base)

function sourceDomainKeyForBase(registry, base)
Param Type Description
registry object DOMAIN_REGISTRY.
base string A node's routing domainBase (always a baseURI).

Returns the domain key for secondary-domain nodes, or "" for the primary (or unknown). Used by the detail panel to determine whether a node belongs to a secondary domain. Matches strictly against baseURI. Never matches against publicURI — this was the root cause of issue #19 where product nodes were misidentified as location nodes because location.publicURI === PRIMARY.


Adding a New Domain

To register a new domain, edit config/domains.yaml (the canonical source) and regenerate frontend/domains.json using scripts/generate_domain_config.py. Do not edit frontend/domains.json directly — the file carries a machine-generated header and will be overwritten.

For each new domain entry, set the following fields:

  1. isPrimary: set to false unless this is a new primary (there can only be one).
  2. internalPort: pick an unused port. Existing assignments: 8090 (product), 8091 (location), 8093 (signal), 8094 (type), 8095 (discipline).
  3. prefix: choose a single non-alphanumeric, non-underscore sigil character not already used by an existing domain. Check the existing prefixes: -, +, ;, %, &.
  4. label: human-readable plural name shown in tabs.
  5. icon: a Lucide icon name.
  6. color and gradient: CSS values for UI branding.
  7. predicate: the default containment predicate name.
  8. baseURI: the internal routing address of the new domain's server. In Docker Compose deployments this is typically http://<service-name>:<internalPort>.
  9. offered_relations: at minimum one entry referencing this domain's own key as target_domain.

Optionally, set the per-domain UI-config fields (above): identity_coordinates to surface a coordinate card, and hidden_zones to hide detail-panel sub-panels this domain does not need. Both are optional and default to "none". If you introduce a new UI-config field, remember the two-path pass-through discipline (add it to both bootstrap whitelists).

After regenerating domains.json, the frontend will pick up the new domain on next application startup without any code changes — loadRegistry() reads the file dynamically, and the remoteDomains list is derived at runtime from the registry.


Integration

eidos-loader (tree loading)

During initApp(), after loadRegistry() completes, the frontend iterates Object.values(DOMAIN_REGISTRY.domains) to collect all distinct baseURI values that differ from BASE_URL. For each, it calls apiFetch("/tree", base) and stamps the returned trees with t.domainBase = base. The domainBase property on a tree record is always a baseURI — it is the value that all subsequent apiFetch calls for nodes within that tree will use. From this point on, every tree node element in the DOM also carries row.dataset.domainBase = domainBase.

mutation-store

When the user edits a node, apiFetch("/mutations/node-update/preview", _edit.selectedDomBase, ...) and apiFetch("/mutations/node-update/commit", _edit.selectedDomBase, ...) are called with _edit.selectedDomBase, which is set to the detail panel's _panelDomainBase at the time of node selection. _panelDomainBase is always a baseURI (set in navigateTo() and selectNode()). This ensures mutations are sent to the correct domain server through the proxy, not to the primary.

The mutation base is also validated by _mutationBase() in app.js (line 4717), which verifies the secondary domain key appears in the registry before permitting the mutation call. If the domain key is not found, _mutationBase() returns null and the edit path is blocked.

Cross-domain relation enrichment

When the detail panel renders outgoing relations to external nodes, it calls DomainRouting.resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL) where r is the relation object from the node payload (containing targetDomain, externalRef, etc.). The returned baseURI is then passed directly to apiFetch as the routing base. For batch enrichment of multiple cross-domain pills, the frontend groups pill elements by el.dataset.enrichPillExtDomain, looks up each domain's baseURI from DOMAIN_REGISTRY.domains[domain].baseURI, and fires one /nodes/meta batch call per domain.

For incoming cross-domain relations, the frontend iterates Object.values(DOMAIN_REGISTRY.domains) to find all domains other than the current node's domain, and calls apiFetch("/nodes-referencing-external?...", entry.baseURI) on each to discover which nodes in other domains reference the current node.

Backend proxy routing

The primary domain's backend receives cross-domain proxy requests at the path /api/proxy/{domain}{path}. The domain segment in the path is the domain key from domains.json (e.g., location). The backend uses this key to look up the corresponding internal URL (using its own domain catalog, sourced from the same config/domains.yaml) and forwards the request. The canonical Python-side operations described in domain-registry.yaml are load_catalog(catalog_path=None) -> dict and get_domain_base_uri(domain_key) -> str, implemented in tools/backend/domain_catalog.py. The backend is the authoritative resolver of domain_key -> internal URL for proxy forwarding, exactly as the frontend is the authoritative resolver via getBaseURI() / DomainRouting.resolveApiBase() for client-side routing.

IAM domain scoping

The IAM panel populates its domain dropdown from Object.keys(DOMAIN_REGISTRY.domains). When a new IAM rule is saved or deleted, the frontend fires an eidos:tree-refresh event with { domainBase: getBaseURI(domain) } so the affected domain's tree panel refreshes. The backend IAM endpoint /admin/iam/domain/{domainName}/tree accepts the domain name key, which the backend resolves using its domain catalog to determine the correct graph partition.


Error Conditions

"Domain not registered: {key}" — thrown by getBaseURI(domain) when the requested key is absent from DOMAIN_REGISTRY.domains. This indicates a domain name key arrived from the backend or a relation payload that was not present in domains.json at registry load time. Fix: add the domain to config/domains.yaml and regenerate.

"Unregistered domain base: {base}" — thrown by apiFetch when baseToDomain(base) returns null. This means a baseURI-shaped string was passed to apiFetch that does not match any entry in the registry. Common cause: a publicURI was passed instead of a baseURI, or the registry was not yet loaded when the call was made.

Registry load failure during init — caught in initApp(). The app continues in local-only mode with a status warning. Cross-domain navigation will fail silently for all secondary domains until the registry is available. reloadRegistry() can be called to retry.

resolveBaseForURI returns null — logged as a warning with the full list of known baseURI values. The caller falls back to BASE_URL. This is expected for truly external URIs not registered in the catalog; it is a bug if it fires for a node that should belong to a registered domain.