HR-ATS-Portal/docs/architecture/adr/0002-primary-relational-dat...

25 KiB
Raw Permalink Blame History

ADR 0002 — PostgreSQL 16+ as the Single Primary Relational Database

Status Accepted — 2026-07-29
Scope Database engine, instance topology, schema layout, extension set, and how schema change is authored and applied
Owner Talha Ahmed (senior); every migration reviewed by Talha
Consistent with _decisions.md Part 2 → Database engine, Schema tooling and migration strategy, Phase 1 search strategy, Threshold for a separate search service, Audit table strategy
Related ADRs 0001 (one database is a premise of the modular monolith), 0003 (blobs are not in the database), 0004 (the job queue lives in this database)

Context

Nothing in the repository constrains this choice. Verified absent (_repo-findings.md §B): any database driver, any ORM or query builder, any migration directory or migration tool, any .env or .env.example, any Dockerfile or docker-compose.yml. The prototype's entire "database" is a seeded LCG generating 100 candidates plus jobs, interviews, assessments, offers and inbox rows in memory at page load (js/data.js:8-10, js/data.js:110-131); localStorage is used only for the theme preference (js/app.js:64,193,198). Nothing persists. This is therefore a free capability-fit decision, and it should be made by matching engine features to the brief's non-negotiable invariants rather than by familiarity.

The constraints are fixed before the options are opened: one platform, one master data model, ONE relational database. Not multi-tenant, no per-region databases, no second datastore (no Elasticsearch, no vector database) in Phase 1. Two developers, no ops staff.

What the invariants actually demand of an engine

This is the decisive table. Every row is a brief-level requirement, not a preference.

Requirement Source Feature required
Raw intake must exist before any candidate or application brief §5.1; js/data.js:284-300 shows the prototype has no unresolved intake state at all NOT NULL FKs + DEFERRABLE INITIALLY DEFERRED constraint triggers for the "at least one contact channel at COMMIT" rule
Store full inbound email/webhook envelopes and parser output verbatim intake channels are Outlook mail and CV files jsonb + GIN
"One email, one identity" as a database fact brief §5.2 Partial + expression unique index on lower()-normalised address, tolerant of soft delete
Candidate identity separate from applications; at most one live application per (candidate, job) brief §5.2; prototype carries jobId on the candidate (js/data.js:117-127) GENERATED ALWAYS AS … STORED column + partial unique index whose predicate derives from status
Duplicate detection with typo tolerance, and manual review brief §5.7 pg_trgm GIN + similarity(), unaccent
Reversible merge with a per-operation undo log brief §5.7 Ordinary relational integrity + an append-only operation table; nothing exotic, but it must be transactional
Jobs, requirements and scoring configs versioned; historical scores must never drift brief §5.3, §5.6; aiScore: int(52,98) today (js/data.js:123) Immutable version rows with UPDATE revoked at the column/table grant level
ATS scores are per-application, append-only, version-pinned brief §5.6 Column-level GRANT to make ats_result insert-only; superseded_by chains
Current state and history both exist brief §5.5; no history tables exist today (§F) tstzrange + btree_gist + EXCLUDE constraints to prevent overlapping validity intervals
Interview scheduling without double-booking, timezone-correct no timezone discipline today; hardcoded "today" new Date('2026-07-09') (js/data.js:237) timestamptz + tstzrange + GiST EXCLUDE
Money with currency salary: int(90,190)*1000, no currency field anywhere; offer validation only checks > 0 (js/data.js:126, js/offers.js:129) numeric(14,2) + ISO-4217 code, bound by CHECK
Append-only audit at tens of millions of rows, archivable BRD §7.3 Declarative RANGE partitioning; pgcrypto for the hash chain
Explainable candidate search in Phase 1, semantic search in Phase 2, without a second datastore brief; forbidden infra tsvector/ts_rank_cd/setweight, pg_trgm, then pgvector in the same database
The chatbot must never bypass access controls brief Row-level security and column-level privileges available for the Phase 2 AI role with no new infrastructure
Transactional job enqueue BRD §6.3 — no document silently lost SKIP LOCKED + LISTEN/NOTIFY (see ADR 0004)

Options considered

Option A — PostgreSQL 16+ (single managed instance)

