Skip to main content

Overview

PensionPortal.ai uses PostgreSQL (hosted on Neon serverless) with Drizzle ORM for all data access. This document is the authoritative reference for the database schema, entity relationships, data classifications, and retention policies.
PropertyValue
DatabasePostgreSQL 16 (Neon serverless)
ORMDrizzle ORM (TypeScript)
Primary key strategyCUID2 (collision-resistant, URL-safe, sortable)
Soft delete strategyStatus enums (FSM-based) — no deletedAt columns
Temporal trackingcreatedAt and updatedAt on all tables
Vector extensionpgvector (1536-dimensional embeddings for RAG)
Tenant isolationApplication-layer via ActorContext (no RLS)

Entity Relationship Summary

brokers
  └── users (brokerId FK)
  └── employers (brokerId FK)
        └── users (employerId FK) — EmployerAdmin / EmployerUser
        └── schemes (employerId FK)
              ├── members (schemeId + employerId FK)
              │     └── contributionRecords (memberId FK)
              │     └── fundChoices (memberId FK)
              ├── keyFunctionHolders (schemeId FK)
              ├── writtenPolicies (schemeId FK)
              ├── oraReports (schemeId FK)
              ├── healthCheckAssessments (schemeId FK)
              ├── acsWizard (schemeId FK)
              └── documents (schemeId FK)

providers
  └── schemes (providerId FK)

ragCorpus (standalone — no FK to tenant data)
audit_logs (standalone — references actorId + entityId by convention, no FK)

Table Descriptions

users

