Domain¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 4 finding(s) fixed — the EffectiveGraph and domain_root_uid/path are built by eidos_loader.current_graph(); a node's @id/iri is node_iri(uid) = https://id.ontoteq.com/{uuid} since #954 WP5 S7, publicURI only bases external relation target URLs; _domain_urls/_domain_public_urls copy config.domain_maps(), external_node_uri delegates to infra.iri.external_node_by_ref_url; _gate_proxy_domain (server.py:804-822) runs _assert_tier('multi_domain') before the IAM check. Re-verified against main 4996d1ce. #935 S1 (2026-10-21): hierarchyPredicate and formalType added to the domain schema (required by the generator); predicate is no longer described as the tree predicate; a domain may have no .eidos file. Checked against archon/issue-935. #1080 (2026-10-22): the deploy-time runtime domains.json generator also refuses a domain without hierarchyPredicate/formalType. Checked against tools/bootstrap/bootstrap.py on archon/issue-1080 at 3dd9a7aa. #935 S7 (2026-10-22): the loader takes the tree declaration from the runtime domains.json; no .eidos file declares a tree or supplies a domain's data since S5a. Checked against eidos_loader.py at a11d2636.¶
What a Domain Is¶
A domain is the smallest independently deployable unit in the EIDOS graph model. Each domain is a named subtree whose root is a direct child of the WORLD node, connected via the hasDomain predicate. One backend process owns exactly one domain. All domains are structurally equal in the graph: there is no privileged position in the data model for any of them.
The practical consequence is that a multi-domain deployment runs one backend process per domain, each serving its own mutation overlay and its own HTTP API. The frontend SPA is served by the primary backend and reaches secondary domains through a proxy layer inside that same primary backend. From the browser's perspective, every domain is accessed through one host: cross-domain requests are forwarded transparently via /api/proxy/{domain} routes defined in backend/server.py.
Why domains exist¶
Domains partition the graph into independently deployable data sets. This achieves three things:
- Data independence. The product domain's mutation overlay has no runtime coupling to the location domain's equivalent. A migration, rebuild, or restart of the location backend does not affect the product backend.
- Horizontal scaling. Each domain process can be placed on its own host, sized independently, and deployed on its own release cycle.
- Semantic clarity. Domains correspond to distinct concerns in the modelled world — physical products, installation locations, signal types, engineering disciplines, and product types in the current registry. Cross-domain relations (
isLocatedIn,hasType, etc.) are first-class graph edges, not implicit joins.
A developer who misunderstands domains as "just folders" or "database schemas" will be confused by two things in particular: the prefix-based ref system, which uses a single leading character to identify which domain a node reference belongs to; and the baseURI / publicURI split, which routes API calls by registry key rather than by the URL host of the target node.
Domain Schema¶
The authoritative definition of every field lives in config/domains.yaml. The script scripts/generate_domain_config.py derives frontend/domains.json and backend/domains.json from this single source. Never edit domains.json directly.
Per-domain fields in domains.json¶
| Field | Type | Purpose |
|---|---|---|
isPrimary |
bool |
Exactly one domain must be true. The primary domain runs the authentication layer, the proxy layer, and serves the SPA. All other domains accept only service-token calls from the primary. |
internalPort |
int |
The TCP port the backend process for this domain listens on within the Docker network. Used during local development and in Docker Compose service definitions. |
prefix |
str |
A single non-alphanumeric character that prefixes every node label in this domain (e.g. + for location, ; for signal). The prefix is part of the dotted ref path (e.g. +FFL.CTP10.TA37) and is the sigil by which domain-routing.js resolves cross-domain targets that carry no explicit targetDomain key. |
label |
str |
Human-readable name shown in the tree panel header and in IAM domain selectors (e.g. "Locations"). |
icon |
str |
Lucide icon name used for the domain in the UI. |
color |
str |
CSS hex color for this domain's accent (badges, relation pills). |
gradient |
str |
CSS gradient string used for domain cards and tree headers. |
predicate |
str |
The RDF predicate that connects this domain's nodes to nodes in the primary domain (e.g. "isLocatedIn", "hasType"). Not the tree's own edge — that is hierarchyPredicate. |
hierarchyPredicate |
str |
The predicate of this domain's own tree (parent → child), e.g. "hasSubLocation" for location, "hasPart" for product. Required; the generator refuses an enabled domain without it, and so does the deploy-time runtime domains.json generator (#1080). The loader always takes the tree declaration from the runtime domains.json; since #935 S5a no .eidos file declares a tree. |
formalType |
str |
The type of this domain's tree — the domain id, except "documents" for the document domain. Required like hierarchyPredicate and used the same way. |
baseURI |
str |
The internal routing address for this domain's backend. In production this is a Docker service name or internal hostname (e.g. https://location.ontoteq.com in the registry, but resolved to a container address at the proxy layer). This is the address that apiFetch uses when proxying requests. It is never sent to the browser as a clickable URL. |
offered_relations |
list[dict] |
The cross-domain relation types that this domain can be the target of. Each entry carries predicate (the relation label), label (display text in the Add Information dialog), and target_domain (the domain key that will be the target). |
The identity_coordinates field appears only on the product domain. It lists the other domain keys whose nodes serve as identity coordinates for product nodes (the location and type domains in the current registry). This field drives the identity-coordinate section of the detail panel and is not a general cross-domain concept.
Domain Scoping¶
Each domain is its own graph¶
Every backend process owns exactly one mutations/ overlay directory. The eidos_loader.current_graph() function in backend/persistence/eidos_loader.py builds the EffectiveGraph from it and the domain's tree declaration; since #935 S5a no .eidos ZIP is read (a domain still holding only a legacy data.eidos fails with LegacyEidosFileNotConvertedError until python -m tools.migration convert moves its root into the overlay). There is no shared in-memory store between domain processes. When the product backend and the location backend are both running, each has its own _cache dict in its own process.
The WORLD node (00000000-0000-0000-0000-000000000000) is every domain's conceptual anchor (an overlay node where a domain carries one), but it is not a real node that gets queried; it is excluded from the domain root detection logic:
_WORLD_UID = "00000000-0000-0000-0000-000000000000"
domain_root_uid: str | None = next(
(uid for uid in nodes if uid != _WORLD_UID and uid not in parent_of),
None,
)
The domain root is identified as the one node (other than WORLD) that has no parent in parent_of. This traversal is domain-local: it runs inside each backend over that backend's own loaded data.
How the frontend selects the active domain¶
On startup, app.js loads domains.json from the SPA static files into the DOMAIN_REGISTRY global. It then fires two rounds of loading in parallel:
- Primary trees — fetched directly from
BASE_URL/tree(the primary backend). - Secondary trees — fetched from
BASE_URL/api/proxy/{domain}/treefor every domain in the registry whosebaseURIdiffers from the primary's base URL.
Each loaded tree is annotated with a domainBase field set to the baseURI of the domain that provided it. Every tree node element is given a data-domainBase attribute at render time (createTreeNodeElement in app.js). When the user clicks a node, the domainBase is read from the row element and passed to navigateTo(nodeId, domainBase), which stores it in _panelDomainBase. All subsequent API calls for that node use _panelDomainBase as the routing base.
The domain is not explicitly "selected" by the user. It is implicitly selected by which tree the clicked node belongs to.
How the backend knows which domain a request targets¶
For primary-domain requests, no routing is needed. BASE_URL is the primary backend; all standard routes (/node/{id}, /children/{id}, etc.) operate on its own EffectiveGraph.
For secondary-domain requests, the frontend calls apiFetch with a base argument set to the secondary's baseURI. Inside apiFetch (in app.js), any base that is not BASE_URL is mapped to a proxy path:
const domain = baseToDomain(base);
if (!domain) throw new Error(`Unregistered domain base: ${base}`);
url = `${BASE_URL}/api/proxy/${encodeURIComponent(domain)}${path}`;
The primary backend receives the request at /api/proxy/{server}/{path}. The server path segment is the domain key (e.g. "location"). The backend looks up the corresponding baseURI from _domain_urls (loaded from domains.json at startup in server.py) and forwards the request to the secondary process using httpx. The secondary backend never directly receives browser requests in production.
The secondary's EIDOS_DOMAIN environment variable identifies which domain it owns. This is read at startup into _THIS_DOMAIN. It gates IAM enforcement calls (_THIS_DOMAIN is passed into has_write_access, is_node_accessible, etc.), prevents self-proxy loops (BL-INF-011), and determines whether node-type validation applies (_DOMAIN_USES_NODE_TYPES = _THIS_DOMAIN in ("", "product")).
The baseURI / publicURI Split¶
Every domain has a baseURI. Some deployments also define a publicURI. These two URLs serve distinct purposes and must never be used interchangeably.
baseURI — internal routing address¶
baseURI is the address that the EIDOS proxy layer uses to reach a domain backend. In Docker-based deployments this resolves to a container service name and port. In the domains.json registry, it is the key that apiFetch looks up when it needs to forward a request.
_domain_urls in server.py is a copy of the baseURI map derived by the canonical parser config.domain_maps():
_domain_urls: dict = dict(_dmaps.base_urls) # internal routing (baseURI)
This dict is the only source of routing addresses for _proxy_get_raw, _proxy_send, and all proxy endpoints. No other URL field is consulted for routing.
publicURI — what the browser displays¶
publicURI is the base of the target URL of outgoing cross-domain relations. It does not appear in node @id fields: since #954 WP5 S7 a node's @id and iri are node_iri(uid) = https://id.ontoteq.com/{uuid} (backend/server.py:1700-1703). It is the address the browser would navigate to if the system were deployed without the proxy layer. In practice, when the SPA is served from the primary backend, all secondary domains share the primary host as their publicURI, because the browser talks to the primary only.
_domain_public_urls in server.py is a copy of config.domain_maps().public_urls — publicURI, falling back to baseURI:
_domain_public_urls: dict = dict(_dmaps.public_urls) # public URLs (publicURI|baseURI)
This dict is used in external_node_uri to build the clickable target URL embedded in outgoing relation payloads:
def external_node_uri(domain: str, ref: str) -> str | None:
base = _domain_public_urls.get(domain)
if not base:
_slog.warning("domain_url_missing", domain=domain, ref=ref)
return None
return external_node_by_ref_url(base, ref) # infra.iri: {base}/node-by-ref?ref={quoted ref}
The critical distinction¶
A secondary domain's publicURI is often identical to the primary's base URL (because both are served through the same public hostname). This means you cannot determine which domain a node belongs to by inspecting the host of its @id URL. The sourceDomainKeyForBase function in frontend/plugins/domain-routing.js explicitly matches only baseURI, not publicURI, for exactly this reason:
function sourceDomainKeyForBase(registry, base) {
const hit = Object.entries(domains).find(([, d]) => d && d.baseURI && d.baseURI === base);
if (!hit) return '';
return hit[1].isPrimary ? '' : hit[0];
}
Matching against publicURI would resolve any secondary node to the primary domain — the root cause of issue #19, where the CONNECTIONS sub-panel was hidden on product nodes because they were misidentified as belonging to a secondary domain. The invariant is enforced in code by ensuring that resolveApiBase, resolveApiBaseForUrl, and sourceDomainKeyForBase all operate on baseURI (a registry routing key) and never on the emitted target host.
Backend id→domain index (primary only)¶
On the backend, "which domain owns this node id?" is answered by the primary's id→domain index, domain.id_domain_index.resolve_id_domain (#954 WP5 S1). The index is built from per-domain id sets: the primary's own EffectiveGraph plus one GET /nodes list per secondary. Each set is scoped to that domain's hasDomain root, the one non-WORLD node without a parent. An id whose parent chain does not reach the root is an orphan, so it is logged and never indexed. A bare uuid and its https://id.ontoteq.com/{uuid} IRI resolve the same way.
- Refresh: the index is rebuilt at most once per minimum interval (
MIN_REFRESH_INTERVAL_S), on the first lookup after it, whether that lookup is a hit or a miss. A miss inside the interval is a plain logged miss. There is no per-domain/node/{id}probe. - Degraded build: if a secondary was skipped (unreachable, error, malformed list, or fewer nodes than its
/nodestotal) or a domain lacks exactly one root, the build is marked degraded. A degraded build resolves no id as a hit, because the domain it could not see may also claim that id. It is rebuilt on the first lookup after the interval. - Conflict: an id claimed by two or more domains raises the typed
IdDomainConflictError(409). No domain wins by build order. - IAM: a caller without a read rule for the owning domain (
iam.has_domain_read_access, the rule the proxy gate uses) gets the same miss as for an unknown id.
Consumers (#954 WP5 S2). GET /node/{id} and the /me/node-notes/all enrichment consult the index only after a local lookup has missed, so for those two a cold or degraded index never affects a node the primary holds itself. GET /resolve asks the index directly: on a degraded build it is a plain miss (404) even for an id the primary holds itself.
GET /node/{id}: a full uuid that is not local gets the structurednon_local_node404 with oneproxyRoutesentry, for the domain the index names. When the index names none (unknown id, no read access to the owning domain, or a degraded build), the answer is the plainNode '{id}' not found404, identical to an unknown id. No candidate routes are guessed.GET /resolve?id=: takes the node'shttps://id.ontoteq.com/{uuid}IRI, or a bare uuid as legacy input during the WP9 deprecation window, which logs onebare_uuid_deprecatedline (the legacyurn:eidos:node:{uuid}form is refused since #989 S1) and returns{iri, uuid, domain}. An id the index cannot name is the same plain 404. Input that is not a node uuid or node IRI is a 422V-INVALID_NODE_ID./me/node-notes/all: label enrichment groups the non-local note ids by the domain the index names and sends one/nodes/metacall per owning domain. An id the index cannot place keeps blank label fields.
Domain in the EffectiveGraph¶
The EffectiveGraph is per-domain, not federated¶
Each backend's eidos_loader.current_graph() builds an EffectiveGraph from its own mutations/ overlay. The EffectiveGraph for the location domain contains location nodes only. It does not contain product nodes, signal nodes, or any other domain's data.
Cross-domain edges are represented inside the EffectiveGraph as relation dicts with external_domain and external_ref fields set. The target node itself is absent from the local EffectiveGraph. When the primary backend renders a product node's outgoing relations, it identifies which relations have external_domain set, constructs a clickable target URL using external_node_uri, and returns that URL in the eidos:externalRelations payload. A row stored with only the remote uuid (no ref) carries the node's IRI https://id.ontoteq.com/{uuid} as its target instead (#954 WP5 S7); the frontend finds its domain through GET /resolve. The browser then calls the appropriate proxy route to fetch the target node's data when the user navigates to it.
How domain identity flows through the system¶
The domain_root_uid and domain_root_path keys in the EffectiveGraph (computed in eidos_loader.current_graph()) identify the root node of this backend's partition. The ref_by_uid and ref_index maps use dotted paths prefixed with this domain's sigil character. For example, a location domain with prefix + would produce ref paths like +FFL.CTP10.TA37. This prefix is how other backends identify cross-domain refs at a glance.
When the primary backend commits a cross-domain relation (a product node pointing to a location node), it stores the relation with external_domain = "location" and external_ref = "+FFL.CTP10.TA37". When the primary later renders that relation, it looks up "location" in _domain_public_urls to construct the target URL. When the frontend resolves it via DomainRouting.resolveApiBase, it matches targetDomain = "location" against the registry to get the correct baseURI for the proxy call — never the host in the target URL.
The IAM subsystem also uses domain identity as a scope dimension. Access rules in access_rules.db are keyed by (subject, domain, subtree_root). The primary backend's IAM enforcement functions receive _THIS_DOMAIN as the domain argument for primary-domain nodes, and the server proxy parameter (e.g. "location") for secondary-domain nodes. This means a user can be granted viewer on product, editor on location, and no access to signal independently.
Adding, Removing, Switching Domains¶
Adding a new domain¶
The authoritative steps for adding a domain are:
-
Define it in
config/domains.yaml. Add an entry with a uniqueid, a uniqueprefixcharacter, a uniqueinternalPort, and the other definition fields. Setenabled: true. -
Regenerate the JSON configs. Run
python scripts/generate_domain_config.py. This writes the updatedfrontend/domains.jsonandbackend/domains.json. Commit both files. Do not hand-edit them; they carry a_commentwarning that they are generated. -
The root of the new domain is created by bootstrap.
tools/bootstrap/bootstrap.py(run by the deploy setup, or byPOST /setup/bootstrap) writes a single root node file into the domain's overlay —mutations/nodes/<uuid>.jsonld, noeidos:parent,eidos:treeId= its own id, the id frominfra.key_derivation.derive_root_uuid(domain_key)(#935 S4a). The root label is prefixed with the domain's sigil character. Bootstrap never generates or writes a.eidosfile. A domain that still has a legacydataFile(its root is indata.eidosuntil it is converted), or whose overlay already holds any node or relation — from an earlier bootstrap, or data imported through FME ortools/migration— is left untouched, so a rerun changes no file. The tree is declared byhierarchyPredicate/formalTypefrom step 1, and the backend refuses to build a graph until exactly one root node exists;GET /setup/statusreportsinitialized: true, andPOST /setup/bootstrapanswers 409, as soon as a legacydata.eidosexists (at any size), the derived root file exists, or any*.jsonldis inmutations/nodes/,mutations/relations/,mutations/node_tombstones/ormutations/tombstones/— a bounded file check that never loads the overlay. A read error during the check counts as initialised (fail closed). -
Deploy a new backend container. The domain's data comes from the overlay root of step 3 and the tree declaration in the runtime
domains.json— never from a.eidosfile, so the generated compose and the image set noEIDOS_FILEanddeploy.pyuploads no seed (#935 S4b). A package that still hasseed/*.eidosfiles or a domainseedFileis refused withSeedInputRefusedbefore any SSH connection — by--setup, the<target>update,--add-domainandnew-customer— naming the seed files; that data must arrive through FME ortools.migrationimport. A legacy/data/data.eidoson an existing volume (the config register'seidos_file()default) is never opened, only checked for existence (#935 S5a): beside an overlay with no root the loader raisesLegacyEidosFileNotConvertedErrornaming thetools.migration convertcommand, beside a converted overlay it logseidos_file_leftover_ignored. The new backend must be configured with: EIDOS_DOMAIN— the domain id (e.g."discipline")PRIMARY_BACKEND=false(or absent)SECONDARY_MUTATIONS=1if this domain needs write supportBASE_URLorPUBLIC_URL— the public URL (shared with the primary in a single-host deployment)-
SERVICE_TOKEN— the shared secret so the primary can proxy requests in. Never set this inline indocker-compose.yml— secrets (SERVICE_TOKEN,PORTAL_TOKEN,LICENSE_KEY,SMTP_PASS) live only in the host's0600 .env, which every service loads viaenv_file:(hardening #513).deploy.pygenerates bothdocker-compose.yml(withenv_file: - .envper service, no inline secrets) and the.envitself;add_domainandsetupshare one.envrenderer so an added domain never leaves the primary withoutSMTP_PASS. -
Update the primary backend's
domains.json. The primary reads_domain_urlsfrom its own copy ofdomains.jsonat startup. Ensure thebaseURIfor the new domain points to the new container's address inside the Docker network (e.g.http://discipline-backend:8095). Restart the primary backend after this change. -
Restart the frontend. The browser loads
domains.jsononce at SPA startup. A stale browser session will not see the new domain's tree until it reloads.
Removing a domain¶
Set enabled: false in config/domains.yaml, regenerate the JSON files, and stop the corresponding backend container. The primary backend will no longer attempt to proxy to that domain. Existing cross-domain relations that pointed to nodes in the removed domain will appear as unresolved in the UI (the unresolved flag on the relation payload is set when external_node_uri returns None for an unregistered domain).
Switching domains in the frontend¶
There is no explicit "switch domain" operation in the UI. The domain is determined entirely by which tree node the user clicks. The tree panel shows all domains' trees simultaneously after the parallel init sequence. Clicking a node in the location tree navigates with domainBase = "https://location.ontoteq.com" (the registry baseURI), which the proxy layer translates to the correct container call. Clicking a node in the product tree uses BASE_URL (the primary) directly.
The self-proxy guard (BL-INF-011)¶
The primary backend lists secondary domains by excluding itself from _SECONDARY_DOMAINS:
_SELF_DOMAIN = _THIS_DOMAIN or _PRIMARY_DOMAIN_KEY # "product" if EIDOS_DOMAIN is unset
_SECONDARY_DOMAINS = {
name: url
for name, url in _domain_urls.items()
if name != _SELF_DOMAIN
}
Additionally, the self-proxy guard lives one layer below the FastAPI gate. DomainClient._guard_self (backend/infra/domain_client.py:124-130) refuses to send a cross-domain request to the instance's own domain and raises the typed SelfProxyError:
# backend/infra/domain_client.py:124-130
def _guard_self(self, domain: str) -> None:
# BL-INF-011: refuse to proxy to this instance's own domain (self-proxy
# loop). Defence-in-depth behind the build-time exclusion from the
# allowlist — catches a misconfig or future regression. Typed domain error
# → mapped to HTTP 421 Misdirected Request at the boundary (trin 3.1).
if domain == (self._this_domain() or "product"):
raise SelfProxyError(domain)
The error maps to HTTP 421 Misdirected Request at the boundary (trin 3.1) — not 400. _gate_proxy_domain (backend/server.py:804-822) is a separate FastAPI dependency that first calls _assert_tier("multi_domain") and then performs the _has_domain_read_access IAM check, raising 403 on denial; it contains no self-proxy loop guard itself. Dependency order means the IAM gate runs first; if the IAM check passes but the server then issues a cross-domain request, _guard_self fires inside the client.
If EIDOS_DOMAIN is unset (standalone product deployment), _this_domain() defaults to "product". Without this guard, a misconfigured instance with EIDOS_DOMAIN unset would accept /api/proxy/product and loop back to itself indefinitely. Any new domain added to domains.json must therefore never receive the same id as the primary domain key ("product") unless it truly is the primary.
Client-side Dangling Target Detection¶
The backend limitation¶
The backend marks an external relation unresolved: true only when the target domain has no configured public URL (external_node_uri() returns None). It never performs a live HTTP probe to verify that the target node still exists on the remote domain. A relation to a configured domain therefore always arrives from the backend with unresolved: false, even if the target node was deleted.
The frontend compensating pass¶
After the initial panel render, loadNodeDetail fires _detectDanglingExternalRels as a fire-and-forget async call. The function probes each external target using r.externalRef + DOMAIN_REGISTRY.domains[r.targetDomain].baseURI (never r.target, which is a Docker-internal URI that apiFetch cannot route).
Slug vs UUID invariant: if the slug probe returns 404, the function falls back to r.externalUUID. A renamed or moved node keeps its UUID while its path changes — only if both slug and UUID fail is the target truly dangling. This prevents renamed nodes from being falsely flagged UNRESOLVED.
If the node is found, stale targetLabel, targetDesc, and externalRef fields are refreshed in-place so the UX reflects the current name (these fields are baked in at relation-create time and never updated by the backend). If the target is truly gone, r.unresolved = true is set in-place and the panel re-renders to show the UNRESOLVED badge.
A navGen + _panelNodeId guard ensures that a slow network check for one node cannot paint stale state on a different node after the user has navigated away.
The treeview unresolved chip is patched separately by _patchUnresolvedChipCount, which adds the client-detected dangling count on top of the backend metric (which cannot account for renamed or deleted remote targets).
Domain Visibility¶
Domains can be hidden from the tree panel and tab bar through two independent mechanisms. Both are UI/tree-surface controls — neither one affects IAM-gated node access, routing, or cross-domain relation resolution.
Layer 1 (deployment-wide): an operator sets hidden: true on a domain in
config/domains.yaml (or in configs/<env>/deploy.json for a per-deployment
override). After regenerating via scripts/generate_domain_config.py and
redeploying, the domain disappears from the tree panel for all users on that
instance.
Layer 2 (per-user): any user can hide or show any non-primary domain from
Admin → Domain visibility. The preference is stored in
localStorage.eidos_tree_prefs.hiddenDomains and affects only the current browser.
The primary domain (isPrimary: true) cannot be hidden by either layer — it is the
orientation anchor (GCF Contract 15, R26-R33).
For the full reference — when-to-use, where configured, scope table, and the visible ≠ accessible invariant — see Domain Visibility.