Pros

  • Every single row of the table above maps to a first-class feature of one engine. Nothing has to move into application code.
  • pgvector in the same database is what makes "no separate AI service in Phase 1" achievable rather than aspirational — hybrid retrieval (FTS/trigram candidate generation, vector rerank) needs no second store and no dual-write.
  • Same index family (pg_trgm GIN) serves both candidate search and duplicate detection, so one mechanism gets tuned, understood and tested instead of two.
  • Column-level GRANT and REVOKE let append-only-ness be enforced by the database rather than by convention — the difference between "we intend not to update scores" and "the application role cannot".
  • Two developers get one engine, one backup story, one PITR story, and psql for ad-hoc work.
  • Managed offerings (Azure Database for PostgreSQL Flexible Server) give PITR, automated backups and patching without an ops hire.

Cons (stated)

  • Extension availability is a dependency on the managed provider. pgvector and pg_partman must be confirmed available and version-appropriate on the chosen tier before Phase 2 planning is finalised.
  • Connection-per-backend model means every connection carries a memory cost, so the connection budget has to be planned rather than assumed. Phase 1 headroom is ample (~14 steady-state connections, budgeted in ADR 0004 §5), but the number grows multiplicatively with web replicas and worker processes.
  • Vertical scaling only in Phase 1 (no read replica). The database is the one component whose saturation has no cheap horizontal answer.
  • Major-version upgrades on a managed instance require a planned maintenance window with real downtime.
  • FTS ranking quality is below a dedicated search engine's — accepted, with a named escalation ladder rather than pretended away.

Option B — MySQL 8 / MariaDB

Pros — genuine

  • Extremely widely known; the easiest hiring and Stack Overflow surface, which matters with a junior developer.
  • Excellent replication tooling and mature managed offerings everywhere.
  • InnoDB is fast and predictable for the OLTP shape of most ATS traffic.
  • Native JSON type and generated columns exist, so two of the requirements above are covered.

Cons — and they are disqualifying, one by one

  • No trigram similarity index → typo-tolerant duplicate detection and fuzzy name search move into application code or a second datastore. This is the platform's single most important matching feature.
  • No vector type → a separate vector database becomes mandatory in Phase 2, violating a hard constraint.
  • No partial (WHERE) indexes → "one live application per (candidate, job), tolerant of soft delete and of merge supersession" cannot be expressed as a constraint. It becomes an application check, which is exactly the class of invariant the prototype already fails.
  • No exclusion constraints → interview double-booking prevention moves into application code with a race window.
  • No deferrable constraint triggers → the "at least one contact channel at COMMIT" rule cannot be expressed.
  • Weaker declarative partitioning ergonomics for the audit archive.

The pattern is the point: MySQL does not merely score lower, it relocates four or five load-bearing invariants from the database into application code written under time pressure by a two-person team. That is the documented failure mode of the existing prototype.

Option C — Microsoft SQL Server

Pros

  • Best-in-class tooling, query optimiser and execution-plan diagnostics.
  • Native temporal (system-versioned) tables — a real advantage for the current-state-plus-history requirement, and the only option here that ships it.
  • Filtered indexes (equivalent to partial indexes), CHECK constraints, and strong JSON support.
  • Natural fit if Utopia is otherwise a Microsoft estate, which it plausibly is given M365/Outlook (assumption).

Cons

  • Licensing cost with no capability gain that matters to us: system-versioned tables mirror columns, not transitions, so they do not actually answer "who moved this candidate from Screening to Interview and why" — we would still hand-write transition tables (see _decisions.md Part 2, Current state plus history).
  • No trigram index and no first-party vector index at the maturity we need → same second-datastore problem as MySQL for both dedupe and Phase 2 semantics.
  • Full-text search is a separate service component with a coarser index-maintenance model.
  • The Python parsing/AI ecosystem this platform depends on (ADR 0001) is Postgres-centric; driver and extension ergonomics are worse.

Option D — MongoDB or another document store

Pros

  • Storing raw email envelopes and heterogeneous parser output as documents is genuinely natural, and schema-per-document suits the messy shape of CV extraction.
  • Horizontal scaling and flexible-schema iteration are real strengths early in a greenfield project.
  • Atlas Search bundles full-text and vector search, which would cover Phase 1 and Phase 2 retrieval in one product.

