Vertex identity modes¶
GraFlo vertices declare how records are matched during upserts through fields on the logical Vertex model in schema.graph.vertex_config. Upsert identity modes and secondary_identities live on the vertex; physical indexes live under DatabaseProfile. Soft-uniqueness policy for secondary lookups (endpoints_on_ambiguous) lives on IngestionModel — the key is schema, the reaction to colliding matches is ingestion.
Four runtime modes¶
Each vertex resolves to one of four modes via the derived property Vertex.identity_mode. Modes describe how the upsert key is obtained (not string encoding). They are mutually exclusive.
A flat hash_identity_properties list and an identity_funnel both resolve to hash: they share one write path and differ only in how the digest sources are chosen. Use Vertex.has_identity_funnel to tell them apart.
identity_mode |
Authored signal | identity |
Key behavior |
|---|---|---|---|
natural |
default | [f] or [f1, f2, …] |
Upsert on declared fields. If a field is typed UUID, validate shape when present — do not invent. |
hash |
non-empty hash_identity_properties or an identity_funnel |
["id"] |
SHA256 of the digest sources → synthetic id at assemble (before edge projection); writer is an idempotent safety net |
assigned |
assigned: true |
["id"] |
Intentional UUID PK: empty → uuid4() at assemble (before edge projection); writer is an idempotent safety net. Not blank-edge resolution. |
blank |
blank: true |
["id"] |
Placeholder: random UUID at write time; listed in blank_vertices; does blank-edge resolution |
Unary and composite natural keys are the same runtime mode. The upsert path passes Vertex.identity to the database as match_keys; width does not change the write branch.
Blank vs assigned¶
Both may mint a random UUID when the synthetic id is empty, but they are not interchangeable:
blank |
assigned |
|
|---|---|---|
| Meaning | No business identity / placeholder | Intentional UUID primary key |
| Mint timing | Writer (_assign_blank_vertex_ids) |
Assemble (before assemble_edges); writer net is idempotent |
| Blank-edge resolution | Yes (_resolve_blank_edges) |
No |
| Typical use | Mentions, ephemeral join stubs | Events / entities whose PK is a UUID |
Schema fields¶
identity¶
Logical field name(s) used for upsert matching. For hash, blank, and assigned modes the normalizer sets identity to ["id"] (GraFlo canonical synthetic key; ArangoDB maps to _key at write time).
hash_identity_properties¶
Source field names whose values are hashed (SHA256, full hex digest) to produce the synthetic id. Only the listed fields are included — transient properties never enter the hash.
Example:
- name: product
properties: [org, product_code, name, category]
identity: [id]
hash_identity_properties: [org, product_code, region]
A document whose hash sources are all empty gets no identity at all, rather than a digest of {field: null} — otherwise every such document would share one key and merge into a single vertex.
identity_funnel¶
Ordered fallback branches. The first branch whose fields are all present and non-empty wins, and its values are digested into id. hash_identity_properties is the single-branch case; the two are mutually exclusive.
Use it when different sources identify the same entity by different keys — the classic multi-source ingestion problem, where one system has email, another phone + country, and a third only a weak name/date pair.
- name: party
properties: [id, email, phone, country, name, dob]
identity: [id]
identity_funnel:
digest: sha256
include_branch_id: true
branches:
- id: email
when_all_present: [email]
fields: [email]
- id: phone
when_all_present: [phone, country]
fields: [phone, country]
- id: weak
when_all_present: [name, dob]
fields: [name, dob]
| Field | Meaning |
|---|---|
digest |
Digest codec. sha256 only — uuid5 needs a namespace policy and is not implemented. |
include_branch_id |
Default true. Puts the winning branch id in the digest payload, so two branches over equal values cannot collide. Set false only to reproduce a flat-hash digest exactly. |
branches[].id |
Branch name, unique within the funnel. Part of the digest when include_branch_id. |
branches[].fields |
Fields digested when this branch wins. |
branches[].when_all_present |
Fields that must be present for the branch to fire. Defaults to fields. Must be a subset of them — a condition on a field the branch does not digest cannot affect the key. |
No branch fires → no identity. The document keeps an empty id and is dropped by drop_empty_identity_docs (on by default). GraFlo does not invent a key, because a random one would create a duplicate vertex on every re-ingest.
Changing a funnel rekeys the graph. Branch order, branch ids and field sets all feed the digest, so reordering two branches produces different keys for the same data. The differ reports this as REKEY_VERTEX at CRITICAL risk.
A funnel can also be proposed rather than authored — see cross-resource identity discovery, which derives one from sampled documents when several resources key the same entity differently.
Authoring it as an evolution op:
op: replace_identity
vertices:
party:
to:
mode: funnel
funnel:
branches:
- { id: email, fields: [email] }
- { id: phone, when_all_present: [phone, country], fields: [phone, country] }
blank¶
Placeholder vertices with no stable natural key; each record gets a random UUID at ingest time and may participate in blank-edge expansion.
assigned¶
Intentional UUID primary key. Empty identity is filled with uuid4() so cast-time edge projections see the key. Present valid UUIDs are preserved; invalid non-empty values raise.
- name: event
properties:
- { name: id, type: UUID }
- { name: payload, type: STRING }
identity: [id]
assigned: true
Natural key that happens to be a UUID (no new mode):
- name: user
properties:
- { name: external_id, type: UUID }
- { name: email, type: STRING }
identity: [external_id]
Secondary identities (edge endpoint lookup)¶
The four modes above answer how a vertex is upserted. secondary_identities answers a different question: how an edge finds a vertex that already exists.
An edge-only source frequently references its endpoints by a business key — an ISIN, an LEI, a source-local code — that is not the vertex's primary identity, and often carries no primary key at all. Declaring that field-set lets an edge step match on it while upserts continue to use identity.
- name: instrument
properties: [sid, isin, org, local_code]
identity: [sid]
secondary_identities:
- name: by_isin
fields: [isin]
- [org, local_code] # bare list; auto-named secondary_1
Selection is per endpoint, so source and target choose independently:
resources:
- name: links
pipeline:
- vertex: instrument
lookup_only: true # matched, never written
- vertex: issuer
lookup_only: true
- from: instrument
to: issuer
relation: issued_by
source_match: by_isin # name, field list, or "secondary"
target_match: identity # explicit primary (also the default)
source_match / target_match accept a declared name, an explicit field list equal to a declared set, or secondary when exactly one is declared. Omitted (or identity) means the primary identity, so existing edge steps are unaffected. An unknown selector fails at manifest load, listing what is declared.
lookup_only¶
A resource that references a vertex without owning it marks its vertex steps lookup_only: true. Those observations take part in edge rendering but are never upserted. Without it, rows carrying only a secondary key would be written as vertices with no primary key.
As a safety net the writer refuses to upsert any document carrying no identity value at all, since no backend can store one meaningfully.
Soft uniqueness and ambiguity¶
Secondary identities are softly unique: the declared index is non-unique, so the database never rejects duplicates. When a lookup matches several vertices, ingestion_model.endpoints_on_ambiguous decides:
| Policy | Behaviour |
|---|---|
all (default) |
Attach the edge to every match; never discards data |
first |
Attach to one match, chosen deterministically by primary identity |
skip |
Write no edge for that row, and count it |
error |
Raise, aborting the batch |
An edge step overrides the model default with on_ambiguous. A row whose key matches nothing, or whose composite key is incomplete, produces no edge and is counted — a partial key is never partially matched.
How it runs¶
Endpoints are resolved to their primary identity immediately before the edge write, via Connection.resolve_vertices. The edge write itself therefore stays an ordinary primary-key operation, which is why this works identically on backends that address endpoints by key (PostgreSQL foreign keys, NebulaGraph VIDs, TigerGraph PRIMARY_ID) and on those that match by property (Cypher, AQL).
Two consequences worth knowing:
- Endpoint vertices must already exist. Resolution reads the database, so the resource that owns a vertex has to be ingested before the edge-only resource that references it. Unmatched endpoints are counted and logged rather than raised.
- Each declared secondary identity gets a non-unique index automatically (see backend indexes); on NebulaGraph that index is required for the lookup to run at all.
Not supported in this form: hash- or funnel-derived secondary identities, and upserting a vertex by its secondary identity. Both are reserved.
Runnable walkthrough: Example 16 (examples/16-secondary-identities/) — instruments and issuers upserted on primary keys, then an edge-only CSV linked by ISIN / LEI. For funnels, see Example 17 (examples/17-identity-funnel/) — two sources keying the same people by email and by phone + country.
Distinct from the SQL connector's type_lookup
SqlConnector's type_lookup also has source_identity / target_identity fields. Those name join columns in a lookup table and are unrelated to schema identities.
Inference vs runtime¶
IdentityInferencer discovers keys from record samples. Its strategy is separate from runtime mode:
Inference strategy |
Runtime identity_mode |
|---|---|
unary |
natural |
composite |
natural |
hash_fallback |
hash |
no_viable_identity |
(vertex unchanged) |
API¶
IdentityInferenceConfig—min_sample_size(default 100), optionalmax_sample_size, bootstrap and scoring weightsIdentityInferencer.infer(samples)— returnsIdentityInferenceResultapply_identity_inference_to_vertices()— apply inference to a vertex list (immutable)infer_identities_from_snapshot()— infer from aGraFloOutputYAML snapshot
See Example 15 for a CSV → manifest → ingest walkthrough.
Where configuration lives¶
| Layer | Holds | Does not hold |
|---|---|---|
Vertex |
Upsert identity / mode fields; secondary_identities (lookup field-sets) |
Write-time ambiguity policy |
DatabaseProfile |
Physical indexes and storage names (auto-indexes from secondary identities land here) | Logical identity semantics |
IngestionModel |
Pipeline steps (lookup_only, source_match / target_match); endpoints_on_ambiguous |
Vertex key definitions |
VertexConfig.hash_identity_vertices, VertexConfig.blank_vertices, VertexConfig.assigned_vertices, and VertexConfig.vertices_by_identity_mode() are derived lists for runtime introspection and db_writer / assemble branching.