Skip to content

Canonical Deep Link

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

956 D1b-4 (2026-10-20): 1 finding(s) fixed — Doc360 links come from a direct infra.iri.node_deeplink call (documentation_360.py:587); build_node_url (:97) stays a thin re-export that only tests call. Re-verified against main 4996d1ce. #989 WP9 S3 (2026-10-22): resolveNodeBase sends GET /resolve the node IRI; an escaped IRI hash opens the node. Checked against frontend/app.js on the #989 branch (base 3c69a14f). #1127 #607 S3 (2026-10-25): an occurrence IRI hash opens its product (resolveNodeBase keeps the /resolve answer; restoreFromHash queues EidosCore.revealOccurrence) and the panel reveals the matching occurrenceId row. Checked against frontend/app.js and detail-panel.js on the #1127 branch (base 6774a2b6).

A node deep link — the URL you share from the app, receive by email, or follow from an report to open one specific node — is always domain-agnostic, and every producer emits one format (#1000): the node's identity IRI as the hash link, unescaped:

{app origin}/#https://id.ontoteq.com/{uuid}

The in-app share button, the node-share email and the Doc360 report links emit the same string for the same node. The FME webURI is not a share link but a base: GraphAnalyzer appends /node/{uuid} to it, which gives the location form {PRIMARY_ORIGIN}/node/{uuid}. A link sent before #1000 in the old hash form

{PRIMARY_ORIGIN}/#{urlencode({PRIMARY_ORIGIN}/node/{uuid})}

still opens. No form carries a domain or a transport. It is never the proxy form {origin}/api/proxy/{domain}/node/{uuid}. The owning domain is not part of a node's address — the consumer discovers it at open time.

The deep link is a location form, not the node's identity. The identity is the IRI https://id.ontoteq.com/{uuid} (WP1 #950), built by infra.iri.node_iri and, since

954 WP5 S7, sent as every node @id. That IRI is identity only and not dereferenceable:

nothing serves id.ontoteq.com. GET /resolve?id={iri|uuid} gives the location — it returns {iri, uuid, domain} — and a bare uuid is accepted as legacy input until WP9 (#989) retires it. Since #1000 every share link carries the IRI; the app opens it and resolves it through /resolve, and never fetches it. Links in the old {PRIMARY_ORIGIN}/node/{uuid} form keep opening.

Why one form

The format drifted into two shapes. The node-share email built the clean form from the node UUID, while the in-app copy-share-link copied the node's @id, and the FME webURI was the domain's publicURI. For a secondary-domain node both of the latter are the proxy form (because a secondary's publicURI is the /api/proxy/{domain} routing path), so the same node produced two different links — and a proxy-form link breaks the moment the baked-in domain is wrong. A node's identity must not encode how it was fetched (Contract 02.01: identity, not transport). The clean form was made canonical first, because the backend already resolved a bare UUID to its domain. #1000 then moved every share link to the identity IRI, so the in-app button, the email and the Doc360 report no longer differ either: one string per node.

Producers

  • In-app share button (identity IRI, #1000) — copyShareLink builds the link from the node UUID via canonicalNodeShareUrl(), never from the node @id, so a secondary node's link names neither a proxy URL nor the secondary host. The hash is EidosIRI.toIri(uuid) (frontend/eidos-iri.js) — https://id.ontoteq.com/{uuid} — unescaped, since a URI fragment may contain : and / (RFC 3986). With no node selected it shows "No node selected"; with no extractable uuid it shows "Couldn't build a link".
  • Node-share email — notifications.py builds the deep link via infra.iri.node_deeplink(base, node_id), which owns the single canonical backend formula {base}/#{node_iri(node_id)} (#1000 slice 2) — byte-identical to canonicalNodeShareUrl for the same node and origin.
  • Doc360 report links — reports/documentation_360.py builds every analysed node's link (primary and secondary alike) by calling the same infra.iri.node_deeplink directly (documentation_360.py:587), on the primary BASE_URL. The module still exports build_node_url (:97), a thin re-export of node_deeplink, but only tests call it.
  • FME webURI (consumed by GraphAnalyzer) — export_service.canonical_web_uris() stamps the canonical primary origin on every hasDomain edge, so GA's webURI + "/node/" + uuid yields the clean form. Since FME 2.0.0 (WP9 S4a) a node's id is its IRI, and the uuid is that IRI's local part.

The producers that route the HTTP form through the IRI façade (G3 #930) are: notifications.py and documentation_360.py via infra.iri.node_deeplink for the hash-wrapped share link; server.py and proxy_router.py via infra.iri.node_url for {base}/node/{id}. On the frontend, WP6 W1 (#955) added the matching façade frontend/eidos-iri.js (window.EidosIRI), and WP6 W2 (#955) moved every remaining app.js site onto it — pushed history entries and the cross-domain "used in" lookups build {base}/node/{id} via EidosIRI.nodeUrl (the share link moved to EidosIRI.toIri in #1000) and read a node URI's base via EidosIRI.nodeBaseOf — everything before /node/, so a proxy-form @id keeps its {origin}/api/proxy/{domain} base and a base with a path keeps its path (unlike EidosIRI.baseOf, which returns only the origin). The resolved bases are byte-identical to the pre-W2 form. The two façades are the single authorised emitters in backend and frontend code; raw string construction or a /node/ split is a bypass caught by the R2 ratchet (tests/test_rdf_g1_r2_ratchet.py, frontend baseline 0).

Consumer (resolves the domain)

The consumer finds the owning domain from the node's identity, not from the host of the link. resolveNodeBase(nodeId, {uri, targetDomain}) in frontend/app.js (WP6 W3,

955) tries, in order:

  1. the base stored on the cached node (_eidos_domainBase, from an earlier load);
  2. the loaded trees (resolveDomainBaseForNode → nodeToTreeIndex → allTrees[idx].domainBase);
  3. a domain key the caller already knows (targetDomain);
  4. GET /resolve?id={iri} on BASE_URL — the primary's id→domain index (#954 S2). The app sends the node IRI, not the bare uuid (WP9 S3, #989). A hit is cached per node until logout; a miss is not cached;
  5. the link's host — but only for an old {base}/node/{uuid} URL.

restoreFromHash, navigateToUri, executeNavEntry (after the recorded history base) and loadNodeDetail use it. navigateToAbsoluteUri still trusts the request URL first (BL-FE-135) and uses it only as the fallback. An IRI (or a legacy bare uuid) given to navigateToAbsoluteUri is no URL to fetch: it is opened by identity through navigateToUri, and apiFetchAbsolute fetches an IRI as /node/<uuid> from the node's resolved domain, so id.ontoteq.com is never requested (#955 W5). Before a navigation waits for /resolve, it claims a navigation generation. If a newer navigation starts during the wait, the older answer is dropped, so a slow /resolve never replaces a later click.

loadNodeDetail has one exception to this order. Its sourceUri argument overrides the stored base only on a retry (retried), where it is the owning domain that the non_local_node probe found. On a first load a sourceUri is only a hint, and the stored base still wins.

The enrichment and graph sites read a node's base from its @id: fetchNodeForGraph, fetchCrossDomainIncoming, enrichUsedInAsync, applyPropagation and the relation targets in _relUriBase. Since #954 WP5 S7 the backend sends every node @id, uri, target and origin that carries a uuid as the IRI. An old {base}/node/{uuid} @id (cached or legacy data) still keeps the base before /node/, as in W2. An IRI @id goes through resolveNodeBase, so a node that is stored or in a loaded tree never costs a /resolve call. A hash may also be the node's IRI https://id.ontoteq.com/{uuid}, escaped or not, or a legacy bare uuid. Links already sent stay readable for good (#989, owner decision Q4): #<uuid>, #{BASE}/node/<uuid> and the GraphAnalyzer form #<encoded {BASE}/node/<uuid>> keep opening after WP9 S6 too (tests-frontend/hash-deeplink-routing.test.js). The IRI is identity only: the app never fetches it, it resolves it through /resolve, and it never matches it against the registered hosts. This is the form the in-app share button copies and the email and Doc360 report emit (#1000); an old {BASE_URL}/node/{uuid} share link still opens through the /node/ branch of EidosIRI.idOf.

An occurrence IRI (#607, #1127) is a valid hash too. It names a product's occurrence of an inherited signal, not a node, so nothing stores or tree-indexes it. resolveNodeBase therefore always asks /resolve, which answers with the owning product: iri, uuid and domain name the product, and occurrenceId echoes the occurrence. The cache keeps that answer next to the base. restoreFromHash sees occurrenceId, queues the reveal (EidosCore.revealOccurrence(product, occurrenceIri)) and opens the product. The product's detail panel takes the reveal once (takeOccurrenceReveal). It opens Related information, scrolls to the row whose occurrenceId matches and flashes it, the same reveal the Documents panel uses. Related information is shown to admins only, so for other users the link just opens the product. A caller who cannot read the product gets the product's own plain 404 from /resolve, and the link opens nothing. Only primary-domain products resolve this way. The occurrence IRI, like every node IRI, is never fetched.

If nothing knows the node, loadNodeDetail asks the primary and follows the backend's structured non_local_node 404 to the owning secondary domain. A 403 shows the "Access restricted" screen. Its proxyRoutes holds the one domain the primary's id→domain index names (#954 S2). A node the caller cannot read, or one the index cannot place, gets a plain 404 instead. The clean link therefore resolves for any domain without the domain ever appearing in the URL.

Slug / Ref-Path Resolution (WP2 #951)

Find-by-ref callers (/node-by-ref via eidos_loader.get_node_by_ref, the admin rulebook _resolve_node_ref, the unresolved node metric, and the export service's _resolve_external_refs) route through infra.key_derivation.resolve_slug(full_path, ref_index) rather than calling resolve_ref directly. In WP2 the two are equivalent — the façade delegates to resolve_ref under the hood — but the single public entry point means future slices (stored-notation index, cross-domain slug rewrites) can replace the resolver without touching every call site. The per-node ref-path segment itself lives on the overlay node as skos:notation and is generated by infra.key_derivation.derive_slug_segment(label, is_root=...); full-path uniqueness within the domain is enforced inside the mutation engine's per-domain write lock.

Authority

EIDOS Explorer defines this contract. Adjacent systems — GraphAnalyzer, DocuGraph, any FME consumer — conform: they consume whatever canonical webURI EIDOS emits and must not reintroduce a domain- or transport-coupled link. See the Domain Routing Key invariant for the matching rule on the fetch side (route by domain identity, never the emitted host).