Skip to content

Cross-Domain Resolution

Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20

956 D1b-4 (2026-10-20): 6 finding(s) fixed — the relation's own uuid is derive_overlay_relation_uuid(idem_key) (WP4) and the external placeholder is not written to disk (eidos:target ExternalRef; read_target_ref_jsonld re-derives it or returns eidos:node); app.js apiFetch is a thin wrapper and routing/proxy rewrite/throw live in _resolveUrl in eidos-api.js; the http-client ratchet is 1 (domain_client only, the chat residual went in #579); getBaseURI sites app.js:2831/2875 fall back to BASE_URL, the repo-chip site :13344 to undefined; app.js pins re-cited (navigateTo 5129, seeds 5135/5137, neighbour fetch 7122, warn 7142, allSettled 3520); neighbour descriptions are cached by tgtUuid. Re-verified against main 4996d1ce. #967 WP8 P3 (2026-10-21): the TargetRef section now says the EXT_DOMAIN_KEY / EXT_REF_KEY / EXT_TARGET_REF_KEY public aliases are deleted and no module outside target_ref.py names a key constant (const ratchet pinned at 0; tools/ uses external_ref_of / external_ref_fields); the read_target_ref_jsonld pin moved to target_ref.py:215. Checked against the P3 change on top of main 562594ac. #967 WP8 P4 (2026-10-21): new subsection 'Placeholder upgrade on write' — create_relation upgrades a stored ExternalRef/UnresolvedRef placeholder to the real node identity on the next write of that relation (duplicate-triple branch or idempotency guard) via the injected id→domain index; never on load, never in bulk; misses, mismatches and index errors keep the placeholder byte-identical; sources gain graph_crud_engine.py and id_domain_index.py. Checked against the P4 change on top of main d0c1b465. #967 WP8 P4 send-back r1 (2026-10-21): the upgrade now requires the stored source, predicate and placeholder ref (and domain) to match the request, binds an UnresolvedRef only to a live non-tombstoned node, uses the cache-only resolve_id_domain_cached under the write lock (no refresh), emits one UpgradeRelationTarget audit event that the as-of fold applies, narrows the read/lookup/write catches, and skips dry runs at both call sites; sources gain reconstruction_service.py.

flowchart LR
%% generated_from: cross-domain-resolution
%% verified_at_commit: 4996d1ceb770fb4545edebc5fd140792bfb23830
%% description: Cross-Domain Resolution lifecycle
%% legend: drki=domain-routing-key-invariant
  subgraph frontend
    detect-cross-domain-ref["Detect Cross Domain Ref · drki"]
  end
  subgraph api
    resolve-routing-key["Resolve Routing Key · drki"]
    proxy-fetch["Proxy Fetch · drki"]
  end
  subgraph domain
    secondary-resolution["Secondary Resolution"]
  end
  detect-cross-domain-ref --> resolve-routing-key
  resolve-routing-key --> proxy-fetch
  proxy-fetch --> secondary-resolution

Overview — The Multi-Domain Problem

EIDOS Explorer is a single-page application served from one primary host, but the data it manages spans multiple independent backend domains. In the default deployment there are six domains: product (primary), location, signal, type, discipline, and document. Each domain runs its own backend service on its own internal port (8090–8095, plus 8097 for document) and maintains its own graph of nodes. A product node can hold a relation to a location node, a type node, a signal node, or a document; the UI must be able to fetch, display, and navigate to any of those targets regardless of which backend owns them.

The core difficulty is that the SPA is served from the primary host. The browser can only make authenticated API calls back to that same host, not directly to secondary backends (which are not exposed to the internet). All cross-domain API traffic must therefore be routed through the primary backend's proxy, which forward-routes to the appropriate secondary.

The routing key that drives this proxy is the baseURI field from domains.json, e.g. https://location.ontoteq.com. Each domain has a distinct baseURI. The proxy path is /api/proxy/{domainKey}/{path}, where {domainKey} is the string key for the domain in domains.json (e.g. location).

