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.
entity_links¶
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_typeequals the partial's. This becomes "is compatible with" if the registry ever declares subtypes (afundunderorganization). - 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
excludedlink 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.
ontology_versiononpartial_entities, #12, for the reshape query above. Recommended here, pending Nacho.- Generated
statuscolumns, #10: closed by the 2026-09-07 reduction; there is nostatuscolumn on these tables.