ADR 0055: Additional node labels per entity type¶
Date: 2026-10-26. Status: Accepted on 2026-10-26, under the owner's decision D3 (entity-level interop after the other should rows), which needs this feature and shared relationship types (GR-62). GR-40.
Context¶
GraphBridge maps each node entity type to one label (ToGraphNode(label)), and that label is the type's identity: every pattern that reads, writes or deletes the type's nodes names it.
Other tools mark nodes with labels of their own:
- neo4j-graphrag 1.22.0 writes every extracted entity as (:__Entity__:__KGBuilder__:Person {...}): the type's label, plus two labels that every entity carries.
- Search labels and tenancy labels follow the same pattern: one label shared by many types.
GraphBridge can't write those labels, so a graph it builds isn't one neo4j-graphrag's entity retrievers recognise (ADR 0052, "What isn't in the box").
Decision¶
1. Mapping¶
modelBuilder.Entity<Person>().ToGraphNode("Person").HasAdditionalGraphLabels("__Entity__", "__KGBuilder__");
HasAdditionalGraphLabels(params string[])records one or more labels in the annotationGraphBridge:AdditionalNodeLabels, separated by colons.- The type's own label stays its identity: its uniqueness constraints and indexes, its schema manifest entry, and its upserts'
MERGEare on that label. - Refused when configured: an invalid identifier, a repeated label, an empty list.
- Refused when the model is built:
- additional labels on a relationship mapping;
- an additional label that is any type's own label, the type's included, because a match of that type would then also find these nodes;
- repeated labels or an invalid one set directly through the annotation.
- Shared: several types may carry the same additional label, which is the point:
__Entity__on every entity type.
2. Written¶
SaveChangescreates a node with every label:CREATE (n:`Person`:`__Entity__`:`__KGBuilder__`).ExecuteUpsertAsyncmerges on the type's own label and key, then sets the additional labels:MERGE (n:`Person` {...}) ... SET n:`__Entity__`:`__KGBuilder__`. A node with the key but without the labels is the type's node (the constraint is on its own label), so an upsert gives it the labels.
3. Matched¶
Every pattern that matches the type's nodes requires all its labels:
- LINQ queries and counts;
- the endpoints of association, identified-edge and node-reference relationships, and their includes;
- keyed updates, deletes and locks from SaveChanges;
- ExecuteUpdate and ExecuteDelete;
- the nodes of Fluent Cypher patterns, and the two ends of a bounded path.
A node with the type's label but without all its additional labels is outside the type:
- Queries skip it. A query or count of the type, an association or an identified relationship never returns it.
- Reads that reach it through a native reference or an Include fail. Skipping it there would hide a required principal or a child, so the read fails closed with InvalidOperationException, as for any malformed reference.
- Keyed writes don't touch it. A tracked entity whose node lost a label fails to save with DbUpdateConcurrencyException, as when the node is gone.
- It still holds its key. The key constraint is on the type's own label, so adding an entity with the same key fails on the constraint. Only an upsert by key takes the node back, and adds the labels.
Not covered:
- graph search (GraphSearchRequest), which returns the index's nodes and scores, not entities;
- the path API's GraphPathPattern, whose start label is given by the caller;
- the intermediate nodes of a bounded path, which carry no label constraint, as before this ADR.
Reserved labels. GraphBridge's migration journal uses labels that start with __GraphBridge. Neither a type's own label nor an additional label may start with it, since that type would read and constrain the journal's records (added 26 October 2026, after review).
4. Migrations and Doctor¶
- The schema manifest records
additionalLabelsfor a type that has them, and only then. Manifests and fingerprints of models without them don't change. A model that adds or removes additional labels changes the fingerprint, as any storage change does. - No constraint or index is created for an additional label; schema comparison is unchanged.
- Doctor (connected) counts, read only, the nodes with a type's label but without all its additional labels. When every node is labelled, it reads every node of each such label, within a 60-second transaction timeout; the count stops at
maxSchemaRows. Like every Doctor report, the warning names no label: GBD-LABEL-001(warning): some exist, and GraphBridge leaves them out;GBD-LABEL-000: none.
Doctor changes nothing.
Consequences¶
- GraphRAG can write neo4j-graphrag's entity labels, the first half of entity-level interop (D3); shared relationship types (GR-62) are the second.
- Existing models are unaffected: no annotation, the same Cypher, the same manifests.
- A graph whose nodes lack labels a model adds must be labelled before the model sees them, by upserting by key or by a reviewed data step.
- Database-first scaffolding and the mapping generator don't propose or emit additional labels yet; a scaffolded model gets them by hand.
Not adopted¶
- Matching on the type's own label only, writing the extras as decoration. Simpler, but a node the model says is a
Personwith__Entity__could then lack it, and other tools that select by__Entity__would miss nodes GraphBridge reads. - A migration step that labels existing nodes. The data steps' locking contract (a dependent
SETon a property) has no label counterpart proven yet; until it is, labelling existing nodes is an upsert by key or a reviewed native statement, and Doctor reports what is left. On the future list. - Inheritance through labels (a
Studentthat is also aPerson). Inheritance stays unsupported; an additional label never names a type.
Verification¶
- Offline:
- binding order;
- rendering of reads, creates, deletes and upserts;
- every refusal;
- a label shared by two types;
- the manifest with and without the labels.
- Live:
- created nodes carry every label;
- LINQ reads, counts, associations and
ExecuteUpdateskip a node without them; - an upsert by key labels a bare node, and it is then read;
- a tracked node that lost a label fails to save and is unchanged;
- Doctor warns while a bare node exists and leaves it bare.