Data model.

Five canonical entity types, a knowledge base over documents, a CTI graph over relationships, and a provenance layer under all of it.

The clusters

CANONICAL ENTITIESindicatorsvulnerabilitiesactorsmalwareattack_patternsMITREKNOWLEDGE BASEkb_sourceskb_documentskb_chunks+ embeddingCTI GRAPHgraph_nodesgraph_edgesgraph_communitiesPROVENANCEevidenceper sourceingestion_runssourcesdashed = referenced by · every entity resolves to evidence rows, and every evidence row to the run that fetched it67 tables in total; the 14 tenant-scoped ones carry FORCE ROW LEVEL SECURITY
Fig. Canonical entities, the knowledge base, the CTI graph, and the provenance tables under them.

67 tables in one Postgres. They fall into four groups: canonical entities, the knowledge base, the graph, and provenance. Fourteen of the 67 are tenant-scoped and carry FORCE ROW LEVEL SECURITY.

Canonical entities

One row per real-world thing, regardless of how many feeds mentioned it. The verdict on a row is derived from the evidence beneath it, not written directly by whichever connector arrived last.

TableHoldsKeyed on
indicatorsIPs, domains, URLs, file hashes, emails, mutexesnormalised value + type
vulnerabilitiesCVEs, with CVSS, EPSS and KEV statuscve_id
actorsThreat actors and their aliasescanonical_name
malwareFamilies and toolingname
attack_patternsMITRE ATT&CK techniquestechnique id

Knowledge base

Documents are what the Co-Analyst actually cites. A document is fetched from a source, retained, split into chunks, and each chunk is embedded so it is reachable by semantic search.

  • kb_sources — the registry of where documents come from, with a reliability grade.
  • kb_documents — the retained document: title, source URL, fetch time, confidence, quarantine flag.
  • kb_chunks — passage-level splits plus their pgvector embedding. This is the retrieval unit.
  • kb_tags / kb_doc_tags — the tag axes (actor, malware, technique, sector, region) used for faceting.

A document carries tenant_id when it belongs to one tenant, and NULL when it is part of the shared global corpus. The retrieval path reads both, so a tenant sees the global corpus plus its own — never another tenant's.

CTI graph

The graph is what makes a pivot cheap. Rather than joining five entity tables at query time, relationships are materialised as edges.

  • graph_nodes — one node per entity that participates in a relationship.
  • graph_edges — typed, directed relationships between nodes.
  • graph_communities — clusters, used to answer “what else moves with this?”

See CTI graph for how a traversal is actually executed.

Provenance

The part that makes the rest defensible. Every claim about an entity is its own row, and every row points at the run that produced it.

TableAnswers
evidenceWhich source said this, when, with what confidence and TLP marking
sourcesWhat feeds exist, their tier and whether they are enabled
ingestion_runsEvery fetch attempt: when it started, its status, how many records it wrote
Freshness is derived, never stored

Nothing keeps a “last updated” column that a failed run could leave lying. Freshness anywhere in the product — the status page, the source catalogue, the dashboard — is computed from the most recent ingestion_runs row for that source.

How tenancy is expressed in the schema

tenant_id, and what NULL means

Tables that hold tenant-owned records carry a tenant_id: knowledge-base documents, submitted indicators, advisory deliveries, usage rows. Row-level security compares that column against the session's tenant, so a query cannot reach another tenant's rows even if the application asks it to.

A NULL tenant_id is not a missing value — it marks the shared corpus, the reference intelligence every tenant reads. That is why a document with no tenant is visible to all of them and a document with one is visible to exactly that tenant.

Data model — OmniIntel docs · OmniIntel