ADR-0149: Person node identity is person_key, not name (name-collision de-bridge)
Date: 2026-07-27 Status: Accepted Deciders: Adrian (Soft4U), Claude (Opus 4.8)
Decision context:
- Latency: negligible — the MERGE key changes from
nametoperson_key; both are single-property indexed lookups. A newPerson(name)secondary index keeps the name-based candidate query fast. - Dependency surface: no new packages. Changes
upsert_person, the Person constraint, one newgraph_etl._person_keyhelper, and the four Person relationship writers. - Debuggability:
name/display_nameremain queryable properties;person_keyis human-legible (nm:<name>|co:<reg>orid:<k>:<v>), so a node's identity basis is visible. - Reversibility: the constraint migration runs idempotently at startup (
DROP CONSTRAINT … IF EXISTS+ createperson_key). Reverting means restoring the name constraint + a re-ETL. Existing collapsed nodes are cleaned by re-running the ETL (per-investigation MERGE). - Blast radius: the four Person writers (directors/UBOs/arrangement-parties/class-members) + the constraint. The ~20 structural reader queries are unaffected — they traverse relationships and read
p.nameas output, and de-bridge automatically once the node stops collapsing. - Alternative considered: keep name-keyed nodes + filter collisions at read time (rejected — the collapsed node structurally bridges companies in every traversal; a read filter cannot un-merge it).
Context
The Neo4j knowledge graph MERGEd a Person node on {name, tenant_id} alone. Two
different people who share a common director name therefore collapsed into ONE node,
which then bridged their unrelated companies via HAS_DIRECTOR. On the OB Holding 1 OÜ
MLRO review the Network graph showed OB connected — 3–4 hops away, through
director → company → director → company chains — to clearly-unrelated companies
(ESET, Marimekko, NortonLifeLock, Digitec Galaxus, Nature et Découvertes). A direct
OB → person → {those} query returns empty: they are not genuine shared directors, they
are name-collisions (issue #482, the ADR-0073 R9 class: never resolve two entities on a
person name alone without a corroborating identifier).
A blast-radius analysis established: (a) ~11 of 13 registry sources carry no per-person
identifier (and the ETL discarded identifiers even where present), so an identifier-only
key is not viable — the key must fall back to company scope; (b) create_relationship's
to_key MATCHes and fans out to ALL name-matches, so keeping a name to_key would
re-create the very bridge being removed; (c) the over-broad MotifInstance targeting an
unrelated company (issue Task 2) is a downstream symptom — its entities were
contaminated by the collapsed node, so it auto-corrects once the node stops collapsing.
Decision
Person node identity becomes person_key:
- A unique per-person identifier (national/personal code) keys the node → the SAME real person merges across companies (verified; a date-of-birth alone is NOT unique and never keys a merge).
- Absent an identifier — the common case — the key is company-scoped
(
nm:<normalized_name>|co:<reg>), so two different people sharing a name never collapse into one bridging node.name/display_namestay queryable properties, so a name-based CANDIDATE lookup (find_co_directorships) still surfaces a possible shared director — as an unverified candidate (ADR-0078 two-lane), not a hard structural bridge.
graph_etl._person_key(name, company_reg, person) computes the key; the four Person
relationship writers pass it to both upsert_person(person_key=…) and
create_relationship(to_key={"person_key": …}). The Person uniqueness constraint moves
from (name, tenant_id) to (person_key, tenant_id) (the legacy constraint is dropped
idempotently at startup); a plain Person(name) index is added for candidate lookups.
Consequences
Positive
- The multi-hop
HAS_DIRECTORfalse-network members disappear (structural de-bridge). - Tasks 1/2/3 of the issue are addressed by one change: name-only persons no longer bridge (1); the motif contamination auto-clears (2); a company-scoped node connects exactly one company, so the "person connecting >N companies" smell is structurally impossible (3).
- ADR-0073 R9 / ADR-0078 two-lane discipline is now enforced in the graph model itself.
Negative
- A genuine shared director with NO consistent identifier is no longer a hard graph edge
across companies — it becomes a name-based candidate (via
find_co_directorships). This is R9-correct (a name-only match was never a verified resolution), but it moves that signal from the structural view to the candidate view. Threading identifiers through the two sources that carry them (FR INPI, NorthData) is the upgrade path. - Existing collapsed Person nodes from prior runs are not auto-split; a re-ETL of a case creates the new company-scoped nodes (the target workflow re-runs per investigation).
Neutral
find_co_directorshipsremains name-based by design (it IS the candidate query); gating the contagion it drives on identifier corroboration is a tracked follow-up (the OB contagion count was already correct, so it is not part of this fix).
Alternatives Considered
Alternative 1: Read-time collision filtering (keep name-keyed nodes)
- Why rejected: the collapsed node structurally bridges companies in EVERY traversal; a read-time filter cannot un-merge it, and every consumer would have to re-implement the filter.
Alternative 2: Identifier-only key
- Why rejected: ~11 of 13 sources carry no identifier, so almost every person would be unkeyable; the key must fall back to company scope.