Skip to content

Entities, partial entities, and entity links

From the graph data model: the two node tables, the edge table, and how reads stay fast while history accumulates.

Status Settled with Nacho 2026-09-03; supersession reduced to the two transaction pointers and the excluded link kind added 2026-09-07 (Nacho): the negative judgement a correction needs so matching cannot redo a merge a person undid. How a link records the claims that justified it is deferred to matching (#21); the link carries no claim column (driving_claim_id removed 2026-09-08, Nacho). ontology_version pending (Open questions)
Created 2026-09-03
Updated 2026-09-08
Context domain.md: concepts Entity, Partial entity, EntityLink; invariants 1, 3, 4, 6, 8. architecture.md: Changed-source reconciliation. D1, D2. Board a8d17080-29dd-47c2-98d7-b1fcaee9c0ab, section "TWO EDGE TYPES IN ENTITY COMPOSITION"

Summary

Three tables. entities is a node with a type, a status, and nothing else: its identity is a view over its links (D1). partial_entities is what one extraction, one import, or one person says about one entity; it keeps its id across event revisions while its claims are reconciled (claims). entity_links is the edge, one row per judgement about a partial and an entity, with its own kind (member, evidence, or excluded) and two transaction pointers (D2). The common reads use partial indexes over live rows only, so the append-only history never slows them.

erDiagram
  entities ||--o{ entity_links : "target of"
  partial_entities ||--o{ entity_links : "source of"
  partial_entities ||--|{ claims : "carries"

  entities {
    uuid id PK "caller-derived"
    text entity_type "registry-validated, immutable"
    text status "canonical | unresolved; the mutable column"
    uuid created_by_tx_id FK
    uuid status_tx_id FK "the transaction that last set status"
  }
  partial_entities {
    uuid id PK "caller-derived, stable across event revisions"
    uuid event_id "nullable, reference only"
    uuid stage_run_id "nullable, reference only; the creating run"
    int ordinal "nullable, position within that run"
    text extractor_version "nullable"
    text ontology_version "nullable, pending"
    text entity_type "registry-validated"
    text content_hash "detects same-id different-content replays"
    uuid created_by_tx_id FK
    uuid superseded_by_tx_id FK "nullable"
  }
  entity_links {
    uuid id PK "caller-derived"
    uuid entity_id FK "composite with organization_id"
    uuid partial_entity_id FK "composite with organization_id"
    text kind "member | evidence | excluded"
    uuid created_by_tx_id FK "the transaction that made it true"
    uuid superseded_by_tx_id FK "nullable; the transaction that made it false"
  }
  claims {
    uuid id PK
    uuid partial_entity_id FK
    text claim_type
  }

organization_id and created_at come from OrgScopedBase on every table and are omitted from the drawing.

entities

Column Meaning
id Caller-derived. An unresolved entity minted by a no-match takes its id from the partial that created it
entity_type A key from registry.entity_types(). Immutable for the row's life
status canonical or unresolved. The one mutable column in the graph besides the normalization cache; written in place by the transaction that promotes or demotes it, which stamps status_tx_id. Its history is the sequence of transactions that have stamped it, recoverable because each names the entity in its own rows (transactions)
created_by_tx_id, status_tx_id The transaction that created the entity and the transaction that last set its status (transactions). Equal until the first promotion. Entities have no superseded_by_tx_id: an entity is never retired, it stops mattering when it has no live member link (invariant 3)

Constraints: UNIQUE (organization_id, id), the parent side of the composite foreign keys below; a check on status. No check on entity_type: the registry owns that vocabulary, so adding asset for a real-estate ontology is a registry bump with no DDL.

Why no type-specific columns. An entity is a view over its member partials, and everything specific to a person or an organization is a claim, already generic. A table per type would produce identical three-column tables and force entity_links.entity_id into a polymorphic target with no real foreign key. The behavior that differs by type, which claim types apply and at which tier, lives in the registry's ClaimBinding (board, "PROPOSED ONTOLOGY"). The admission test for a new type is the board's: it joins the graph only if the registry binds at least one hard claim type to it, because otherwise nothing about it is resolvable. A real-estate asset passes (cadastral reference, address); a deal does not, which is why the board keeps deals bootstrap-only.

partial_entities

Column Meaning
id Caller-derived. Extracted: uuid5 over (organization_id, stage run idempotency key, ordinal). Bootstrap and manual: derived by the importer or the API from its own stable inputs
event_id Lineage to the event, reference only, nullable. Null for bootstrap and manual partials
stage_run_id, ordinal The run that created the partial and its position in that run's output. Nullable for the same reason
extractor_version, ontology_version The versions the partial was extracted and typed under. ontology_version is what makes a future ontology reshape a query instead of archaeology (Open questions)
entity_type Registry key; the partial's kind in domain.md (D22), mapped per the summary doc's conventions. A member link requires the entity's type to match
content_hash Hash of the partial's raw content as first written, so a replay with the same id and different content is caught as an error
created_by_tx_id, superseded_by_tx_id The two transaction pointers (transactions). The second is null while the partial is live; only a reconcile transaction fills it

Stable across event revisions. When a changed source forces re-extraction (architecture.md, Changed-source reconciliation), new output is aligned with the event's active partials by identifying hard claims. An aligned partial keeps its id and its claim set is reconciled claim by claim (claims). A prior partial with no aligned counterpart is superseded here by the reconcile transaction, and its links and live claims are superseded under the same transaction through the applier. A new partial with no aligned prior is stored under a new id. So a partial's stage_run_id is the run that first created it, not the last run that confirmed it.

Constraints: UNIQUE (organization_id, id); a partial unique index on (organization_id, stage_run_id, ordinal) WHERE stage_run_id IS NOT NULL, so extracted partials are unique per run position while bootstrap and manual ones are not constrained by it; composite foreign keys from the two transaction pointers to transactions.

Columns: id, entity_id, partial_entity_id, kind, created_by_tx_id, superseded_by_tx_id.

A link is a judgement about one partial and one entity, and kind is the verdict:

kind Says Written by Read by
member this partial is this entity matching on an identifying hit; promotion; a person search and the entity view (invariant 3); at most one live per partial (invariant 4)
evidence this partial may be this entity matching on a non-identifying hit promotion; the review queue
excluded this partial is not this entity a person correcting a merge; the judge ruling a candidate unrelated matching's candidate search drops the entity; the applier refuses a member or evidence link where one is live

Why a negative kind. Retiring a member link records that the placement stopped being true; it does not record the opposite. Matching reads the live graph, not history, so a week after a person split two partials it would see the same claims and merge them again. excluded is where the knowledge "B is not E1" lives, and it holds until a person retires it (added 2026-09-07, after a review of the judgement-log pattern in Reltio and nomenklatura, which keeps same | unsure | different verdicts; our three kinds are those verdicts against an entity id, so membership is a filter and needs no resolver). The exclusion is per partial, not per claim: a new partial with the same name and employer is judged on its own claims and, with two entities now sharing them, routes as ambiguity to evidence links and the review queue. Entity-to-entity negatives are not expressible here and are not needed while B3 proposes partial-to-entity items only; if entity merge is ever defined, a fold moves live exclusions along with member links so the negative survives the entity changing id.

created_by_tx_id is the transaction that made the link true and superseded_by_tx_id the one that made it false (transactions; the second is null while the link is live). Every link comes from a logged transaction (D12), so the reference holds whatever matching turns out to be, and the transaction's actor_id names the matching run whose stored output is the full resolution item: every claim that matched, the scores, the algorithm version (transactions).

No claim column. This document as settled on 2026-09-03 followed invariant 6 literally: one driving_claim_id per link. Nacho's expectation for the matching algorithm (2026-09-04) is a score over all of a partial's claims, under which a single driving claim misdescribes the decision, and the column was removed on 2026-09-08. How a link records its contributing claims, one claim, a link_claims junction, or an array, is decided with matching at milestone 5, when links first exist (#21). Until then the transaction's actor_id names the matching run whose stored output holds every claim that matched, and invariant 6's wording waits with it.

Rules the functions layer enforces on link(), beyond what the constraints below express:

  • The entity's entity_type equals the partial's. This becomes "is compatible with" if the registry ever declares subtypes (a fund under organization).
  • A member link carries created_by_tx_id, always. Whether and how it also records the claims that justified it is #21; when it does, those claims belong to the linked partial (invariant 6).
  • No evidence link is created on a partial that already holds a live member link.
  • No member or evidence link is created where the partial holds a live excluded link to the same entity; the store refuses and names the excluding transaction. Matching filters the same rows earlier, in candidate search (a B3/B4 note for interfaces.md at ratification); the store rule is what makes the guarantee hold whatever matching does.

Why a table and not a column. A partial has at most one active member, so a nullable entity_id on partial_entities would express membership. Four things do not fit a column, and the decision to keep the table was taken by Nacho on 2026-09-03:

The model must express Column on the partial entity_links row
Evidence links to several candidate entities at once (architecture, Concepts; matching route S2) No: one column, one entity One evidence row per candidate
A wrong merge corrected without deleting, and kept corrected (invariant 1, R3) No: repointing overwrites where the partial sat, and nothing says "not here" Fill the row's second pointer, insert an excluded row; the old stays queryable and matching cannot redo it
Every placement records what drove it (invariant 6) Only for the current placement created_by_tx_id per row, and the contributing claims per row once #21 is decided, so wrong merges are queryable
Promotion from evidence to member in one transaction Nothing to promote Supersede evidence, insert member, supersede sibling evidence, one savepoint

The cost is one indexed join on the most common read, which the indexes below make an index-only lookup.

Indexes and query shapes

The hot reads touch live rows only. Postgres partial indexes index only those rows, so the indexes stay the size of the live graph while the tables keep every superseded row. Two of the indexes are also the constraints of invariant 4.

-- invariant 4, and the lookup for "which entity is this partial in"
CREATE UNIQUE INDEX uq_entity_links_live_member
  ON entity_links (organization_id, partial_entity_id)
  INCLUDE (entity_id, created_by_tx_id)
  WHERE kind = 'member' AND superseded_by_tx_id IS NULL;

-- one live evidence row per (partial, candidate entity)
CREATE UNIQUE INDEX uq_entity_links_live_evidence
  ON entity_links (organization_id, partial_entity_id, entity_id)
  WHERE kind = 'evidence' AND superseded_by_tx_id IS NULL;

-- one live exclusion per (partial, entity); matching's candidate filter and the applier's refusal read it
CREATE UNIQUE INDEX uq_entity_links_live_excluded
  ON entity_links (organization_id, partial_entity_id, entity_id)
  WHERE kind = 'excluded' AND superseded_by_tx_id IS NULL;

-- everything live on an entity: the entity view, and search's last hop
CREATE INDEX ix_entity_links_live_by_entity
  ON entity_links (organization_id, entity_id, kind)
  INCLUDE (partial_entity_id, created_by_tx_id)
  WHERE superseded_by_tx_id IS NULL;

-- audit: what did this transaction make true, what did it make false
CREATE INDEX ix_entity_links_created_by
  ON entity_links (organization_id, created_by_tx_id);
CREATE INDEX ix_entity_links_superseded_by
  ON entity_links (organization_id, superseded_by_tx_id)
  WHERE superseded_by_tx_id IS NOT NULL;
-- the by-claim audit index follows the shape chosen in #21
-- Which entity is partial P in. Index-only on uq_entity_links_live_member.
SELECT entity_id, created_by_tx_id
FROM entity_links
WHERE organization_id = $org AND partial_entity_id = $p
  AND kind = 'member' AND superseded_by_tx_id IS NULL;

-- Entity view. One range scan on ix_entity_links_live_by_entity.
SELECT partial_entity_id, kind, created_by_tx_id
FROM entity_links
WHERE organization_id = $org AND entity_id = $e AND superseded_by_tx_id IS NULL
  AND kind <> 'excluded';

-- Candidates matching may not propose for partial P. Index-only on uq_entity_links_live_excluded.
SELECT entity_id
FROM entity_links
WHERE organization_id = $org AND partial_entity_id = $p
  AND kind = 'excluded' AND superseded_by_tx_id IS NULL;

The planner uses a partial index only when the query repeats its predicate, so the functions layer writes superseded_by_tx_id IS NULL explicitly, in one place. Views live_entity_links, live_partial_entities, and live_claims carry the predicate for anyone querying by hand, so a raw query reads like a plain current-state table. Forgetting the predicate resurrects retired rows silently, which is the one bug this model invites; the functions layer reads through the views, and a static test rejects raw SQL on the base tables without the predicate.

A superseded row leaves the partial indexes when its second pointer is filled and is never indexed by them again; the table and the audit indexes keep it. Supersession updates one row once, so autovacuum reclaims the single dead tuple. Unique indexes are checked per statement, so replacing a member link supersedes the old row before inserting the new one; the write transaction sequences it.

Ontology change

Additive change is free: a new entity type or claim type is a registry version bump with no DDL and no touched row. Reshaping (splitting organization into company and fund, renaming a type) is a data question: which rows were typed under the old key. The ontology_version stamp makes that a query. The reshape itself is a re-extraction under the new version, new partials superseding old ones through the reconciliation path, and the entity's type following its members through a logged re-typing transaction. That is applier-phase work and needs no schema change.

Two consequences for adjacent questions. A country as a government actor is an organization entity; a country as nationality or jurisdiction is a claim value, as domain.md already treats nationality and location. The test is whether a product needs to resolve mentions of the thing into one identity with its own claims and links; the same word can be a claim in one ontology and an entity type in another (a place, in real estate), and the tables do not care. If a second product needs a different ontology rather than a richer one, the registry can carry per-organization profiles of enabled types while the tables stay indifferent.

Open questions

Filed as GitHub issues labeled open-question.

  1. ontology_version on partial_entities, #12, for the reshape query above. Recommended here, pending Nacho.
  2. Generated status columns, #10: closed by the 2026-09-07 reduction; there is no status column on these tables.