Module interfaces¶
The contracts the five modules build against, so each can be built and tested before any other module exists, whoever builds it. This document is the authority until the shared pydantic package ships; from then on the package is the contract and this document records ownership and change rules.
Created 2026-09-01. Updated 2026-09-03 (v1 relation attribute schemas moved here from the
relationships proposal, no contract change, D19/D28; PartialEntity kind + groundedness,
D22-D23; previously ownership vs staffing, D15).
Module map: architecture.md.
Full field lists and flow diagrams: the Excalidraw board
(a8d17080-29dd-47c2-98d7-b1fcaee9c0ab).
Rules¶
- Contract-first. A module is built against the contracts here, never against another module's implementation. Test fixtures are generated from the contract models, so a consumer runs its tests before its producer exists.
- Change by agreement, recorded here. A contract changes only with the sign-off of both its producer and consumer owners, as an edit to this document (and, once it exists, a version bump of the shared package). No silent widening.
- Breaking-change gate. Once the pydantic package exists, CI diffs the published models on every PR and blocks unagreed breaking changes.
- Everything is org-scoped. Every payload and every query carries
organization_id(architecture invariant 8); it is omitted from the shapes below for brevity only.
Ownership¶
| Boundary | Producer | Consumer | Owner (producer side) |
|---|---|---|---|
B1 Event |
1 Ingestion | 2 Extraction | ____ |
B2 [PartialEntity] |
2 Extraction | 3 Matching | ____ |
B3 entity_resolution_list |
3 Matching | 4 Store/applier | ____ |
| B4 search API | 4 Store/applier | 3 Matching | ____ |
| B5 bootstrap import | 5 Bootstrap | 4 Store/applier | ____ |
| Shared claim-type registry | joint (2, 3, 4) | all | ____ |
Owners are filled in here as they are assigned. Five modules do not mean five owners: v1 is built by two engineers, so modules 1 and 2 are assigned at milestone 1 and modules 3, 4, and 5 stay unassigned until milestone 5 starts (D15). An unassigned producer does not weaken rule 1: its contract is fixed here, and a consumer tests against fixtures generated from it.
Contracts¶
B1: Event (Ingestion to Extraction)¶
Event {
id, organization_id, type, occurred_at,
sources: [Source { id, external_id, external_system, content_hash, content, ingested_at }],
event_summary: EventSummary { text, summarizer_version },
source_summaries: [SourceSummary { source_id, text, summarizer_version }],
}
Sources are immutable; summaries are derived rows keyed by id plus summarizer_version.
B2: PartialEntity (Extraction to Matching)¶
PartialEntity {
id, event_id, extractor_version,
kind, # v0: person | organization (D22); more kinds = registry bump
claims: [Claim {
id, type, snippet,
value?, normalized_value?, # value claims (email, alias, ...)
target_partial_id?, # relation claims: the object partial, same event
attributes?, # relation claims: per-type registry schema
}],
}
Every Claim.type must exist in the registry. A partial entity carries at least one hard
claim (invariant 2); the extractor enforces it, matching may assume it. A relation claim
carries target_partial_id referencing a partial of the same extraction output, plus
attributes validated against its type's registry schema; it carries no
value/normalized_value and nothing of it enters the hard-claim index
(relationships.md, D16/D19).
An identifying claim is additionally grounded: its value must appear literally in the event's source text, modulo the claim type's canonicalization, or the extractor output is rejected (D23, PRD E6). Non-identifying and soft claims are exempt.
Change record: 2026-09-01, Claim gained snippet, optional target_partial_id and
attributes (relations); agreed in team review of
relationships.md. 2026-09-03, PartialEntity gained kind (v0:
person | organization) and the groundedness rule for identifying claims; agreed in
team review of v0-review.md (D22, D23).
B3: entity_resolution_list (Matching to Store/applier)¶
entity_resolution_list {
organization_id,
partial_entities: [PartialEntity],
resolutions: [ResolutionItem],
}
ResolutionItem =
| matched { partial_entity_id, target_entity_id, driving_claim_id }
| evidence { partial_entity_id, target_entity_ids: [..], driving_claim_id }
| unresolved { partial_entity_id }
Applied sequentially per org (invariant 7): the applier is a FIFO consumer, matching may run in parallel.
B4: Search API (Store/applier exposes, Matching consumes)¶
search(claim_type, value, org_id)
-> hits [{ entity_id, entity_status: canonical | unresolved, matched_claim_id }]
| no_match
| error
no_match is a positive assertion and is promotion-legal; error never counts
(invariant 5). The index behind it is a projection of active member links only
(invariant 3).
B5: Bootstrap import (Bootstrap to Store/applier)¶
Bootstrap writes canonical entities through the same applier mutations as resolution, never
through direct table writes, so provenance and invariants hold for imported data too.
Shape: a stream of partial entities from an authoritative system plus a directive to create
canonical entities with member links (driving_claim_id pointing at the authoritative
identifying claim).
Shared: the claim-type registry¶
A versioned pydantic package, one copy, imported by modules 2, 3, and 4. Each hard claim type declares:
- identifying class:
identifying | non_identifying, keyed by (claim type, entity kind) where it differs (D22):domainis identifying for an organization, non-identifying for a person. Entity kinds v0:person | organization; adding a kind is a registry version bump - temporality:
constant | temporal, and for temporal, cardinalityexclusive | accumulative - normalization function
- search strategy (exact, normalized-exact, trigram)
Relation types (v1: employment, investment, partnership, governance, bounded
other; see relationships.md, D18) additionally declare:
endpoint_kinds: (subject_kind, object_kind)directional: bool(symmetric types store subject/object as extracted, normalized at query time)attributes_schema(nullable fields; temporal relation types carryvalid_from/valid_to, D19). The v1 schemas, authoritative here:
employment { role?, valid_from?, valid_to? }
investment { amount?, currency?, round?, date? }
governance { role?, valid_from?, valid_to? }
partnership { valid_from?, valid_to? }
other { label } # stored, never queried by label (D18)
valid_from/valid_to are filled only when the source states them; when absent, the
event's occurred_at (reported_at) is the only anchor (D19).
Adding a claim type is a registry version bump, not a schema migration in any module.