PostgreSQL: Database Architecture and Conventions

Table of Contents

ORE Studio uses PostgreSQL as its sole persistent store. All services read and write through a shared database, each with a strictly scoped service role. This article documents the database structure, naming conventions, session settings, and the patterns every service must follow. Return to Knowledge.

Project reference

The SQL source lives entirely in ores.sql. Key paths:

Path Purpose
ores.sql/create/ DDL: tables, triggers, functions, indexes, RLS policies
ores.sql/migrate/ Incremental migrations (applied by migrate.sh)
ores.sql/populate/ Seed data: reference tables, system accounts, flags
ores.sql/drop/ Complementary DROP scripts (idempotent teardown)
ores.sql/test/ pgTAP SQL unit tests
ores.sql/create/utility/utility_functions_create.sql Shared utility functions (infinity timestamp, name normalisation, etc.)
ores.sql/setup_extensions.sql Extension installation script
compass db recreate Full database teardown and rebuild
ores.sql/migrate.sh Incremental migration runner

Schema layout

All tables share the public schema with an ores_ prefix followed by a domain sub-prefix:

Prefix Domain
ores_iam_* Identity and access management
ores_refdata_* Reference data (currencies, parties, books, …)
ores_trading_* Trades, instruments, lifecycle events
ores_analytics_* Pricing models and configurations
ores_compute_* Compute grid: apps, batches, work units, results
ores_reporting_* Report definitions, instances, concurrency policies
ores_dq_* Data quality: badges, FSMs, datasets, catalogues
ores_marketdata_* Market series, observations, fixings
ores_workflow_* Workflow instances and steps
ores_variability_* System settings
ores_assets_* Binary assets (images, tags)
ores_telemetry_* Telemetry logs and samples
ores_scheduler_* Job definitions and instances

Roles and users

ORE Studio uses a least-privilege role model. Every service runs as a dedicated database user with DML only on its own domain tables. No service user can read or write another domain's tables directly.

Role pattern Privilege
ores_<env>_owner Superuser-level within the DB; owns all objects
ores_<env>_ddl_user DDL (CREATE, ALTER, DROP); used by setup scripts
ores_<env>_<service>_service SELECT, INSERT, UPDATE, DELETE on own domain only
ores_<env>_readonly_user SELECT on all tables; used by reporting tools
ores_<env>_cli_user CLI surface DML
ores_<env>_shell_user Shell surface DML
ores_<env>_wt_user Wt (web) surface DML
ores_<env>_http_user HTTP surface DML

Row-Level Security enforces tenant isolation on top of these grants — see PostgreSQL Row-Level Security for details.

Session conventions

Every application connection must set the following before executing queries. The ores.sql tenant_aware_pool does this on acquire():

-- Force UTC — all TIMESTAMPTZ values are returned as "YYYY-MM-DD HH:MM:SS+00"
SELECT set_config('TimeZone', 'UTC', false);

-- Set the current tenant (required by RLS policies)
SELECT set_config('app.current_tenant_id', '<uuid>', false);

-- Set the current party (required by some service-level policies)
SELECT set_config('app.current_party_id', '<uuid>', false);

Every service must set TimeZone = UTC on every connection. Without it, PostgreSQL uses the server's local timezone and returns TIMESTAMPTZ values with local offsets rather than +00. All C++ timestamp parsing assumes UTC.

Bitemporal design

All reference and trading entities use a bitemporal schema: see Time and Timestamps: Architecture and Conventions for the full treatment. The short version:

  • Every table has valid_from TIMESTAMPTZ NOT NULL and valid_to TIMESTAMPTZ NOT NULL.
  • valid_from is set to current_timestamp by the INSERT trigger.
  • valid_to is set to ores_utility_infinity_timestamp_fn() on INSERT and closed to current_timestamp on UPDATE or DELETE.
  • "Current" records are those with valid_to = ores_utility_infinity_timestamp_fn().
  • ores_utility_infinity_timestamp_fn() must be used in SQL wherever the sentinel value is needed — never the bare literal.

Extensions

Extensions are installed by ores.sql/setup_extensions.sql, which is called during database creation. Extensions are per-database in PostgreSQL.

btree_gist (required)