Cons

  • The entire brief is a list of relational invariants: raw-intake-before-candidate, identity separated from applications, per-application version-pinned scores, at-most-one-live-application, reversible merge with an undo log, non-overlapping validity intervals. In a document store every one of those becomes an application convention.
  • That is precisely the failure the repository already exhibits — js/data.js:117-127 is a flat denormalised document per candidate, and it is why one candidate cannot hold two applications. Choosing a document store would institutionalise the prototype's data model as the production one.
  • Cross-document transactions exist but are the exception rather than the default, and the Phase 1 write path spans four aggregates.
  • The reversible merge undo log and audit hash chain both depend on strict ordering and on a role that physically cannot UPDATE — much weaker in this model.
  • jsonb in Postgres already covers the legitimate document-shaped needs (raw payloads, parser output, before/after audit values) without giving up constraints.

Option E — PostgreSQL plus specialised stores in Phase 1 (Elasticsearch and/or a vector DB)

Pros

  • Best search relevance and richest faceting available; per-field BM25 tuning and learning-to-rank become possible.
  • Removes search load from the transactional database entirely.

Cons

  • Forbidden by constraint, and absurd against the evidence: the prototype holds 100 generated rows (js/data.js:112), and the realistic corpus is 10⁴10⁵ candidates accumulated over years (assumption).
  • Introduces a permanent dual-write plus reindex-drift cost that two developers feel every week, and a second backup/restore story.
  • In practice most "we need Elasticsearch" moments are an unindexed query or an untuned ranking function.

Option F — SQLite

Pros: zero operational cost, trivially reproducible in CI, single-file backups. Cons: no partitioning, weak write concurrency, no LISTEN/NOTIFY for the queue, no trigram/vector story, no role-level or column-level privileges. Viable for a single-user tool; not for a 66-seat multi-writer system with a worker process.


Decision

PostgreSQL 16+ (target 17). ONE managed instance, ONE logical database.

1. Instance and layout

Item Decision
Hosting Managed (Azure Database for PostgreSQL Flexible Server — assumption, tracking the Azure/M365 alignment in ADR 0001), PITR enabled, 14-day automated backups. No self-managed Postgres on a VM.
Sizing (Phase 1) 2 vCPU / 8 GB. No read replica. Vertical scaling is the Phase 14 answer.
Instances Exactly one per environment (local via docker compose, staging, production). No per-region and no per-module databases.
Schemas app (domain), ref (controlled vocabularies), audit (partitioned append-only log), ai (AiRun, prompt/model versions, embeddings), staging (raw intake landing)
Timezone Server and application role set timezone = 'UTC'. All instants are timestamptz.
Roles Separate migration role (owns DDL), app role (DML only, with UPDATE/DELETE revoked on append-only tables), and a Phase 2 ai_query role for the chatbot path with RLS policies and column privileges excluding sensitive_personal columns.

2. Extensions

Phase Extensions Purpose
1 pg_trgm Fuzzy name/employer search and duplicate detection — one mechanism, two consumers
1 unaccent Normalisation pipeline for names and free text
1 btree_gist EXCLUDE constraints combining scalar equality with tstzrange overlap (history intervals, interview double-booking)
1 pgcrypto sha256 for the audit hash chain, document checksums and deterministic retention tokens
2 pgvector Semantic retrieval in the same database, HNSW index, embeddings versioned per model
2 pg_partman or a small scheduled SQL function Monthly audit partition management

Confirm extension availability on the target managed tier before Phase 2 planning closes. This is an explicit action item, not an assumption to discover late.

3. Schema authoring and migration — the SQL-first rule

The schema is authored as plain SQL. The ORM maps to the schema; it never generates it.

Reconciling this with the Django choice in ADR 0001 (_decisions.md Part 1 requires a makemigrations --check gate; Part 2 requires SQL-authored DDL), the binding mechanism is:

