Domain¶
Verified — minor divergences from the code — 1 finding(s) · 19d408be · 2026-08-25
The domain.md doc is exceptionally precise about schema, file paths, function names, and even quotes several code blocks verbatim and correctly (e.g. domain-routing.js sourceDomainKeyForBase, eidos_loader.py domain-root detection, server.py apiFetch, config/domains.yaml field list). One divergence found: the self-proxy guard section misattributes the guard to the wrong function and misquotes the HTTP status code it raises.
Divergences from the code — details
| Sev | Where | Doc says | Code does | Evidence |
|---|---|---|---|---|
| MEDIUM | The self-proxy guard (BL-INF-011) | Doc's 'The self-proxy guard (BL-INF-011)' section states that _gate_proxy_domain explicitly refuses to proxy to the instance's own domain key at request time, quoting: if server == (_THIS_DOMAIN or _PRIMARY_DOMAIN_KEY): raise HTTPException(status_code=400, detail=f"Refusing to proxy to this instance's own domain '{server}'") |
That exact check lives in _proxy_send, not _gate_proxy_domain (which only performs the _has_domain_read_access IAM check). The HTTPException it raises uses status_code=421, not 400. |
backend/server.py:669-687 (_gate_proxy_domain, no self-proxy check present) and backend/server.py:730-734 (_proxy_send, actual guard, status_code=421) |
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 .eidos file, 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
.eidosfile and mutation overlay have 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's .eidos file (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"). This is also the hierarchyPredicate of the domain's tree. |
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 .eidos ZIP file and one mutations/ overlay directory. The eidos_loader._load() function in backend/persistence/eidos_loader.py builds the EffectiveGraph from these two sources. 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) appears in every domain's .eidos as a conceptual anchor, 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 populated from baseURI:
_domain_urls[_name] = _info.get("baseURI", "").rstrip("/")
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 canonical URL that appears in node @id fields and in the target field of outgoing cross-domain relations. 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 populated from publicURI, falling back to baseURI:
_domain_public_urls[_name] = _info.get("publicURI", _info.get("baseURI", "")).rstrip("/")
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:
return None
return f"{base}/node-by-ref?ref={quote(ref, safe='')}"
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.
Domain in the EffectiveGraph¶
The EffectiveGraph is per-domain, not federated¶
Each backend's eidos_loader._load() builds an EffectiveGraph from its own .eidos file and 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:outgoingRelations payload. 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._load()) 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. -
Create the
.eidosfile for the new domain. The new domain needs its own data file. Prepare it with the correct root node structure (root label prefixed with the domain's sigil character). -
Deploy a new backend container. The new backend must be configured with:
EIDOS_DOMAIN— the domain id (e.g."discipline")EIDOS_FILE— path to the new domain's.eidosfilePRIMARY_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,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, _gate_proxy_domain explicitly refuses to proxy to the instance's own domain key at request time:
if server == (_THIS_DOMAIN or _PRIMARY_DOMAIN_KEY):
raise HTTPException(status_code=400, detail=f"Refusing to proxy to this instance's own domain '{server}'")
If EIDOS_DOMAIN is unset (standalone product deployment), _SELF_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 constant ("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).