5.4 Entity canonicalization — the spines a join anchors on
Every example above anchors on a canonical Person or Organization. Those are spines: the
single node a real human or company resolves to within one visibility scope, no matter how many
sources mention them. A world-visible spine is keyed deterministically — Person by
(worldId, WORLD, Person, email), Organization by (worldId, WORLD, Organization, registrable domain). A context-private
spine substitutes its contextId for WORLD, so two records
from different sources (a HubSpot contact and a GitHub commit author, both rod@embabel.com)
converge on one Person, and “my contacts’ companies” lands on the same Organization the
email graph already built.
Source records canonicalize onto a spine via projection metadata on the type (not a separate
pipeline). A property tagged hub: <Spine> is the value that keys the spine; a property tagged
relationship/target/matchBy links the record to a second spine:
- name: HubSpotContact properties: email: { identity: true, hub: Person } # → Person, by email company: { metadata: { relationship: WORKS_FOR, target: Organization, matchBy: name } }hub: is the opt-in, and it is explicit. identity: true alone says what identifies the
record; it never implies a spine, because most identities (an issue number, an invoice id) are
nobody’s canonical person. A type with no hub: is not canonicalized.
hub: may sit on any property, not only the identity one. The two are often different facts. A
CRM partner is identified by the CRM’s own id, and every join inside that realm keys on it; what
identifies the company is its website:
- name: OdooCustomer properties: id: { identity: true } # the record, for this realm's own joins website: { hub: CustomerAccount } # the company, for everybody'sSeveral properties may carry the same hub: (a website and a billing email, say); each non-empty
value is normalized by the spine and contributes a key. A type attaches to ONE spine this way — the
first hub: names it.
The relationship’s merge key is the spine’s key, not the field text — company’s value is
only the display name; the Organization is keyed by the contact’s email domain. So two contacts
on @acme.com share one Organization however they spelled “Acme”, and a freemail/no-domain contact
yields no Organization (a name alone never invents a spine — fuzzy name resolution is a
separate tier, never in the query path; it runs on the eager/offline population path, not on the
on-demand hop, because a per-entity model call over thousands of fetched records would be one LLM
call each and defeat the point of a deterministic hot path). The result is a durable
(p:Person)-[:WORKS_FOR]->(o:Organization) edge onto the current visibility-scope spine — read the contact’s company
through its canonical Person, no source-specific company walk.
This runs on demand, and persists ONLY the spine — never the source record. When a query
materializes virtual records of a canonical-bearing type, a pre-pass resolves each record onto its
canonical Person/Organization, writes the durable derived edges (HAS_CONTACT, WORKS_FOR),
enriches the canonical fields straight from the fetched record, and stamps the source on the
canonical’s sources provenance set — then the main query reads the deduped graph, so
(p:Person {primaryEmail})-[:WORKS_FOR]->(o:Organization {name:'Acme'}) works on first ask. The
source record itself stays virtual: no :HubSpotContact mirror node is persisted, because its
mutable CRM state (lead status, last activity, owner) would only go stale — those fields are
re-fetched live each query. Identity and durable relationships are durable; source state is not.
Resolution is O(log n), through indexed key-nodes. Each spine has a key-node — EmailAddress
(email-address:<addr>) for Person, Domain (dom:<domain>) for Organization — with a uniqueness
constraint on (worldId, visibilityScopeId, id). Resolving a key is one indexed hop
((:Domain {id})-[:USED_BY_ORG]->(o:Organization)), never a scan, and the key-nodes are shared
with the email/sender graph inside that visibility scope, so a canonicalized record and an emailed
person dedupe to one spine. The
spine’s own (worldId, visibilityScopeId, id) is uniqueness-constrained too, so the deterministic
MERGE can never fork a duplicate or merge contexts/customers. Context-private evidence cannot mutate
a WORLD spine without explicit policy-checked promotion. Organization-wide spines require an
explicit orgId, verified membership, and an organization-scoped key; a bare email or domain is
never cross-context or cross-world authority.
The two built-in spines are not hardwired — they are config. A spine is declared by a
CanonicalSpec (label, key property, id prefix, normalization primitives, key-node + edge); Person
and Organization are just the two built-ins. An operator may add a Place, Repository, or
Product spine in the host’s configuration, and source types join it with hub: <newLabel> — the
engine builds the hub, applies the key-node uniqueness constraint, and the join surface above works
unchanged. A realm may declare one too, which is the next section.
5.4.1 Spines a realm declares — spine: on a type
Section titled “5.4.1 Spines a realm declares — spine: on a type”Person and Organization cover people and the companies in your correspondence. They do not cover a customer account, a product, a site, a vessel — the business entities that several systems each hold a record of, and that a cross-system realm exists to bring together. A realm says so by declaring the type a spine:
- name: CustomerAccount description: A company the business sells to, whatever system knows about it. spine: key: accountKey # the key property on the spine node identityProperties: [accountKey, website] # fields, on anything attaching, that carry the key normalize: [lowercase, extractDomain, stripTrailingDot] # ordered require: hasDot # a value that fails this keys nothing exclude: freemail # nor does a value in this set properties: accountKey: "The company's registrable domain."| Field | Meaning | Default |
|---|---|---|
key |
The scalar key property on the spine node. The multi-valued form is <key>s. |
required |
identityProperties |
Property names that may carry the key, on the spine and on records attaching to it. key and <key>s are always included. |
[] |
normalize |
Ordered primitives: lowercase, trim, extractDomain, stripWww, stripScheme, stripTrailingDot. |
[trim] |
require |
containsAt or hasDot. A value failing it keys nothing. |
none |
exclude |
A named exclusion set. freemail is the one defined. |
none |
Everything else is derived from the label and is not the realm’s to choose — the key-node is
<Label>Key, linked (:<Label>Key)-[:IDENTIFIES]->(:<Label>), with the same uniqueness constraints
and the same one-hop indexed resolution as the built-ins. A realm that could pick its own key-node
label could collide with another realm’s.
The normalization is the contract. It is the single definition of “the same account”, applied to
every value from every realm that opts in. https://www.AcmeCorp.com/about from a CRM,
acmecorp.com from a helpdesk and Billing@acmecorp.com from a billing system are one key,
acmecorp.com, and therefore one node. No realm normalizes on its own side, and no realm needs to
know how another spells things.
Other realms opt in with hub:, exactly as for a built-in (above). The spine’s realm does not
know who attaches, and an attaching realm names only the label.
A join anchored on the spine normalizes BOTH sides. The anchor’s key goes out normalized:
CustomerAccount → ChatwootConversation on accountKey asks the helpdesk for acmecorp.com
whatever shape the account’s key arrived in. And where the join’s keyField is one of the spine’s
identity properties, each RECORD’s recordKeyField is read through the spine too before it is
matched to an anchor. So a realm joins on the source’s own field, in the source’s own spelling:
- { anchorLabel: CustomerAccount, relationship: BILLED_AS, keyField: accountKey, recordKeyField: url, producer: lagoCustomersByDomain } # url is "https://www.Stark.com/"This is what makes a SEARCH safe to join on. A source often cannot be asked for a key exactly —
only with a substring match that also returns notstark.com for stark.com. Do NOT echoKeyAs
on such a producer: stamping the asked key onto whatever came back makes every stray a match.
Let the record’s own field be the key; the customer whose url IS that account is linked and the
stray is not. A value the spine refuses (a freemail address, a bare word) matches nothing.
resolve: chains (§5.2) treat a realm spine as they treat Person: canonicalDomain /
canonicalEmail normalize through it.
A spine-anchored join can ride a book sweep — ridesHub: true. An edge anchored on the spine
is answered by asking the source about each account, so it is refused above maxAnchors (200)
— and that caps every rule set absolutely, because a rule body cannot fetch and a requires:
demand written as (a:CustomerAccount)-[:HAS_CASE]->(…) is itself account-anchored. Declaring
ridesHub: true on the join says: in a query that has already swept this record type’s BOOK —
MATCH (d:ChatwootDesk {status:'all'})-[:HAS_CASE]->(c:SupportCase) MATCH (a:CustomerAccount)-[:HAS_CASE]->(c)
— link each account to the swept records by the spine’s own key (the record’s hub: property,
normalized as the spine defines: a URL becomes the account’s domain), with no fetch at all. The
cap does not apply to a ridden edge, so the book’s size no longer matters.
- { anchorLabel: CustomerAccount, relationship: HAS_CASE, keyField: accountKey, recordKeyField: accountKeyAsked, producer: chatwootConversationsByAccount, ridesHub: true }What the author asserts by declaring it: that the sweep is COMPLETE for the edge. {status:'open'}
sweeps only open cases; an edge that means “this account’s cases” ridden on it would silently
under-report, which is exactly the fault the opt-in exists to forbid. Sweep all, or do not opt in.
Three rules for the demand that rides: sweep first and name the account edge to the SAME alias;
PROJECT what is swept (RETURN c.id — a demand that only asks whether something exists is fetched
as one page, and one that counts is answered by a count and no records); and bind a per-customer
hop through its own sweep (MATCH (b)-[:HAS_CUSTOMER]->(lc) MATCH (b)-[:HAS_INVOICE]->(i) MATCH (lc)-[:HAS_INVOICE]->(i),
the hop declaring materializedKeyField: customerExternalId) so it links rather than mints.
Measured on realm-account-health at 2,030 accounts: the three rule-backed views went from “none”
to answering in a few seconds, six book sweeps and no per-account call.
An account exists once something has keyed it. A spine node is created when a query
materializes records of a type that opts in — canonicalization is on demand, and only the spine
persists; the source record stays virtual. Until some realm’s records have been read, the spine is
empty, and a view that starts MATCH (a:CustomerAccount) answers nothing, truthfully. A realm
whose views start at a spine should ship the small view that walks its door (MATCH (:OdooBook) -[:HAS_COMPANY]->(:OdooCustomer)) and say in its README that a surface reads it first. Opt in
from EVERY system that can name the entity, not only the one that seems authoritative: a company
support is helping and nobody bills is exactly the account an account-level view must not miss.
Scope is the world. A realm’s spine exists in the worlds that installed the realm. Its nodes carry the world, and the world is part of their id, so two worlds in one store never share an account. Removing the realm removes the spine from resolution; nodes already written remain, as any persisted node does.
What a host MUST refuse, at load, as a loading problem — and register no spine:
- A label the host owns (
Person,Organization, or any operator-configured spine). An installed realm must not be able to change what every other realm’shub: Organizationkeys on. - Two realms declaring one label differently — both. Which loaded last must never decide what identity means. The identical declaration restated by a second realm is one spine, not a conflict; that is how a realm restates a vocabulary it cannot yet declare a dependency on.
- An unknown
normalize,requireorexcludename, listing the known ones. Skipping a misspelledlowercasewould keyAcme.comandacme.comas two companies, everywhere, silently. - A spine label in another type’s
parents:, pointing the author athub:. See §5.4.2.
A host SHOULD also report a hub: that names no spine in the world — a misspelling, or a vocabulary
realm that was never installed. Nothing downstream fails: the type is simply never canonicalized and
every join expecting the spine returns nothing.
5.4.2 An identity is a spine; a record is a parent label
Section titled “5.4.2 An identity is a spine; a record is a parent label”A shared vocabulary has two tools and they are not interchangeable.
parents:(LABELS_AND_COMPOSITION.md) stamps the ancestor’s label on your nodes. Right for records of a kind: everyChatwootConversationand everyZendeskTicketis aSupportCase, andMATCH (c:SupportCase)should return all of them, as separate things, because they are separate things.spine:+hub:resolves your records onto ONE shared node. Right for an identity: anOdooCustomerand aLagoCustomerfor the same company are two records about one account.
The test: if two systems each hold one, are those two things or one? Two → parent label.
One → spine. Getting it wrong in the first direction is the expensive mistake: parents: [CustomerAccount] on three realms’ customer types yields three :CustomerAccount nodes per company
with nothing joining them, and “accounts with an open ticket and an overdue invoice” is empty for
every account — no error, because no single node has both.
5.4.3 Joining across realms — which mechanism
Section titled “5.4.3 Joining across realms — which mechanism”Every cross-realm join is one of these. Choose by what the two sides actually share:
| The two realms share… | Use | Notes |
|---|---|---|
| A person (email) or a corresponded-with company (domain) | built-in spine: hub: Person / hub: Organization |
nothing to declare |
| Some other real-world entity, held under differently shaped keys | a realm-declared spine (§5.4.1) | the spine’s normalize is the agreement |
| The same exact key, already identical in both (a purl, an ISO code, a CIK) | a plain virtual join on keyField / recordKeyField |
declare joinKey: on both so a mismatch is caught |
| A key one side must look up (a login → an email) | a resolve: chain (§5.2) |
learned handles persist |
| A key that exists only sometimes, with a fallback | a join policy: ladder (§5.15) |
ask is not yet honoured; see there |
| No key — only meaning | a vector edge (§6) or a generative producer (§5.3) | a score, not an identity |
| A conclusion over both | a DERIVE rule (§13) | after the join exists, not instead of it |
Two things that are NOT a join mechanism, however tempting: seeding the same key by hand into both realms (it works until the second customer), and a per-producer rewrite that normalizes one side to match the other’s spelling (it encodes realm B’s format inside realm A).
5.4.4 canonicalize() — the spine as a function
Section titled “5.4.4 canonicalize() — the spine as a function”A spine’s normalize is the single definition of “the same entity”, and hub: applies it to a
property. When a query holds a raw value that is not on a hub: property — an invoice’s customer
URL, a sender’s address, a parameter — it must not normalize the value itself:
split(i.customerUrl, '//')[1] strips the scheme and nothing else, so https://www.Acme.com/ joins
nothing while the same company’s customer record, keyed through hub:, joins fine. No error; the
invoices are simply missing. Ask the spine instead:
MATCH (b:LagoBooks {scope:'all'})-[:HAS_INVOICE]->(i:LagoInvoice)MATCH (a:CustomerAccount {accountKey: canonicalize(i.customerUrl, 'CustomerAccount')})RETURN a.name, sum(i.amountDueCents) / 100.0 AS owed| Call | Returns |
|---|---|
canonicalize(value, 'Spine') |
the spine’s key for a raw value — 'Priya <priya@Northwind-Traders.example>' → 'northwind-traders.example', 'https://www.Acme.com/' → 'acme.com' — or null |
canonicalize(n) |
for a node whose type marks a property hub: <Spine>, the key from the first such property that has a value; for a node of the spine type itself, its own key |
canonicalization(value, 'Spine') |
{key, outcome, rule} — outcome is ok, excluded (the spine’s exclude: refused it, e.g. a freemail address) or invalid (it fails require:, e.g. no dot); rule names the spine |
It is the same normalisation hub: applies, not a second copy — a query and a hub: property can
never disagree about a value.
null means “not an identity for this spine” and nothing else. When the reason matters — telling
a declined personal address from a malformed one — ask canonicalization.
It reads nothing and claims nothing. No lookup, no model: canonicalize says what the key would
be, not that an entity with that key exists. Existence is the MATCH that uses it.
Refused before anything is fetched, as an invalid query: a spine name that is not a string
literal; an unknown spine; a type that is not a spine; the one-argument form on a type with no
hub:; a value that is not a string, a parameter, or a property of a matched node. In the
property form the node’s label must be written in the statement (MATCH (i:LagoInvoice)), and a
list-valued property reads null.
Spines are per world. A realm-declared spine (§5.4.1) is visible to queries in the world it is installed in, and unknown elsewhere.

