Storage
Postgres as the system of record, the split hot path, canonicalisation at ingest, and how the schema is owned across two languages.
Proposed design
This page describes decisions taken during design. It is not yet implemented — treat it as the intended shape of the system rather than a description of running infrastructure.
Postgres as the system of record
CTI graphs are wide and shallow. A realistic query walks one to three hops — indicator → malware → actor → campaign — not deep pathfinding across a sprawling network. The scaling pain in a threat intel feed is ingest volume, deduplication, and lookup latency; it is not traversal depth.
A dedicated graph database solves a problem Hoard CTI does not have yet, so Postgres is the primary store.
| Need | Approach |
|---|---|
| STIX 2.1 SDOs | Typed tables, or one table with a type discriminator plus JSONB |
| STIX 2.1 SROs | A relationship(source_ref, target_ref, relationship_type, confidence, first_seen, last_seen) join table — this table is the graph |
| Raw feed payloads, enrichment output | JSONB with GIN indexes, so a new field in a feed doesn't mean a schema migration |
| Traversal | Recursive CTEs, which are fine at these depths |
| Fuzzy domain and string matching | pg_trgm |
| Sightings retention | Time-partitioned tables — drop a partition instead of running DELETE |
Split the hot path early
Two workloads do not belong in the same tables as relational data, and separating them later is far more painful than separating them at the start.
Sightings and observations
"IOC X was seen in feed Y at time T." Append-only, high volume, and only ever read in aggregate.
"Is this IOC known?" lookups
A single hot question asked at high frequency, answered from a cache rather than from the relational store.
Sightings live in time-partitioned Postgres, which holds up comfortably to a few hundred million rows. Beyond that the answer is ClickHouse — compression on IOC data is excellent, and the access pattern is exactly what a columnar store is built for.
Lookups are served from Redis/Valkey, keyed by the normalised indicator value and holding the current verdict. Postgres should never be answering this question at query volume.
Canonicalisation is the highest-value decision
Normalise at ingest, never at query time. That means handling defanged IPs, punycode domains, hash casing, and URL trailing slashes once, on the way in.
The normalised value is then protected by a database-level guarantee:
UNIQUE (type, canonical_value)Why this one matters most
Dedup enforced by the database cannot silently stop working. Dedup enforced by convention across several codebases eventually does — and nobody notices for months.
Schema ownership
The core constraint: two languages, one database, exactly one source of truth.
- Drizzle owns the schema. A TypeScript schema definition goes through
drizzle-kit generateto produce plain SQL migrations, whichdrizzle-kit migrateapplies. - Go never runs migrations. The aggregators are pure readers and writers of a schema that already exists.
- Go tooling derives from the migrated database. A schema change that breaks a Go query fails codegen in CI, rather than failing at 3am in the middle of an ingest run.
Watch items
Casing. Set Drizzle's casing option explicitly — camelCase in TypeScript, snake_case in the database — and keep it consistent. Otherwise Go codegen targets column names Drizzle never created.
Types Drizzle doesn't model natively. inet, cidr, tsvector, partitioned tables, and custom domains belong in hand-written migrations via drizzle-kit generate --custom, rather than being forced through the TypeScript schema builder. For IOC storage specifically: inet for IP addresses, and bytea or fixed-width text for hashes.
Don't consume Drizzle's meta/ snapshot JSON. It is an internal format with no stability guarantee and it reshapes on minor version bumps. The stable cross-language contract is SQL DDL, exported with drizzle-kit export.
Go database tooling
Driver: pgx v5
Use the native pgx interface rather than database/sql. That buys:
CopyFrom, using the COPY protocol — roughly an order of magnitude faster than row-by-row upsertsSendBatchpipelining- Native JSONB, array, and
inethandling LISTEN/NOTIFY
The bulk ingest pattern is CopyFrom into a staging table, followed by a single INSERT ... SELECT ... ON CONFLICT DO UPDATE.
Query layer: go-jet
go-jet is a type-safe SQL builder with code generation and automatic result mapping — explicitly not an ORM. Queries read like SQL because they are: SELECT(...).FROM(...).WHERE(...), with dot-imports giving it a native feel.
It covers the full surface needed here: DISTINCT, GROUP BY, HAVING, window functions, sub-queries, CTEs via WITH, and INSERT ... ON_CONFLICT ... RETURNING.
Being database-first is a feature rather than a limitation: Drizzle keeps owning the schema, and jet derives from it. The generator runs as a pre-build step against the migrated database.
One pool, two access paths
jet executes through database/sql, so it does not expose pgx's native CopyFrom. There is no need to choose between them — pgx v5's stdlib.OpenDBFromPool(pool) wraps a *pgxpool.Pool as a *sql.DB.
One connection pool. jet for typed relational queries, native pgx for bulk ingest.
Alternatives considered
| Tool | Verdict |
|---|---|
| sqlc | Strong option — write SQL, get typed Go. A viable substitute for jet. |
| ent | The best philosophical match for Drizzle's schema system, and its edge model fits CTI well. Rejected because it insists on owning the schema. |
| Bun | Covers both halves superficially, but is reflection-based with much weaker type safety. A downgrade coming from Drizzle. |
| GORM | No. Reflection-heavy, opaque, and a poor bulk-insert story. |
| sqlboiler | Database-first like jet, but weaker builder ergonomics. |
Deferred decisions
When to add a graph database
The trigger is a concrete, user-facing query that cannot be served — for example, "every entity within four hops of this actor, ranked by path confidence."
At that point the first thing to try is Apache AGE, a graph extension that runs inside Postgres, before taking on Neo4j or Memgraph as separate infrastructure.
Full-text search over reports
Postgres FTS goes a long way. OpenSearch is worth reaching for only when relevance ranking becomes a genuine product concern rather than a nice-to-have.
Summary
| Layer | Choice |
|---|---|
| Primary store | Postgres |
| Sightings | Time-partitioned Postgres → ClickHouse at volume |
| IOC lookup cache | Redis / Valkey |
| Raw archive | Cloudflare R2, content-hash keyed |
| Graph database | Deferred; Apache AGE first if ever needed |
| Schema source of truth | Drizzle (TypeScript) |
| Go driver | pgx v5, native interface |
| Go query layer | go-jet, generated from the migrated database |
Next: Ingest covers how data reaches this store, and Scrapers & Collectors covers where it comes from.