What breaks with incorrect routing

The critical failure mode, documented in BL-INF-011 and BL-ARCH-024 and described in the header of frontend/plugins/domain-routing.js, is confusing baseURI with publicURI. Secondary domains are served through the primary SPA; their publicURI — the URL that appears in node links the backend emits — equals the primary host. If code resolves a cross-domain target by parsing its URL's origin (e.g. new URL(targetUrl).origin), it gets the primary host back. The resulting apiFetch call goes to the primary backend's own /node-by-ref endpoint, which knows nothing about location or signal nodes, and returns 404. This defect recurred multiple times and is the sole reason that domain-routing.js exists as a canonical, single-authority resolver.


Stage 1: Request Initiated

YAML stage id: detect-cross-domain-ref
Engine: frontend-crud
Zone: frontend

The workflow begins when the frontend must fetch a node that belongs to a domain other than the one currently selected in the detail panel. This happens in two contexts:

  1. The user clicks a relation target in the detail panel. The relation record contains either an explicit targetDomain / externalDomain field, or an externalRef whose first character is a domain sigil.
  2. The graph view expands into neighbour nodes; it encounters eidos:externalRelations entries that reference nodes in other domains.

Domain identity signals:

Every cross-domain reference carries at least one of: - targetDomain or externalDomain — an explicit string key matching a key in domains.json (e.g. "location"). - externalRef / ref / targetRef — a path-style reference whose first character is a non-alphanumeric, non-underscore sigil that uniquely identifies the owning domain: - + — location - ; — signal - & — discipline - % — type - @ — document - - — product (primary)

Unprefixed references are treated as belonging to the primary domain. The sigil check is implemented in _sigil() in frontend/plugins/domain-routing.js:

function _sigil(ref) {
  const r = String(ref || '').trim();
  return (r && !/[a-zA-Z0-9_]/.test(r[0])) ? r[0] : '';
}

The domain identity signal (explicit key or sigil) is what survives through every subsequent stage. The emitted URL host is never used for routing because it is unreliable — it reflects publicURI, not baseURI.

Active domain state: navigateTo() in app.js (line 5129) sets two module-level variables every time the user navigates to a node: _panelDomainBase and _edit.selectedDomBase. These hold the baseURI of the domain that owns the currently-displayed node. Both are seeded from BASE_URL (the primary) and updated as the user navigates (app.js:5135, app.js:5137). They drive all subsequent mutation and fetch calls in the detail panel so those calls always go to the backend that owns the node being edited.


Stage 2: Domain Lookup

YAML stage id: resolve-routing-key
Engine: domain-registry
Zone: api

Once the frontend has identified that a target is cross-domain, it must translate the domain identity (a key string or a sigil) into a baseURI routing key. This is the responsibility of resolveApiBase() in frontend/plugins/domain-routing.js, which is the single canonical resolver for this translation (Contract 02.01).

function resolveApiBase(target, registry, baseUrl) {
  const domains = (registry && registry.domains) || {};
  const t = target || {};
  // 1) Explicit domain identity wins
  const dk = t.targetDomain || t.externalDomain || '';
  if (dk && domains[dk] && domains[dk].baseURI) return domains[dk].baseURI;
  // 2) Sigil selects the owning domain
  const sig = _sigil(t.externalRef || t.ref || t.targetRef);
  if (sig) {
    for (const d of Object.values(domains)) {
      if (d && d.prefix === sig && d.baseURI) return d.baseURI;
    }
  }
  // 3) Primary fallback
  return baseUrl;
}

The function takes the full relation object as target, the loaded DOMAIN_REGISTRY (populated from domains.json at startup), and the primary BASE_URL as fallback. It returns a baseURI string — never an emitted URL host.

Resolution priority is strict: 1. Explicit targetDomain / externalDomain field — most reliable, never ambiguous. 2. Sigil prefix on the ref string — reliable as long as the sigil convention is enforced in the data. 3. Primary fallback — used for same-domain or unknown targets.