Extends GiST indexes to support btree-indexable data types (integers, timestamps, text). ORE Studio uses it for temporal exclusion constraints: a GiST index over (id, valid_from, valid_to) prevents two rows for the same entity from having overlapping valid-time ranges. Without btree_gist, the exclusion constraint on bitemporal tables cannot be created.

create extension if not exists btree_gist;

unaccent (required)

Provides a text-search dictionary that strips accents from characters (e.g. é → e, ñ → n). Used by the ores_utility_normalise_name_fn() helper to produce a lowercase, accent-free version of party and counterparty names for deduplication and search. Without unaccent, name normalisation falls back to a plain lower() which misses accented characters.

create extension if not exists unaccent;

pgtap (optional — recommended for development)

A TAP-compliant unit-testing framework for SQL. Provides plan(), is(), ok(), throws_ok(), and similar functions for writing database-level tests under ores.sql/test/. If not installed, the SQL test suite cannot run; application functionality is unaffected.

Install on Debian/Ubuntu: apt install postgresql-NN-pgtap.

create extension if not exists pgtap;

timescaledb (optional — recommended for telemetry)

A time-series database extension. ORE Studio uses it for the telemetry tables (ores_telemetry_*): hypertables partition telemetry logs by time, enabling efficient range queries and compression. If TimescaleDB is not installed, the telemetry tables fall back to regular PostgreSQL tables — all functionality works but performance at scale degrades.

TimescaleDB requires a system-level installation (apt install timescaledb-2-postgresql-NN) and shared_preload_libraries = 'timescaledb' in postgresql.conf before the extension can be created.

create extension if not exists timescaledb;

Insert trigger patterns

Every writable table has a BEFORE INSERT trigger that enforces the invariants the application layer is allowed to assume. Triggers fall into three structural categories with different validation contracts.

Category 1 — Domain entities

Full-CRUD entities with their own MDI windows, shell/CLI commands, and manual chapters (currency, country, party, book, …).

Trigger structure (order is mandatory):

  1. tenant_idores_iam_validate_tenant_fn(new.tenant_id)
  2. Optional soft-FK fields (coding_scheme_code inline; named attributes via ores_refdata_validate_*_fn / similar)
  3. change_reason_codeores_dq_validate_change_reason_fn(...)
  4. Version management block (SELECT … FOR UPDATE, optimistic lock check, UPDATE … SET valid_to, version increment)
  5. valid_from / valid_to set by trigger
  6. modified_byores_iam_validate_account_username_fn(new.modified_by)
  7. performed_bycoalesce(ores_iam_current_service_fn(), current_user)

Rule: all validation (steps 1–3) must precede the version management block (step 4). This ensures the trigger fails fast — before acquiring the row lock and before closing the previous temporal record — on any bad input.

Domain entity triggers should declare security definer and set search_path = public, pg_temp to prevent search-path injection.

Category 2 — Owned sub-entities

Records that are data owned by a parent entity and carry their own temporal history: party_identifiers, party_contact_informations, business_units, …

Trigger structure follows Category 1, with these additions:

  • Parent UUID FK (e.g. party_id) validated inline with if not exists (select 1 from ores_refdata_parties_tbl where id = NEW.party_id …). No separate validate function exists for UUID-keyed parent references; the inline check is the established pattern.
  • Type/scheme attributes (e.g. id_scheme) validated via ores_refdata_validate_party_id_scheme_fn(...).

Sub-entities do not have an independent validate function (there is no ores_refdata_validate_party_identifier_fn); they are not referenced as soft-FKs by other entities.

Category 3 — Pure visibility junctions

Tables that control which entities a party can see, not data about the party itself: party_currencies, party_countries, party_counterparty.

These carry version management and audit columns but their entity references (currency_iso_code, country_alpha2_code, party_id, …) are not validated in the trigger. The validated fields are:

  • tenant_idores_iam_validate_tenant_fn(...)
  • modified_by, performed_by — same as Categories 1 & 2
  • change_reason_codeores_dq_validate_change_reason_fn(...)

Why no entity FK validation? Junction rows represent an access-control decision ("party X may see country Y"), not a business datum. An orphan junction row — pointing to a country that does not (yet) exist — is harmless: the join produces no rows and the UI shows nothing. Contrast this with a book.ledger_ccy field: if the currency disappears, the book's P&L calculation breaks. The semantic difference justifies the asymmetry.

