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.
Deep link versus identity¶
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) —
copyShareLinkbuilds the link from the node UUID viacanonicalNodeShareUrl(), never from the node@id, so a secondary node's link names neither a proxy URL nor the secondary host. The hash isEidosIRI.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.pybuilds the deep link viainfra.iri.node_deeplink(base, node_id), which owns the single canonical backend formula{base}/#{node_iri(node_id)}(#1000 slice 2) — byte-identical tocanonicalNodeShareUrlfor the same node and origin. - Doc360 report links —
reports/documentation_360.pybuilds every analysed node's link (primary and secondary alike) by calling the sameinfra.iri.node_deeplinkdirectly (documentation_360.py:587), on the primaryBASE_URL. The module still exportsbuild_node_url(:97), a thin re-export ofnode_deeplink, but only tests call it. - FME
webURI(consumed by GraphAnalyzer) —export_service.canonical_web_uris()stamps the canonical primary origin on everyhasDomainedge, so GA'swebURI + "/node/" + uuidyields the clean form. Since FME2.0.0(WP9 S4a) a node'sidis 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:¶
- the base stored on the cached node (
_eidos_domainBase, from an earlier load); - the loaded trees (
resolveDomainBaseForNode→nodeToTreeIndex→allTrees[idx].domainBase); - a domain key the caller already knows (
targetDomain); GET /resolve?id={iri}onBASE_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;- 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).