Why not publicURI? The domains.json structure (from frontend/domains.json) makes the distinction concrete. For the location domain:

"location": {
  "isPrimary": false,
  "internalPort": 8091,
  "prefix": "+",
  "baseURI": "https://location.ontoteq.com",
  ...
}

baseURI is the internal routing identity used by the proxy. publicURI (the URL stamped on emitted node payloads by the backend) equals the primary host because the SPA serves all domains through the primary's nginx layer. Using new URL(target).origin on an emitted location node URL would return https://product.ontoteq.com (the primary), not https://location.ontoteq.com.

Registry loading: loadRegistry() in app.js fetches domains.json at SPA startup and populates the module-level DOMAIN_REGISTRY object. getBaseURI(domainKey) provides direct lookup by key and throws "Domain not registered: <key>" if the key is absent. baseToDomain(baseURI) provides the reverse lookup (baseURI → key); it is injected into EidosApi as resolveDomain (app.js:3105) and used by _resolveUrl() in frontend/eidos-api.js to derive the proxy path segment.


Stage 3: Request Dispatch

YAML stage id: proxy-fetch
Engine: frontend-crud
Zone: api

With a baseURI routing key in hand, the frontend calls apiFetch() (app.js:3080):

async function apiFetch(path, base = BASE_URL, timeoutMs = 8000, extraOpts = {})