In practice these junctions are populated by trusted internal operations (the provisioner, bulk seeders) where the application layer already guarantees entity existence.

Validation functions (ores_refdata_validate_*_fn)

Each refdata entity that may appear as a soft-FK attribute on another entity must define a validation function:

create or replace function ores_refdata_validate_<entity>_fn(
    p_tenant_id uuid,
    p_value     text
) returns text
security definer
set search_path = public, pg_temp
as $$
-- Raises 23502 if null/empty.
-- Returns p_value unchanged if no active rows exist yet (bootstrap pass-through).
-- Raises 23503 with a helpful list if the value is not found among active rows.
$$ language plpgsql;

These functions are:

  • Called from Category 1 and 2 triggers, never from Category 3.
  • Defined in the same DDL file as their entity's table (at the bottom, after the soft-delete rule).
  • Dropped in the entity's drop/ file alongside the table and insert function.
  • Declared security definer + set search_path = public, pg_temp unconditionally — see below.

Security: validate functions must carry security definer and set search_path = public, pg_temp independently of their callers. security definer prevents the function from executing as the calling service role (which may have a manipulated search path). set search_path is required alongside it to pin table resolution to public and pg_temp; security definer alone, without a pinned path, is still vulnerable to search-path injection. security definer does not propagate to callees — each function must declare it explicitly.

Bootstrap pass-through: the pass-through check must test for active rows (valid_to = ores_utility_infinity_timestamp_fn()), not any rows. A table with only soft-deleted historical rows is semantically empty for validation purposes; checking without the valid_to filter would skip the pass-through, then fail the active-row validation with a misleading "Must be one of: (empty list)" error. The limit 1 inside EXISTS is a no-op and is omitted.

Notification triggers

Most tables have a NOTIFY trigger that fires after INSERT, UPDATE, or DELETE and publishes a JSON payload on a per-entity PostgreSQL channel. The service layer listens on that channel and translates database events into NATS messages, which are then routed to connected clients. This is how live-update eventing works:

DB write → NOTIFY trigger → service listener → NATS event → Qt markAsStale()

Trigger files follow the naming pattern: <domain>_<table>_notify_trigger_create.sql.

PostgreSQL channel naming

Every pg_notify channel follows the rule:

ores_{component}_{entity_plural}

where component is the domain sub-prefix (iam, refdata, trading, analytics, compute, dq, reporting, variability, workspace, scheduler, mq, …) and entity_plural is the snake_case plural name of the table without its ores_{component}_ table prefix.

Component Example entity PostgreSQL channel
refdata currency ores_refdata_currencies
refdata party ores_refdata_parties
trading trade ores_trading_trades
iam role ores_iam_roles
dq change_reason ores_dq_change_reasons
variability system_setting ores_variability_system_settings
workspace workspace ores_workspace_workspaces

The component prefix is required. Without it, the same entity noun in two domains (e.g. parties in refdata and a hypothetical parties in iam) would share a single channel, causing incorrect cross-domain event delivery.

The codegen template sql_schema_notify_trigger.mustache generates conforming channel names automatically. Handwritten triggers must follow the same rule.

NATS event channel naming

The application layer re-publishes each database event onto a NATS subject visible to shell and UI clients. The naming convention there is distinct from the PostgreSQL layer:

ores.{component}.{entity}_changed

PostgreSQL channel NATS subject
ores_refdata_currencies ores.refdata.currency_changed
ores_iam_roles ores.iam.role_changed

The service registers the mapping explicitly; the two names are not derived from each other automatically.

Key utility functions

Defined in ores.sql/create/utility/utility_functions_create.sql:

Function Returns Purpose
ores_utility_infinity_timestamp_fn() timestamptz Canonical "live record" sentinel. Always use this, never the bare literal.
ores_utility_normalise_name_fn(text) text Lowercase + unaccent normalisation for search/dedup.
ores_iam_validate_account_username_fn(text) text Validates modified_by is a known IAM account; called from write triggers.
ores_iam_current_service_fn() text Returns the current service username; used to set performed_by.
ores_dq_validate_change_reason_fn(uuid, text) text Validates change_reason_code against the DQ catalogue.

See also

Emacs 29.1 (Org mode 9.6.6)