Skip to content

Domain Registry

Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20

956 D1b-1 (2026-10-20): 11 finding(s) fixed — IAM refresh via EidosCore.dispatchMutation kinds iam (eidos:tree-refresh retired), DomainRouting read via EidosCore.module (no window global), publicURI is the node-by-ref host while node @id is the identity IRI, node-update via EidosMutations, init skips hidden domains, loadRegistry via EidosApi.raw, app.js/server.py line pins. Re-verified against main 421c63ba. #1080 (2026-10-22): pass-through discipline now names the backend runtime generator (generate_backend_domains_json) carrying formalType/hierarchyPredicate on every deploy path, its UnknownDomainError refusal and the preflight check. Checked against tools/bootstrap/bootstrap.py, tools/bootstrap/domain_catalog.py and deploy.py on archon/issue-1080 at 3dd9a7aa.

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 cross-domain node-by-ref target URLs use as their host (node @id is the host-independent identity IRI https://id.ontoteq.com/{uuid}). 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 2988) 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 boolean no When true, hides this domain's tree tab from all users on this deployment (Layer 1 of the two-layer visibility system). Default (field absent) means visible. Set in config/domains.yaml (SSOT) or configs/<env>/deploy.json. The primary domain cannot carry this field. Backend access is never gated by hidden — it is a UI-surface flag only; _isHiddenDomainBase in app.js is explicitly a UI-only guard and domain-routing.js stays hidden-blind. See Domain Visibility and the Visible Not Accessible invariant.
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 artifact — scripts/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 enrichment — tools/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).

The backend runtime file has its own allowlist: generate_backend_domains_json in tools/bootstrap/bootstrap.py writes the per-domain domains.json that every deployed backend reads (DOMAINS_FILE), on every path that writes it — deploy.py setup, deploy.py <target>, add_domain, deploy_new_customer and run_bootstrap. It carries each domain's tree declaration, formalType and hierarchyPredicate, which the loader needs for a domain without a .eidos file (#935 S1). Both fields pass through _DEFINITION_FIELDS, and this generator refuses a domain without them with UnknownDomainError — the declaration is never guessed (#1080). deploy.preflight_check refuses the same package before any SSH connection on deploy.py setup and deploy.py <target> (when the target has a package); add_domain, deploy_new_customer and run_bootstrap do not run it — they call validate_tree_declarations on every domain before their first file write or remote call, so a refusal writes nothing. tests/test_runtime_domains_tree_declaration.py drives every path for every target in targets/ and guards the generator against drift from backend/domains.json.


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 of cross-domain node-by-ref target URLs (external_node_uri, backend/server.py:629). It is not the node @id host: node @id is the canonical IRI https://id.ontoteq.com/{uuid} from infra.iri.node_iri (backend/infra/iri.py:74), independent of any domain host. 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 (async function init() in frontend/app.js, line 3470), the frontend:

  1. Calls loadRegistry() (line 2988), which fetches domains.json with EidosApi.raw("domains.json", { base: "same-origin", auth: false }) and throws on a non-OK response.
  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 — after dropping registry-hidden (d.hidden) and user-hidden (getHiddenDomainKeys()) domains (line 3514). A hidden domain gets no /tree fetch and no tab, but stays routable via DOMAIN_REGISTRY.
  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 2994) — 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 3051) — 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 3007) — 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 BASE_URL and every registered baseURI. The longest match wins (a tie goes to BASE_URL), and a match must end at a path boundary (/, ?, # or end of string). This matters in a same-origin proxy deployment (DEV): each secondary's baseURI is {BASE_URL}/api/proxy/{domain}, so {BASE_URL}/api/proxy/location/node/{uuid} matches both BASE_URL and the location base, and resolves to the location base (#1007). Before #1007 a BASE_URL match returned first. That stamped the primary as _eidos_domainBase on secondary nodes after an absolute hop, and the stamp then 404'd on back/forward. 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 plus VERSION. In Node.js test runners it is loaded with require(). In the browser there is no window.DomainRouting global (removed in 4.5c-4, #776): the module registers itself with EidosCore.registerModule('DomainRouting', api) and callers read it with EidosCore.module('DomainRouting').

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), 8097 (document).
  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 (minus hidden domains).

To hide a domain from all users on a deployment, set hidden: true on its entry and regenerate. To let individual users hide the domain themselves while leaving it visible by default, omit hidden and let the per-user toggle in Admin → Domain visibility handle it. The two layers are independent — see Domain Visibility for scope, interaction, and the Visible Not Accessible invariant.


Integration

eidos-loader (tree loading)

During init(), after loadRegistry() completes, the frontend iterates Object.entries(DOMAIN_REGISTRY.domains), skips registry-hidden and user-hidden domains, and collects 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, EidosMutations.preview("node-update", { base: _edit.selectedDomBase, ... }) and EidosMutations.commit("node-update", { base: _edit.selectedDomBase, ... }) are called (app.js:11561, :11620; the two-phase calls go through the EidosMutations layer, not apiFetch) 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 _mutationApiBase() in app.js (line 5942; used at app.js:5957 for renderDetail's mutationBase and at app.js:8931 for canEdit), which verifies the secondary domain key appears in the registry before permitting the mutation call. If the domain key is not found, _mutationApiBase() 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 operation described in domain-registry.yaml is load_catalog(catalog_path=None) -> dict, implemented in tools/bootstrap/domain_catalog.py (not tools/backend/ — that directory holds only domains.json and access_rules.db). The function get_domain_base_uri does not exist anywhere in the codebase; the base-URI lookup lives in _domain_urls (backend/server.py:599, built from infra.config.domain_maps()) inside the server runtime, not in a public catalog function; DomainClient is a separate class in backend/infra/domain_client.py:78. 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, addIamRule / deleteIamRule call EidosCore.dispatchMutation({ kinds: ["iam"], domainBase }) with domainBase = getBaseURI(domain) (app.js:2877, :2833), and the tree-access reconciler refreshes the affected domain's tree. The old eidos:tree-refresh CustomEvent bridge is retired (Wave 3). 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 init(). 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.