apiFetch() is a thin wrapper over EidosApi.request() (4.1a #674); the routing logic lives in _resolveUrl() in frontend/eidos-api.js (line 62), with primaryBase bound to BASE_URL and resolveDomain to baseToDomain:

const primary = typeof _deps.primaryBase === "function"
  ? _deps.primaryBase() : _deps.primaryBase;
if (!base || base === primary) return `${primary}${path}`;
const domain = _deps.resolveDomain(base);
if (!domain) throw new Error(`Unregistered domain base: ${base}`);
return `${primary}/api/proxy/${encodeURIComponent(domain)}${path}`;

When base is anything other than the primary BASE_URL, baseToDomain() converts it back to the domain key string, and the call is rewritten to /api/proxy/{domain}/{path} on the primary host. The primary backend's proxy then forwards the request to the secondary backend using the internal port.

Authentication: The call always goes to the primary host, so the user's Bearer token (added by authHeaders()) reaches only the primary. The primary backend is responsible for forwarding authentication to secondary services. The browser never sends credentials directly to a secondary backend URL. This is the correct security model for the architecture.

Cross-domain call examples from app.js: - Graph view neighbour fetch (app.js:7122): apiBase = window.EidosCore.module('DomainRouting').resolveApiBase(r, DOMAIN_REGISTRY, BASE_URL), then apiFetch(EidosPaths.nodeNodeId(tgtUuid), apiBase) (stable UUID path, #1008). Reads the DomainRouting singleton via EidosCore.module(...) (Contract 02.01, 4.5c). - /node-by-ref fallback inside the same neighbour-fetch block: retried as EidosPaths.nodeByRef + ?ref= via externalRef (else the ref of a /node-by-ref target) when the UUID is stale (BL-ARCH-025). - Tree loading for secondary domains (app.js:3520): Promise.allSettled(remoteDomains.map(async base => apiFetch(EidosPaths.tree, base))) for each baseURI in DOMAIN_REGISTRY.domains that differs from BASE_URL and is not hidden. - IAM rule delete/add domain base lookup (app.js:2831, app.js:2875): try { return getBaseURI(domain); } catch { return BASE_URL; } — the canonical try/catch pattern around getBaseURI for optional domain keys. The repo-management chip (app.js:13344) uses the same try/catch but falls back to undefined (no base), not BASE_URL.

Unregistered domain guard: If baseToDomain() returns null — meaning the given base string is not a known baseURI in DOMAIN_REGISTRY — _resolveUrl() (and so apiFetch) throws "Unregistered domain base: <base>" immediately, before any network request is made. This prevents silent mis-routing to the primary.


Stage 4: Response Integration

YAML stage id: secondary-resolution
Engine: eidos-loader
Zone: domain

The secondary backend receives the proxied request at its own /node-by-ref or /node/{uid} endpoint and resolves it against its own EffectiveGraph — the secondary domain's complete local graph of nodes. It returns a standard node payload in the same JSON-LD shape the primary uses.

Back in the frontend, the returned data is integrated in two ways depending on the call context:

Navigation (full node detail): When the user navigates to a cross-domain node, navigateTo() sets _panelDomainBase to the secondary's baseURI. The detail panel renders the node's properties, relations, and spec values exactly as it would for a primary node. All subsequent mutation calls (preview and commit) use _edit.selectedDomBase, which holds the same secondary baseURI, so edits go to the correct backend.

Graph view (partial data): Cross-domain neighbours are added to nodesMap with an external: true flag. Their description strings are fetched via the proxy and stored in nodeCache keyed by the target's uuid (tgtUuid); a target with no uuid is not fetched or cached. If the fetch fails (e.g. the secondary is unreachable), the failure is logged with console.warn and the node is still added to the graph without a description. The graph therefore degrades gracefully — missing descriptions are surfaced in the console but do not crash the view.

Tree loading: Remote domain trees are loaded in parallel at startup using Promise.allSettled(). Each fulfilled tree array is tagged with domainBase set to that domain's baseURI and appended to allTrees. A rejected secondary tree load emits a console.warn and the primary tree remains usable. The tree tabs are re-rendered after all settled results are processed.


The Backend Proxy Layer: the canonical DomainClient

Everything above happens in the browser and ends at the primary backend's proxy route. Inside the primary backend, every outbound call to a secondary domain — the proxy fan-out, cross-domain node resolution, metrics/reconstruction/export fan-outs, IIS lookups, the write-engine forward — goes through one owner: backend/infra/domain_client.py (DomainClient). This is the server-side enforcement of Contract 02.01 (single canonical owner of cross-domain HTTP).

Before the client existed, the proxy helper _proxy_send declared itself the sole owner, but 28 raw httpx.AsyncClient(...) / urllib constructions across a dozen modules bypassed it — each re-inventing the service-token header (six omitted the trace-id), the timeout, and the error mapping. Hærdning trin 2.3 collapsed all of that into the client; the AST ratchet in tests/test_http_client_guard.py freezes the count at one legitimate construction (_BASELINE_TOTAL = 1): the client itself. The former Anthropic-API residual (plugins/chat.py) was deleted with the disabled chat feature in hærdning 3.4 (#579).

Config from the register, not the server module. The client takes bases from config.domain_maps(), the service token from config.service_token(), and its four timeout tiers from config.proxy_timeout_{fast,default,upload,export}() (the trin 2.2 config register). It never imports server; the domain→URL resolver and the self-domain identity are injected at construction. That keeps it re-liftable into eidos-core unchanged — the server owns the (self-excluding, test-monkeypatchable) allowlist, the client owns the HTTP mechanics.

Headers built in one place. service_headers() always attaches X-Service-Token; it adds X-Trace-Id whenever a trace_id is bound in the request's structlog contextvars (outbound correlation), and X-Eidos-Username when a call acts on behalf of a principal (IAM read-filtering / audit on the secondary). A secondary accepts that forwarded identity only for an existing, active user. The primary refuses it with 401 (#1057; see the IAM Engine's Service-token trust model). Centralising this gave the six previously trace-less call sites correlation for free.

Timeout tiers. FAST (5s) for health/stat/node lookups and ext-ref resolution; DEFAULT (15s) for standard proxy GETs and the write-engine forward; UPLOAD (30s) for larger transfers (raw proxy, domain export/import); EXPORT (120s) for long-running mutation POSTs. The historical hardcoded literals were each mapped to the nearest tier — never to a shorter wait.

Self-proxy guard (BL-INF-011). Before any request the client refuses to proxy to this instance's own domain (HTTP 421), defence-in-depth behind the build-time exclusion from the allowlist.

The error-mapping contract (Contract 05)

The client owns the mapping from a library or upstream failure to a user-safe status, so an httpx exception never escapes as an unhandled 500:

  • Timeout (httpx.TimeoutException) → 504 secondary_timeout.
  • Other network error (httpx.RequestError) → 502 secondary_unreachable. Order matters — TimeoutException is a RequestError subclass, so it is mapped first.
  • Upstream 4xx is preserved as that status (a 404 is special-cased secondary_not_found, so a genuinely-missing target surfaces as "Unresolved relation", not a gateway error).
  • Upstream 3xx or 5xx collapses to 502 secondary_bad_status — the secondary itself is broken.

The upstream status is never echoed to the client body; it is carried on ExternalServiceError.upstream_status for server-side logging only (R14/R15), and the structured error response ({error, detail, trace_id} + X-Trace-Id header) is produced by the API boundary's _domain_error_response. A non-JSON upstream body is replaced with a generic detail, never forwarded raw.

The four _proxy_get / _proxy_get_raw / _proxy_request / _proxy_send helpers in server.py remain as thin delegates over the client, preserving the signatures that api/proxy_router.py and routes/documents.py still call. They are a migration scaffold, slated to retire into the client's own API at eidos-core P0.


The Critical Invariant: baseURI vs publicURI

This invariant is identified in the YAML as domain-routing-key-invariant and is enforced at three points in the codebase:

  1. resolveApiBase() in domain-routing.js — the resolver explicitly reads d.baseURI from the registry entry and returns it. It never reads from the target URL.
  2. sourceDomainKeyForBase() in domain-routing.js — matches a node's domainBase field against d.baseURI, not d.publicURI. The comment for this function explicitly calls out issue #19: matching against publicURI resolved primary product nodes to a secondary domain, which caused the CONNECTIONS sub-panel to be hidden on product nodes.
  3. _resolveUrl() in frontend/eidos-api.js (behind apiFetch() in app.js) — compares base against the primary BASE_URL to determine routing. Only baseURI values appear in this comparison; publicURI values are never stored in any variable that reaches apiFetch.

Concrete failure example:

Suppose a product node has a relation with externalRef: "+FFL/Pump Room A" and targetDomain: "location". The location domain has:

"baseURI": "https://location.ontoteq.com",
"publicURI": "https://product.ontoteq.com"

(publicURI equals the primary because the SPA proxies the display.)

If a developer writes new URL(rel.target).origin to derive the fetch base, they get "https://product.ontoteq.com" — the primary. apiFetch('/node-by-ref?ref=+FFL/Pump Room A', 'https://product.ontoteq.com') sends the request to the primary backend at https://product.ontoteq.com/node-by-ref?ref=+FFL/Pump Room A. The primary's graph contains no location nodes. It returns 404. The UI shows "Relation target not found."

The correct call: resolveApiBase(rel, DOMAIN_REGISTRY, BASE_URL) returns "https://location.ontoteq.com". apiFetch('/node-by-ref?ref=+FFL/Pump Room A', 'https://location.ontoteq.com') rewrites to https://product.ontoteq.com/api/proxy/location/node-by-ref?ref=+FFL/Pump Room A. The primary proxies it to the location backend on port 8091. The location backend finds the node and returns it.


Error Scenarios

Unknown domain

Trigger: getBaseURI(domainKey) is called with a key that does not exist in DOMAIN_REGISTRY.domains.

Behaviour: Throws "Domain not registered: <key>". The call site must catch this. The canonical try/catch wrapper around getBaseURI with BASE_URL as fallback appears in app.js:2831 and app.js:2875; the repo-management chip handler at app.js:13344 catches too but falls back to undefined (no base), so the node is located as a local-domain attachment.

Root cause: The domains.json file on the frontend and the backend's catalog have drifted, or a relation record in the database references a domain that was decommissioned.

Detection: Check that domains.json (generated from config/domains.yaml by scripts/generate_domain_config.py) includes an entry for every targetDomain value stored in the relation payloads.

Unreachable secondary domain

Trigger: The secondary backend is down, its internal port is blocked, or the proxy misconfiguration causes the primary to fail the forward.

Behaviour at tree load: Promise.allSettled() catches the rejection for that domain. console.warn("Could not load remote trees: <message>") is emitted. The domain's tree tabs are absent from the UI but the primary tree is unaffected.

Behaviour at node fetch: apiFetch throws either a network error or an HTTP error. In the graph view, the cross-domain neighbour node is added to the graph without a description, and a console.warn is emitted (app.js:7142: "[EIDOS] cross-domain neighbour fetch failed:"). In the navigation path (navigateRelTarget), the error propagates to the caller which typically surfaces it in the status bar.

Detection: Check console.warn output for "cross-domain neighbour fetch failed" or "Could not load remote trees". Verify that the proxy route /api/proxy/{domain} is correctly configured on the primary backend.

Mis-routed request (baseURI/publicURI confusion)

Trigger: Code outside domain-routing.js derives a fetch base from an emitted node URL using new URL(target).origin or similar.

Behaviour: Request goes to the primary backend. Primary returns 404 for the foreign ref. UI displays "not found" for a node that exists on the secondary. No crash, no network error — the failure is silent unless the console.warn contract (BL-INF-011) is followed.

Detection: Search the codebase for new URL( followed by .origin in any context that constructs an apiFetch base argument. The only permitted way to derive a cross-domain fetch base is DomainRouting.resolveApiBase() or getBaseURI(domainKey). Direct calls to apiFetchAbsolute() are permitted only when the absolute URL is known to have come from a reliable source such as the baseURI field itself, not from an emitted node payload.

Blast radius: Every cross-domain relation navigation and graph expansion silently fails. The UI appears to work (no crash) but cross-domain nodes are unreachable. This is the most dangerous failure mode because it is invisible without explicit logging and has recurred multiple times (BL-INF-011, BL-ARCH-024, issue #19).

Mis-routed relation write/delete (target-domain vs owner-domain)

The ownership rule. A cross-domain relation is owned and stored in the SOURCE node's domain, never the target's. Example: a product node -TAA01 --isLocatedIn--> +FEM (a location node) is stored as an overlay relation in the product domain (product/mutations/relations/<id>.jsonld), with externalDomain: location recording only where the target lives. externalDomain/targetDomain is a target pointer for reads/neighbour-fetches — it is NOT the domain a write to the relation belongs to.

Trigger: a relation write or delete is routed by the relation's target domain (targetDomain/externalDomain, or the predicate's target tree base) instead of the source node's owning domain. Historically the Add/Edit-Information dialog's "Remove" button resolved its delete base from the predicate's target tree, so a product→location relation delete was proxied to the location backend.

Behaviour: the delete lands on a backend that does not own the relation (its overlay file lives in the source domain), so graph_crud_engine.delete_relation cannot find it and returns V-RELATION_NOT_FOUND → HTTP 404 "Relation not found (id=…)". The relation is perfectly valid and still visible — only the write was misdirected.

Correct routing: relation writes/deletes MUST route by the source node's domain base. In the frontend this is _aidSourceBase() (app.js) — DOMAIN_REGISTRY.domains[sourceDomainKey].baseURI || window._panelDomainBase || BASE_URL — the same source-domain resolution used for POST /documents attach and the detail-panel/workbench delete buttons. Never route a relation write by targetDomain/externalDomain.

Backend guard. When a relation delete carries a context_node_id (the source node) that is not a node in the backend's own effective graph, the request was mis-routed to a domain that does not own the source node. delete_relation returns the typed V-RELATION_WRONG_DOMAIN → HTTP 409 ("This relation belongs to a node in another domain — delete it from that node's own domain") instead of a misleading 404, so the failure is honest and actionable.


TargetRef — one type for every target shape (G4, #931)

Before G4 the backend addressed a cross-domain or deferred relation target through four parallel runtime-dict fields (external_ref, external_uuid, external_domain, externalTargetRef) scattered across ~27 files. There was no single owner — each consumer re-derived the dispatch rule and the four names leaked into docstrings, parameter lists, and overlay JSON-LD.

G4 collapses this into one typed object, infra.target_ref.TargetRef:

status Carries Meaning
"in-model" uuid Target is a local graph node — the usual case.
"external" uuid, domain, ref Target lives in another domain. uuid is the real remote UUID when known (eidos:node), else a derived placeholder stable in (source, cross-domain-ref, predicate) — re-derived on read, never written to disk (WP4b #965). domain is the owning backend's registry key; ref is the path (incl. sigil).
"unresolved" uuid="", ref Target is a local deferred path not yet bound to a node. uuid stays empty on disk (consumers test to for truthiness to detect "no target yet"); the relation's OWN uuid comes from its idempotency key (WP4 #953). ref is the local path.

Two canonical functions bridge runtime dicts and TargetRef:

  • read_target_ref(rel) -> TargetRef — dispatches on external_domain first (external), then unresolved_ref (unresolved), then falls back to in-model. Available for new call-sites and for the typed-reader migration (#958). Since WP8 (#967) no module outside target_ref.py keys on the raw constants: callers that need main-identical byte shapes use the narrow typed helpers next to it (external_ref_of, external_uuid_of, external_domain_of, legacy_relation_fields, external_ref_fields, set_ref_only, …). The companion constant-keyed ratchet alongside R3 is pinned at exactly 0.
  • apply_target_ref(rel, tref) — writes the TargetRef fields onto a relation dict, clearing any stale external keys first. Safe for re-assignment (e.g. heal operations).

The target_ref.py module also owns the key names for the runtime, API-facing and JSON-LD shapes (_EXT_REF_KEY, _EXT_TARGET_REF_KEY, _EXT_DOMAIN_CAMEL_KEY, _EIDOS_EXT_TARGET_REF_KEY, etc.). They are private: WP8 (#967) deleted the last public aliases (EXT_DOMAIN_KEY, EXT_REF_KEY, EXT_TARGET_REF_KEY) once tools/document_classifier/build_ga_graph.py moved onto the typed helpers. Any other module — backend or tools/ — addresses those fields only through read_target_ref / apply_target_ref or the helpers above; the raw string literals and the constants both live only here.

On disk (WP4b #965) a relation carries one eidos:target field: the node IRI (in-model), an eidos:ExternalRef node {domain, ref[, node]} (external), or an eidos:UnresolvedRef node {ref} (unresolved). The external placeholder is not written — excel_importer.resolve_relation_rows derives it as the row's tgtUuid via infra.key_derivation.derive_relation_uuid_for_unresolved_target(source_id, ref_key, relation_type) (a stable UUID v5 that retired the former uuid.uuid4() placeholder; CCR Rule 16), and read_target_ref_jsonld (backend/infra/target_ref.py:215) re-derives it on read, or returns the real remote UUID from eidos:node when the writer embedded one. For "unresolved" relations the target uuid stays empty. The relation's OWN uuid is derive_overlay_relation_uuid(idem_key) (backend/infra/key_derivation.py:89, WP4 #953) — derived from the CreateRelation idempotency key, not from endpoints — so the engine and the import router's bookkeeping id agree, and moving an endpoint does not change the relation's identity.

Placeholder upgrade on write (WP8 P4, #967)

The two placeholder shapes — an eidos:ExternalRef without eidos:node, and an eidos:UnresolvedRef — are upgraded to the real node identity on the next write of that relation, and only then. Loading or reading never rewrites a file (mutation_store.load_overlay and read_target_ref_jsonld stay read-only), and there is no bulk or background rewrite.

"The next write" is a graph_crud_engine.create_relation call that lands on a relation already on disk: the WP4 duplicate-triple branch (same source, predicate and cross-domain or deferred ref — the usual path, since a UI re-submit carries a fresh idempotency key) or the idempotency guard (same idempotency key). The upgrade runs only when the stored relation is the one the request describes: the same source, predicate and placeholder eidos:ref (and, for an ExternalRef, the same eidos:domain). A request that reuses an idempotency key with a different ref leaves the stored file byte-identical. If that call carries a real target_id, the engine asks the id→domain index (backend/domain/id_domain_index.py) which domain owns it. The routers inject the index as resolve_domain_fn, bound to the authenticated caller's user_id; the domain engine never imports it, because a lookup needs the server module and the caller's IAM context. The lookup runs under the domain write lock, so the routers inject resolve_id_domain_cached: it reads the current cache only and never builds or refreshes the index (no network call under the lock). A cold cache answers None, and a cache past its refresh interval is used as it is until the next ordinary lookup refreshes it. A target in a domain the caller cannot read gets the same None. Only the existing relation's own overlay file is rewritten, and only its eidos:target:

Stored placeholder Index answer Result
ExternalRef {domain, ref} the stored eidos:domain eidos:node = node_iri(target_id) is added (domain and ref kept)
UnresolvedRef {ref} this domain, and target_id is a node of the effective graph that is not tombstoned eidos:target becomes the in-model node IRI node_iri(target_id)
either unknown (None), another domain, a deleted node, or the index raises IdDomainConflictError / ExternalServiceError placeholder kept byte-identical; the write still succeeds

The relation's own id (derive_overlay_relation_uuid(idem_key), the file name and @id) never changes. A write after the upgrade is a no-op: the target already carries a real identity, so there is no index lookup, no file write and no audit event. A seeded relation (one with no overlay file) is never upgraded and no file is created for it. An unreadable relation file or an OSError on the write keeps the placeholder; any other error propagates. Each upgrade emits one committed UpgradeRelationTarget audit event (the heal-uuid precedent) carrying the relation id, source, predicate, the new targetId and the placeholder's domain and ref; the as-of reconstruction fold applies it, so an as-of view after the upgrade matches the live load and one before it still shows the placeholder. The upgrade invalidates the overlay cache through notify_raw_write. A dry run never upgrades: both the duplicate-triple and the idempotency-guard call pass the dry-run skip (the idempotency guard is also only reached after the dry-run return). The Excel re-import of an unchanged file never reaches the engine (the router skips rows whose ref relation already exists), and the Excel target id for a cross-domain row is the derived placeholder, which the index never knows. So in today's traffic the upgrade is driven by a UI re-submit with a real picked node id.

Stage Reference

Detect Cross Domain Ref

Engine: frontend-crud — Zone: frontend

identify cross-domain target via sigil prefix (+ location, ; signal, & discipline, % type, @ document) or explicit targetDomain / externalDomain field; unprefixed refs are primary-domain

Invariants enforced: domain-routing-key-invariant

Resolve Routing Key

Engine: domain-registry — Zone: api

map domain identity to baseURI (routing key) via DOMAIN_REGISTRY; NEVER derive apiFetch base from publicURI or new URL(target).origin — both collapse a secondary to the primary host

Invariants enforced: domain-routing-key-invariant

Proxy Fetch

Engine: frontend-crud — Zone: api

apiFetch to /api/proxy/{domain}/{path} using baseURI as routing key

Invariants enforced: domain-routing-key-invariant

Secondary Resolution

Engine: eidos-loader — Zone: domain

secondary backend resolves /node-by-ref or /node/{uid} against its own EffectiveGraph