Stores all authenticated principals. The brokerId column is the tenant anchor for broker-level users.
const users = pgTable("users", {
  id:           text("id").primaryKey().$defaultFn(() => createId()),
  email:        text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  role:         roleEnum("role").notNull(),
  brokerId:     text("broker_id").references(() => brokers.id),
  employerId:   text("employer_id").references(() => employers.id),
  fullName:     text("full_name"),
  createdAt:    timestamp("created_at").defaultNow().notNull(),
  updatedAt:    timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
idtext (CUID2)Primary key
emailtextUnique, used as login identifier
passwordHashtextbcryptjs hash (work factor 12) — never plaintext
roleenumSuperAdmin | BrokerAdmin | BrokerUser | EmployerAdmin | EmployerUser | Member
brokerIdtext (FK)Tenant anchor — set at creation, immutable
employerIdtext (FK)Populated for EmployerAdmin / EmployerUser / Member
fullNametextDisplay name
createdAttimestampUTC, server-assigned
updatedAttimestampUTC, updated on every write

password_reset_tokens

Stores hashed password reset tokens for the forgot-password flow. Tokens are SHA-256 hashed before storage — plaintext is never persisted.
const passwordResetTokens = pgTable("password_reset_tokens", {
  id:        text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId:    text("user_id").notNull(),
  tokenHash: text("token_hash").notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  usedAt:    timestamp("used_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
});
ColumnTypeNotes
idtext (UUID)Primary key
userIdtext (FK)References users.id
tokenHashtextSHA-256 hash of the plaintext token
expiresAttimestampToken expiry (60 minutes from creation)
usedAttimestampSet when token is consumed (one-time use)
createdAttimestampUTC, server-assigned

employers

The primary tenant-scoped entity. Every employer belongs to exactly one broker tenant.
const employers = pgTable("employers", {
  id:                 text("id").primaryKey().$defaultFn(() => createId()),
  brokerId:           text("broker_id").notNull().references(() => brokers.id),
  name:               text("name").notNull(),
  registrationNumber: text("registration_number"),
  sector:             text("sector"),
  payrollCycle:       payrollCycleEnum("payroll_cycle"),
  brand:              jsonb("brand"),   // { primaryColor, logoUrl, ... }
  stats:              jsonb("stats"),   // cached aggregate counts
  createdAt:          timestamp("created_at").defaultNow().notNull(),
  updatedAt:          timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
idtext (CUID2)Primary key
brokerIdtext (FK)Non-nullable tenant anchor
nametextEmployer legal name
registrationNumbertextCRO / Companies Registration Office number
sectortextIndustry sector (NACE code or free text)
payrollCycleenumweekly | fortnightly | monthly | quarterly
brandJSONBWhitelabel branding: { primaryColor, logoUrl, tradingName }
statsJSONBCached counts: { memberCount, schemeCount, activeKfhCount }

providers

Pension product providers (insurers, fund managers). Not tenant-scoped — shared reference data.
const providers = pgTable("providers", {
  id:               text("id").primaryKey().$defaultFn(() => createId()),
  slug:             text("slug").notNull().unique(),
  displayName:      text("display_name").notNull(),
  submissionFormat: text("submission_format"), // e.g. "xml_v2", "csv_iol"
  contactName:      text("contact_name"),
  contactEmail:     text("contact_email"),
  contactPhone:     text("contact_phone"),
  createdAt:        timestamp("created_at").defaultNow().notNull(),
  updatedAt:        timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
slugtextURL-safe identifier (e.g. irish-life, zurich-ireland)
submissionFormattextFormat for contribution file uploads

schemes

A pension scheme belongs to one employer and uses one provider. The status column implements a finite state machine.
const schemes = pgTable("schemes", {
  id:                          text("id").primaryKey().$defaultFn(() => createId()),
  employerId:                  text("employer_id").notNull().references(() => employers.id),
  providerId:                  text("provider_id").references(() => providers.id),
  name:                        text("name").notNull(),
  type:                        schemeTypeEnum("type").notNull(),
  status:                      schemeStatusEnum("status").notNull().default("Draft"),
  revenueApprovalNumber:       text("revenue_approval_number"),
  trustDeedDate:               date("trust_deed_date"),
  establishmentDate:           date("establishment_date"),
  normalRetirementAge:         integer("normal_retirement_age"),
  payrollCycle:                payrollCycleEnum("payroll_cycle"),
  salaryDefinition:            text("salary_definition"),
  omaFlag:                     boolean("oma_flag").default(false),
  establishedBeforeDerogation: boolean("established_before_derogation").default(false),
  transitionDecision:          text("transition_decision"),
  createdAt:                   timestamp("created_at").defaultNow().notNull(),
  updatedAt:                   timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
typeenumDB | DC | PRSA | RAC | SmallSelf | Executive
statusenumFSM — see Scheme Status FSM
revenueApprovalNumbertextIssued by Irish Revenue Commissioners on approval
omaFlagbooleanOne-Member Arrangement — triggers different IORP II obligations
establishedBeforeDerogationbooleanAffects IORP II transitional provisions
transitionDecisiontextOMA transition decision text

members

Individual scheme members. PPS numbers are stored encrypted (AES-256-GCM). Status implements a FSM.
const members = pgTable("members", {
  id:                   text("id").primaryKey().$defaultFn(() => createId()),
  employerId:           text("employer_id").notNull().references(() => employers.id),
  schemeId:             text("scheme_id").notNull().references(() => schemes.id),
  firstName:            text("first_name").notNull(),
  lastName:             text("last_name").notNull(),
  dateOfBirth:          date("date_of_birth"),
  ppsNumberEncrypted:   text("pps_number_encrypted"), // AES-256-GCM ciphertext
  email:                text("email"),
  phone:                text("phone"),
  status:               memberStatusEnum("status").notNull().default("PendingEnrolment"),
  currentFundChoiceId:  text("current_fund_choice_id"),
  employeeContribRate:  numeric("employee_contrib_rate"),  // percentage
  employerContribRate:  numeric("employer_contrib_rate"),  // percentage
  createdAt:            timestamp("created_at").defaultNow().notNull(),
  updatedAt:            timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
ppsNumberEncryptedtextAES-256-GCM ciphertext — never plaintext. See Security Architecture
statusenumFSM — see Member Status FSM
currentFundChoiceIdtext (FK)Active fund allocation
employeeContribRatenumericPercentage of salary — e.g. 5.00 for 5%
employerContribRatenumericPercentage of salary

keyFunctionHolders

Records the IORP II Key Function Holders (KFHs) appointed to a scheme. Pensions Authority notification status is tracked.
const keyFunctionHolders = pgTable("key_function_holders", {
  id:                      text("id").primaryKey().$defaultFn(() => createId()),
  schemeId:                text("scheme_id").notNull().references(() => schemes.id),
  kfhType:                 kfhTypeEnum("kfh_type").notNull(),
  holderName:              text("holder_name").notNull(),
  holderEmail:             text("holder_email"),
  appointmentDate:         date("appointment_date"),
  paNotificationStatus:    text("pa_notification_status"), // "pending" | "submitted" | "acknowledged"
  fitAndProperAssessedAt:  timestamp("fit_and_proper_assessed_at"),
  status:                  text("status").notNull().default("active"), // "active" | "resigned" | "removed"
  createdAt:               timestamp("created_at").defaultNow().notNull(),
  updatedAt:               timestamp("updated_at").defaultNow().notNull(),
});
ColumnTypeNotes
kfhTypeenumrisk_management | internal_audit | actuarial
paNotificationStatustextPensions Authority notification workflow state
fitAndProperAssessedAttimestampDate of Fit & Proper assessment under IORP II

writtenPolicies

Tracks the 11 written policies required by IORP II. Each policy record stores its review status and RAG rating.
const writtenPolicies = pgTable("written_policies", {
  id:             text("id").primaryKey().$defaultFn(() => createId()),
  schemeId:       text("scheme_id").notNull().references(() => schemes.id),
  type:           policyTypeEnum("type").notNull(),
  status:         policyStatusEnum("status").notNull().default("Draft"),
  effectiveDate:  date("effective_date"),
  lastReviewedAt: date("last_reviewed_at"),
  nextReviewDue:  date("next_review_due"),
  ragStatus:      ragStatusEnum("rag_status").default("Red"),
  createdAt:      timestamp("created_at").defaultNow().notNull(),
  updatedAt:      timestamp("updated_at").defaultNow().notNull(),
});
Policy types (policyTypeEnum):
investment_policy | risk_management_policy | internal_control_policy |
internal_audit_policy | actuarial_policy | outsourcing_policy |
remuneration_policy | business_continuity_policy | communications_policy |
conflict_of_interest_policy | esc_policy
ColumnTypeNotes
ragStatusenumRed | Amber | Green — overall compliance status
nextReviewDuedateTriggers dashboard alerts when overdue

oraReports

Own Risk Assessment (ORA) reports. Each ORA covers 7 risk categories, each with likelihood, impact, rating, and mitigation fields.
const oraReports = pgTable("ora_reports", {
  id:               text("id").primaryKey().$defaultFn(() => createId()),
  schemeId:         text("scheme_id").notNull().references(() => schemes.id),
  assessmentYear:   integer("assessment_year").notNull(),
  status:           oraStatusEnum("status").notNull().default("Draft"),

  // 7 risk categories — each with likelihood/impact/rating/mitigation
  investmentLikelihood:    text("investment_likelihood"),
  investmentImpact:        text("investment_impact"),
  investmentRating:        text("investment_rating"),
  investmentMitigation:    text("investment_mitigation"),

  operationalLikelihood:   text("operational_likelihood"),
  operationalImpact:       text("operational_impact"),
  operationalRating:       text("operational_rating"),
  operationalMitigation:   text("operational_mitigation"),

  // ... (repeated for: compliance, governance, counterparty, liquidity, esg)

  nextOraDate:  date("next_ora_date"),
  signedOffBy:  text("signed_off_by"), // users.id of the trustee who signed off
  createdAt:    timestamp("created_at").defaultNow().notNull(),
  updatedAt:    timestamp("updated_at").defaultNow().notNull(),
});
The 7 ORA risk categories are: investment, operational, compliance, governance, counterparty, liquidity, ESG.

healthCheckAssessments

Scheme health check assessments with 7 RAG ratings and an AI-generated executive summary.
const healthCheckAssessments = pgTable("health_check_assessments", {
  id:                 text("id").primaryKey().$defaultFn(() => createId()),
  schemeId:           text("scheme_id").notNull().references(() => schemes.id),
  assessmentDate:     date("assessment_date").notNull(),
  status:             assessmentStatusEnum("status").notNull().default("Draft"),

  // 7 RAG ratings
  governanceRag:      ragStatusEnum("governance_rag"),
  investmentRag:      ragStatusEnum("investment_rag"),
  complianceRag:      ragStatusEnum("compliance_rag"),
  communicationsRag:  ragStatusEnum("communications_rag"),
  administrationRag:  ragStatusEnum("administration_rag"),
  riskManagementRag:  ragStatusEnum("risk_management_rag"),
  financialRag:       ragStatusEnum("financial_rag"),

  executiveSummary:   text("executive_summary"),    // AI-generated draft
  remediationActions: jsonb("remediation_actions"), // [{ action, owner, dueDate, status }]
  createdAt:          timestamp("created_at").defaultNow().notNull(),
  updatedAt:          timestamp("updated_at").defaultNow().notNull(),
});

acsWizard

Annual Compliance Statement (ACS) wizard completion tracking. IORP II requires trustees to submit an ACS annually. Dual trustee sign-off fields enforce the two-signatory requirement.
const acsWizard = pgTable("acs_wizard", {
  id:                  text("id").primaryKey().$defaultFn(() => createId()),
  schemeId:            text("scheme_id").notNull().references(() => schemes.id),
  complianceYear:      integer("compliance_year").notNull(),
  status:              acsStatusEnum("status").notNull().default("NotStarted"),
  answers:             jsonb("answers"),             // wizard step answers keyed by question ID
  trustee1Id:          text("trustee_1_id"),         // users.id
  trustee1SignedAt:    timestamp("trustee_1_signed_at"),
  trustee2Id:          text("trustee_2_id"),         // users.id
  trustee2SignedAt:    timestamp("trustee_2_signed_at"),
  submittedToPA:       boolean("submitted_to_pa").default(false),
  paSubmittedAt:       timestamp("pa_submitted_at"),
  createdAt:           timestamp("created_at").defaultNow().notNull(),
  updatedAt:           timestamp("updated_at").defaultNow().notNull(),
});

auditLogs

Append-only audit trail. No application-layer DELETE or UPDATE operations. Required for IORP II regulatory examination.
const auditLogs = pgTable("audit_logs", {
  id:            text("id").primaryKey().$defaultFn(() => createId()),
  actorId:       text("actor_id").notNull(),   // users.id (no FK — preserved after user deletion)
  actorType:     text("actor_type").notNull(), // role at time of action
  action:        text("action").notNull(),     // e.g. "scheme.created", "member.pps_decrypted"
  entityType:    text("entity_type"),          // e.g. "scheme", "member"
  entityId:      text("entity_id"),            // PK of affected entity
  previousState: jsonb("previous_state"),      // state before change (null for creates)
  newState:      jsonb("new_state"),           // state after change (null for deletes)
  ipAddress:     text("ip_address"),
  userAgent:     text("user_agent"),
  timestamp:     timestamp("timestamp").defaultNow().notNull(),
});
actorId is stored as plain text rather than a foreign key. This ensures audit records survive user deletion — a regulatory requirement.

ragCorpus

Regulatory document chunks for pgvector RAG. Contains only public regulatory text — no member or scheme data.
const ragCorpus = pgTable("rag_corpus", {
  id:           text("id").primaryKey().$defaultFn(() => createId()),
  sourceTitle:  text("source_title").notNull(),   // e.g. "S.I. 128/2021 — IORP II"
  chunkText:    text("chunk_text").notNull(),      // the actual regulatory text chunk
  embedding:    vector("embedding", { dimensions: 1536 }), // OpenAI text-embedding-3-small
  createdAt:    timestamp("created_at").defaultNow().notNull(),
});
ColumnTypeNotes
embeddingvector(1536)Requires pgvector extension. HNSW index on this column.
sourceTitletextDisplayed in AI citations

Data Classification Table

FieldTableClassificationProtection Measures
ppsNumberEncryptedmembersPII — Highly SensitiveAES-256-GCM encryption at rest; access audit logged; never sent to AI
dateOfBirthmembersPII — PersonalHTTPS in transit; access role-scoped; audit logged
emailusers, membersPII — PersonalHTTPS in transit; access role-scoped
firstName, lastNamemembersPII — PersonalHTTPS in transit; access role-scoped
phonemembersPII — PersonalHTTPS in transit; access role-scoped
passwordHashusersCredentialbcryptjs hashed; never logged or returned via API
previousState, newStateaudit_logsOperationalAppend-only; SuperAdmin / BrokerAdmin read only
Scheme compliance dataschemes, writtenPolicies, oraReportsOperationalRole-scoped; tenant-isolated
RAG corpusragCorpusRegulatory (public)No classification required — public regulatory text
embeddingragCorpusOperationalNo PII content — derived from public text

FSM Status Enums

Scheme Status FSM

Draft
  └─► PendingRevenueApproval   (awaiting Revenue Commissioners approval)
        └─► Active              (approved and live)
              ├─► UnderReview   (regulatory review in progress)
              │     └─► Active  (review completed — returns to Active)
              └─► WindingUp     (scheme closure initiated)
                    ├─► WoundUp (closure complete)
                    └─► Closed  (administrative close — no wind-up process)

Member Status FSM

PendingEnrolment
  └─► Active                 (enrolled and contributing)
        ├─► Deferred          (left employer, preserved benefits)
        ├─► Retired           (in drawdown)
        ├─► TransferredOut    (benefits transferred to another scheme)
        └─► Deceased          (notified of death — benefits to estate/dependants)

Data Retention

Data TypeRetention PeriodBasis
Member records7 years post-scheme closurePensions Act 1990 (as amended)
Contribution records7 yearsRevenue Commissioners requirement
Audit logsIndefiniteIORP II regulatory examination requirement
Written policiesDuration of scheme + 7 yearsIORP II
ORA reportsDuration of scheme + 7 yearsIORP II
ACS submissionsDuration of scheme + 7 yearsIORP II
AI-generated draftsRetained with document recordDeletable by BrokerAdmin
Password reset tokens60 minutes (auto-expire)Security (GDPR Art. 32)
Session JWTs30 days (Auth.js default)Operational
Soft deletes (via status enums) are used throughout the application. Hard deletion of member records is only permitted after the 7-year retention period expires. A scheduled job (future work) will flag eligible records for review.

Migration Files

Database migrations are managed by Drizzle Kit. Migration files are located in drizzle/migrations/.
MigrationFileDescription
00000000_initial_schema.sqlInitial schema: brokers, employers, providers, schemes, members, users, documents
00010001_pgvector_rag.sqlAdds pgvector extension, rag_corpus table, HNSW vector index
00020002_auth_compliance.sqlAdds Auth.js session/account tables, compliance tables (keyFunctionHolders, writtenPolicies, oraReports, healthCheckAssessments, acsWizard), audit_logs
00030003_add_password_reset_tokens.sqlAdds password_reset_tokens table for forgot-password flow (GDPR Art. 32)

Running Migrations

# Generate migration from schema changes
npx drizzle-kit generate

# Apply pending migrations to the database
npx drizzle-kit migrate

# Inspect current schema state
npx drizzle-kit studio
Never run drizzle-kit push against the production database. Always use drizzle-kit migrate with reviewed migration files. The push command bypasses the migration history and can cause schema drift.