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
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.
| Table | Holds | Keyed on |
|---|---|---|
indicators | IPs, domains, URLs, file hashes, emails, mutexes | normalised value + type |
vulnerabilities | CVEs, with CVSS, EPSS and KEV status | cve_id |
actors | Threat actors and their aliases | canonical_name |
malware | Families and tooling | name |
attack_patterns | MITRE ATT&CK techniques | technique 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.
| Table | Answers |
|---|---|
evidence | Which source said this, when, with what confidence and TLP marking |
sources | What feeds exist, their tier and whether they are enabled |
ingestion_runs | Every fetch attempt: when it started, its status, how many records it wrote |
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
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.