Core Data Model: Hub–Link–Satellite on PostgreSQL¤
Context and Problem Statement¤
db4's core design constraint (see CLAUDE.md) is to optimize for many disparate data sources, not
many users: a bank export, a smartwatch API, a lab PDF, a beancount ledger, and whatever shows up next
all need to plug into the same core without redesigning it each time. Per-domain normalized SQL tables
(the shape explored on experiment/add-accounting) work fine in isolation, but there's no shared way to
cross-link rows of different types (e.g. a Transaction to a Person) without a bespoke join table per
pair of types, and no built-in notion of history. What core data shape lets every new domain plug in as
"add a table or two," while keeping arbitrary cross-linking, full history, and user-added tags/metadata
as things core already provides rather than things every plugin re-invents?
Decision Drivers¤
- Many disparate, unanticipated future data sources; only a handful of users — extensibility over scale.
- Arbitrary entity ↔ entity cross-linking (transaction ↔ person ↔ task ↔ artifact) must not require a new join table for every new pair of types.
- History is first-class: "time of fact" vs. "time recorded" (bitemporal), queryable "as of" any point.
- Users can add their own tags and free-form metadata from the UI(s), without a schema migration.
- Low operational burden appropriate for a single-Postgres-instance, few-user system.
- Should not foreclose local/cloud AI (embeddings, semantic search) integrating "seamlessly" later.
Considered Options¤
- Hub–Link–Satellite (Data Vault–inspired) on PostgreSQL
- Per-domain normalized tables with ad hoc foreign keys (status quo)
- Generic EAV / triple-store inside PostgreSQL
- Dedicated bitemporal/Datalog engine as the primary store (XTDB / Datomic family)
- Event Sourcing + CQRS with a separate event store
Decision Outcome¤
Chosen option: "Hub–Link–Satellite on PostgreSQL 18", because it solves cross-linking structurally (one
real link table, backed by a shared entity-identity spine, instead of a join table per type pair),
gives bitemporal history without a second storage engine (insert-only, valid-time-ranged satellites), and
lets every new data source ship as a plugin owning its own hub + satellite tables — directly serving the
"many sources, few users" constraint at an operations cost appropriate for a single Postgres instance.
Architecture Sketch¤
core.entity(entity_id uuid PK, entity_type text, created_at timestamptz)— the shared identity spine. Every domain hub table's primary key is also a FK to this table, so anything, regardless of domain, can be referenced generically.- Domain hub tables, one per business concept, owned by the plugin that defines it — e.g.
people.hub_person,accounting.hub_account,accounting.hub_transaction. A new domain registers a new hub; core is untouched. core.link(link_id, left_entity_id, predicate, right_entity_id, valid_range tstzrange, load_time, source_plugin)— one generic table for arbitrary entity ↔ entity relations, with a GiST exclusion constraint on(left_entity_id, predicate, right_entity_id, valid_range)so overlapping-in-time duplicate links are rejected at the database level. This is the "cross-linking should be easy" answer.- Domain satellite tables hold descriptive/measured facts for a hub, keyed by
(entity_id, valid_range), insert-only — neverUPDATE, only append a new row and let the previous one's range close. This is where bitemporal history lives:valid_rangeis domain time,load_timeis transaction time. - User-added tags and metadata reuse the same shapes, so they need zero schema changes:
core.hub_tag(entity_id PK, slug UNIQUE, label, created_by)— a tag is just another hub; any UI can create one.- Tagging anything = a
core.linkrow withpredicate = 'tagged_as'. core.sat_metadata(entity_id, key, value jsonb, valid_range, load_time)— free-form key/value attached to any entity, from any UI, versioned like everything else.core.hub_artifact(entity_id PK, uri, content_hash, mime_type, size_bytes, added_at)—uriis an fsspec/universal_pathlib-resolvable URI (local path,s3://…, etc.); artifacts attach to any entity viacore.link, keeping unstructured content out of SQL while still linkable.- Extensions, all
CREATE EXTENSION, no extra services:timescaledb(Apache-2 edition) as hypertables for genuinely high-frequency satellites;pgvectorfor embeddings, colocated with the facts they describe — the concrete hook for "AI should integrate seamlessly," since a local or cloud model just writes rows like any other ingest plugin;ltree(built-in contrib) for hierarchies like the accountingAccounttree. Not "ladybugdb" — LadybugDB (Kuzu's community successor) is a standalone embedded graph engine, not a PostgreSQL extension, so it can't run inside Postgres. Ifcore.linkjoins/recursive CTEs stop being enough for real multi-hop graph queries, Apache AGE is the actual Postgres extension for that (openCypher inside Postgres, Apache top-level project, supports PG 11–18) — worth adding later, not needed for the initial shape. - PostgreSQL 18 is current stable as of this writing; 19 is in beta, targeted for GA around September
- Start on 18; re-evaluate 19 after it's been GA for a while rather than tracking the beta.
flowchart TB
subgraph Core["core"]
ENTITY[("core.entity")]
LINKTBL[("core.link")]
TAG[("core.hub_tag")]
META[("core.sat_metadata")]
ARTIFACT[("core.hub_artifact")]
end
subgraph PeoplePlugin["people plugin"]
HPERSON[("hub_person")]
end
subgraph AccountingPlugin["accounting plugin"]
HTXN[("hub_transaction")]
HACCOUNT[("hub_account")]
SBAL[("sat_account_balance")]
end
subgraph HealthPlugin["blood_test plugin"]
HTEST[("hub_blood_test_result")]
SVAL[("sat_test_value")]
end
HPERSON -->|entity_id FK| ENTITY
HTXN -->|entity_id FK| ENTITY
HACCOUNT -->|entity_id FK| ENTITY
HTEST -->|entity_id FK| ENTITY
TAG -->|entity_id FK| ENTITY
ARTIFACT -->|entity_id FK| ENTITY
HACCOUNT --> SBAL
HTXN --> SBAL
HTEST --> SVAL
HPERSON -.->|"tagged_as (via link)"| TAG
HTXN -.->|"involves (via link)"| HPERSON
HTEST -.->|"belongs_to (via link)"| HPERSON
ARTIFACT -.->|"attached_to (via link)"| HTEST
Note on event sourcing for measurements¤
Largely already included, if satellites are kept insert-only. Each satellite row is already an immutable
fact with a valid_range and load_time; appending a new row for every new measurement or correction
(instead of UPDATE-ing) already gives an append-only, replayable, "as of any point in time" log — the
practical benefit event sourcing is usually adopted for. What real event-sourcing/CQRS additionally buys
you is modeling process-level events that aren't just "a value changed" (e.g. "ingestion run started,"
"duplicate detected and merged," "user corrected a typo and here's why") — genuinely distinct from state
facts. If a concrete need for that shows up, add a narrow core.event_log table alongside Hub/Link/
Satellite rather than adopting a second architecture (separate event store, message bus, projection
engine). This is consistent with experiment/health-track-ideas's own earlier conclusion that a message
bus is overkill at db4's write volume. Decision: don't adopt event-sourcing/CQRS as the primary
architecture; revisit only if a workflow-level auditing need appears, not just a history need.
Consequences¤
- Good, because every new data source ships as one plugin owning its own hub + satellite tables, with zero changes to core.
- Good, because cross-linking is free: one
core.linktable with real FK integrity via the sharedcore.entityspine, instead of a join table per pair of types. - Good, because bitemporal history comes from the shape itself (insert-only satellites), not a second storage engine or event bus.
- Good, because tags and free-form metadata are extensible from any UI with zero migrations.
- Good, because a single Postgres instance stays the only piece of infrastructure to run;
timescaledb,pgvector, and (later)apache_ageare extensions, not new services. - Bad, because
core.linksacrifices per-relationship-type typed columns (e.g. noNOT NULL rolecolumn specific to a person↔task link) — mitigate by allowing a plugin-owned side table once a relation type is attribute-rich enough to need one. - Bad, because generic operations (tag lookup, artifact discovery) always cost at least one join through
core.entity. - Bad, because there's no purpose-built temporal query language (unlike XTDB/Datomic); "as of" queries are hand-written SQL range containment, which is more verbose.
- Bad, because PostgreSQL's native
WITHOUT OVERLAPStemporal constraint support is very new (18+); until it's battle-tested, bitemporal validity is enforced by hand-written GiST exclusion constraints instead.
Confirmation¤
Before committing further domains to this shape: implement core.entity + core.link, re-derive the
experiment/add-accounting Account/Transaction shapes as a hub_account/hub_transaction +
satellites, and run one "as of" query and one cross-domain link query (e.g. "which person does this
transaction involve") end to end.
Pros and Cons of the Options¤
Hub–Link–Satellite on PostgreSQL¤
- Good, because the generic
core.linktable gives arbitrary cross-linking with real FK integrity. - Good, because satellites give bitemporal history "for free," with no separate audit mechanism.
- Neutral, because it requires discipline (insert-only satellites) that the shape itself doesn't enforce — needs a small core repository/helper layer to make that the easy path.
Per-domain normalized tables + ad hoc FKs (status quo)¤
- Good, because it's the simplest shape to reason about for a single isolated domain.
- Bad, because this is exactly the pain already hit on
experiment/add-accounting: no shared identity, so linking aTransactionto aPersonneeds a bespoke join table per pair of types, and it doesn't compose as more domains are added. - Bad, because there's no built-in history model — bitemporal columns would be re-invented per table.
Generic EAV / triple-store inside PostgreSQL¤
- Good, because it's maximally flexible — no schema change is ever needed.
- Bad, because it loses typed columns and native FK integrity; most queries become self-joins over one giant table.
- Bad, because the prior art that does this well (Wikibase, RDF stores) runs on purpose-built triple-store engines, not vanilla Postgres tables.
Dedicated bitemporal/Datalog engine as primary store (XTDB / Datomic family)¤
- Good, because bitemporal semantics and generic facts are native, not hand-built.
- Bad, because it adds a second engine/ecosystem (JVM/Clojure, often Kafka-backed) alongside Postgres — more operational burden than justified for a few-user system.
- Neutral, because it's worth a real spike later if hand-rolled bitemporal-on-Postgres proves insufficient.
Event Sourcing + CQRS with a separate event store¤
- Good, because it gives full replayable history and a clean audit trail of why something changed.
- Bad, because it needs a second storage system (event store/message bus) plus projection/read-model
machinery — already judged overkill for db4's write volume on
experiment/health-track-ideas. - Neutral, because insert-only satellites already deliver the state-history benefit without adopting the full pattern (see "Note on event sourcing for measurements" above).
More Information¤
- CLAUDE.md — the core design constraint and prior art this ADR builds on.
- Data Vault modeling: Hubs, Links, and Satellites
- Class Table Inheritance (Fowler)
- XTDB bitemporality
- Wikibase/DataModel primer
- PostgreSQL 18 released
- PostgreSQL temporal constraints (
WITHOUT OVERLAPS), PG 18 - Apache AGE overview
- pgvector
- TimescaleDB licensing
- LadybugDB (Kuzu's successor) — not a Postgres extension
- universal_pathlib
Revisit this ADR if: the write volume grows enough that insert-only satellites become a real storage/performance problem, a concrete workflow-auditing need (not just history) appears, or a spike of XTDB/the Datomic family turns out to remove more hand-built bitemporal code than it costs in operational complexity.