Element Decision
Runner Django's own migration executor — it is the runner that "matches the backend language", it is the one procrastinate ships migrations against, and it makes CI test-database creation apply the real schema
Migration content Hand-written SQL inside migrations.SeparateDatabaseAndState(database_operations=[RunSQL(...)], state_operations=[...]). The SQL is the truth; state_operations keeps Django's model state honest
Drift detection makemigrations --check --dry-run as a required CI gate. Because state is declared explicitly, a model change without a migration fails the build, and a migration whose declared state diverges from its SQL surfaces as drift
Down-migrations Not written. Recovery is forward-fix plus PITR
Review Every migration reviewed by Talha, no exceptions
Tests pytest against a real Postgres service container, never SQLite — the design depends on jsonb, partial unique indexes, FTS, pg_trgm, EXCLUDE constraints and LISTEN/NOTIFY
graph TD
  SQL["db/migrations/*.sql<br/>hand-written DDL — the source of truth"] --> RUN["Django migration executor<br/>SeparateDatabaseAndState"]
  RUN --> PGDB[("PostgreSQL<br/>app · ref · audit · ai · staging")]
  RUN --> STATE["Django model state"]
  STATE --> CHECK["CI: makemigrations --check<br/>drift gate"]
  MODELS["Django models<br/>mirror the schema"] --> CHECK
  PGDB --> CI["CI: pytest against real Postgres<br/>service container"]

Justification

One engine covers every invariant; every alternative relocates invariants into application code. That is the whole argument, and the requirements table above is its evidence. The prototype is a live demonstration of what happens when invariants live in application conventions: a flat candidate array where a person cannot hold two applications (js/data.js:117-127), a random integer masquerading as a score (js/data.js:123), no history at all, no currency on money, and an RBAC matrix nothing reads (js/rbac.js:78). The correction is to push invariants down into the engine, which requires an engine that can hold them.

Postgres is the only mainstream engine where duplicate detection and Phase 2 semantic search need no new infrastructure. Trigram similarity and vector search inside the same database is what turns "no separate AI service, no Elasticsearch in Phase 1" from a restriction we are grudgingly obeying into the natural design. On MySQL or SQL Server, honouring the same constraint would mean writing fuzzy matching by hand.

Column-level privileges are the mechanism behind three separate brief requirements. "Scores are append-only", "versions are immutable", "audit cannot be altered" are all implemented as REVOKE UPDATE, DELETE from the application role, plus a BEFORE UPDATE OR DELETE trigger that raises. Two independent layers, both travelling with the schema. No ORM-level convention achieves this.

SQL-first migrations, stated as the tradeoff it is. We give up the convenience of makemigrations autogeneration, and the junior developer has to read DDL. We accept that because the invariants are partial unique indexes with WHERE clauses, expression indexes on lower(), regex CHECKs, DEFERRABLE INITIALLY DEFERRED constraint triggers, GiST EXCLUDE constraints, generated columns, RANGE partitions and column-level GRANTs — and an ORM expresses approximately none of them. If the schema were ORM-declared, all of this would live in raw-SQL escape hatches anyway while the ORM's model of truth silently diverged. Plain SQL also makes the schema reviewable as a diff, which matters when one of two developers is junior and the other is the only reviewer.

One database is also a security decision. RLS and column privileges for the Phase 2 chatbot role mean the access boundary can be enforced by the database rather than by prompt engineering — with zero new infrastructure. A polyglot topology would put candidate PII in a second store with its own, weaker, access model.


Consequences

Positive

  • Every non-negotiable invariant in the brief is expressible as schema. Correctness is enforced at the lowest possible layer.
  • One backup, one PITR, one restore drill, one psql — the entire persistence operational surface for two developers.
  • Duplicate detection and search share tuned indexes, halving the surface that must be understood and tested.
  • Phase 2 semantics (pgvector) and Phase 2 chatbot isolation (RLS + column grants) both arrive without new components.
  • The job queue is transactional with domain writes because it is in the same database (ADR 0004).
  • Analytics read models are SQL views owned by one module — no ETL, no second warehouse in Phase 1.

Negative — costs we are accepting

Cost Accepted because
The database is a single point of failure and the only stateful component. No read replica in Phase 1 It is managed, so recovery is PITR rather than a runbook we must write; business-hours internal tool
Vertical scaling only. Saturation has no cheap horizontal answer 66 seats; the escalation ladder (tune → replica → materialise) is defined before it is needed
Search relevance is below a dedicated engine's Explicit numeric triggers replace vibes; a BM25 extension (pg_search/ParadeDB) sits on the ladder before any second datastore
Connections are scarce. A 2 vCPU instance's connection budget must be shared between web threads, worker concurrency and each worker's LISTEN connection Budgeted explicitly in ADR 0004; PgBouncer in transaction mode is the named next step
Extension availability is a managed-provider dependency Named as a pre-Phase-2 verification action, not an assumption
SQL-first migrations cost the team autogeneration convenience and raise the floor of SQL knowledge required The invariants are unexpressible otherwise; Talha reviews every migration; it is also a genuine learning workstream for the junior
Major-version upgrades need a real maintenance window Internal, business-hours; the six-jurisdiction spread narrows but does not eliminate the window
Audit and queue tables share the engine with OLTP, so their bloat and autovacuum behaviour affect recruiter latency Audit is RANGE-partitioned so archival is DETACH, not mass DELETE; queue table health is an explicit monitoring item (ADR 0004)

