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
apiFetchsends requests to reach that domain's server. This is thebaseURI— always an internal network address (e.g.,https://product.ontoteq.com,http://location:8091). - Display identity: what end-user-facing links and emitted node
@idURIs use as their host. In a served-stack deployment, all secondary domains share the primary domain's public hostname as theirpublicURI. 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:
- Committed artifact —
scripts/generate_domain_config.pycopies every field exceptid/enabledverbatim intobackend/+frontend/domains.json.scripts/check_domain_drift.pyfails CI if the JSON drifts from the YAML, so you must regenerate, never hand-edit the JSON. - Deploy-time enrichment —
tools/bootstrap/domain_catalog.py(_DEFINITION_FIELDSwhitelist) andtools/bootstrap/bootstrap.py(generate_frontend_domains_jsonfield-copy loop) rebuild the registry at deploy time fromdeploy.jsonreference 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 howidentity_coordinatesregressed on 2026-06-22. When adding a UI-config field, add it to both whitelists and cover it with a pass-through test (seetests/test_domain_zone_config.pyandtests/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:
- The emitted URL's host is the primary
publicURI(e.g.,https://eidos-sys-dev.ontoteq.com). baseToDomainfindsproductfor that host (the primary domain).apiFetchroutes toBASE_URL/node-by-ref?ref=+FEM.CPB05— the primary server.- 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:
- Calls
loadRegistry()which fetchesdomains.jsonrelative to the page origin with a plainfetch("domains.json"). - Stores the parsed object in the module-level
DOMAIN_REGISTRYvariable. - Extracts every distinct
baseURIthat differs fromBASE_URL— these are the remote domains. - Fires
apiFetch("/tree", base)in parallel for each remotebaseURI, stamping each returned tree witht.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:
isPrimary: set tofalseunless this is a new primary (there can only be one).internalPort: pick an unused port. Existing assignments:8090(product),8091(location),8093(signal),8094(type),8095(discipline).prefix: choose a single non-alphanumeric, non-underscore sigil character not already used by an existing domain. Check the existing prefixes:-,+,;,%,&.label: human-readable plural name shown in tabs.icon: a Lucide icon name.colorandgradient: CSS values for UI branding.predicate: the default containment predicate name.baseURI: the internal routing address of the new domain's server. In Docker Compose deployments this is typicallyhttp://<service-name>:<internalPort>.offered_relations: at minimum one entry referencing this domain's own key astarget_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.