Risks

# Risk Likelihood Impact Mitigation
R1 pgvector or pg_partman unavailable or version-lagging on the managed tier chosen Medium Medium Verify before Phase 2 planning closes; fallback is a scheduled plpgsql partition function (already named) and, for vectors, deferring hybrid retrieval rather than adding a vector DB
R2 Connection exhaustion as web replicas and worker processes multiply (not a Phase 1 concern — ~14 steady-state against several hundred available) Low in Phase 1, Medium later High (hard outage) Explicit connection budget in ADR 0004 §5; PgBouncer transaction pooling as the pre-planned fix at 40% of max_connections, with a session-mode path for the queue's LISTEN connection; alert at 70%
R3 Audit partition growth degrades vacuum/IO and hurts recruiter latency Medium Medium Monthly RANGE partitions; detach + archive after 13 months; audit retention set independently of candidate retention
R4 GIN index write amplification on candidate_search_index slows the intake path LowMedium Medium Search index is one row per candidate maintained by trigger, not a generated column on a wide table; reindex is a targeted UPDATE
R5 SQL-first migrations drift from Django model state, so ORM queries assume a schema that does not exist Medium Medium SeparateDatabaseAndState + makemigrations --check as a required gate; tests run against a real Postgres built from the same migrations
R6 The team, under deadline pressure, "temporarily" adds a second datastore LowMedium High (violates a hard constraint and creates permanent dual-write) The escalation ladder and numeric triggers below; any second datastore requires a new ADR superseding this one
R7 Single region conflicts with a legal ruling on data residency across the six jurisdictions Medium High Escalated to legal as an open question; retention and deletion are implemented per record, not per region, so a ruling changes policy rows, not topology. Per-region databases remain forbidden

Revisit conditions

Reopen the engine choice only if

# Trigger Threshold
E1 A required capability is genuinely absent from Postgres including extensions Named, with a failed spike documented
E2 Legal mandates data residency that a single instance cannot satisfy A written legal determination — and even then the first response is region migration, not multiple databases

Reopen the topology (replica, partitioning strategy, pooling) when

# Trigger Threshold
T1 Database size Total >500 GB, or audit.audit_event >200 million rows
T2 Candidate corpus Candidate rows exceed ~2,000,000, or indexed searchable text exceeds ~50 GB
T3 Transactional latency p95 for indexed single-row reads >100 ms, or p95 write >200 ms, after query and index tuning
T4 CPU Sustained DB CPU >70% during business hours for 2+ weeks at 4 vCPU / 16 GB
T5 Connections Peak connections >70% of max_connections (→ PgBouncer, before any sizing change)
T6 Vector memory HNSW index working set no longer fits comfortably in shared buffers at the current tier

Reopen "search stays in Postgres" only when ANY of these actually fires

# Trigger Threshold
S1 Corpus As T2
S2 Latency p95 search >500 ms on the tuned FTS + trigram path after index tuning and after moving search to a read replica
S3 Throughput Sustained >50 search queries/second and measurable degradation of transactional write latency
S4 Capability A requirement Postgres genuinely cannot serve: live per-field BM25 relevance experimentation, learning-to-rank, sub-second facets across >~10 dimensions, or cross-entity typo-tolerant autocomplete under 50 ms

Exhaust these first, in order: index and query tuning → a read replica dedicated to search → a materialised search table → a BM25 extension (pg_search/ParadeDB) if ranking quality alone is the gap.

Honest projection, labelled an assumption: an internal Utopia Brands recruiting platform will hold on the order of 10⁴10⁵ candidates accumulated over several years — three to four orders of magnitude below trigger S1/T2. The realistic conclusion is that Postgres FTS plus trigram will very likely never be outgrown for this system, and a read-replica-for-search is the ceiling of what will ever be needed.