Skip to main content

10 — Data Models

Status: populated Last updated: 2026-04-18 Companion: 01 enterprise-architecture · 02 DDD contexts · 03 platform-services · 04 event-driven-architecture · 13 security-compliance-tenancy · 16 offline-first · 17 technology-stack

This document is the platform-level data-model index. Per-service schemas live in each service's DATA_MODEL.md. The canonical clinical model is FHIR R4 (see standards/FHIR_FIRST_STANDARD.md); local tables are operational indexes, workflow state, adapter payloads, or non-clinical config.

1. Principles

#PrincipleImplication
P1FHIR R4 is the system of record for clinical meaningLocal tables are operational; cross-service clinical joins go through FHIR or published events.
P2Every persisted row carries tenant_id, created_at, updated_at, versionEnforced by base migration and Drizzle ORM mixin.
P3No shared databasesOne logical DB per service; cross-service reads are events or REST.
P4Row-Level Security (RLS) enforces tenant isolationFallback when an app-level bug slips through.
P5Opaque IDs at external boundariesUUIDs or prefixed ULIDs; never sequential DB IDs exposed.
P6Immutable clinical artifactsCorrections create new versions; provenance preserved.
P7Soft-delete where retention policy requiresHard-delete only with explicit policy (DSAR, GDPR).
P8Events carry minimal identifiersConsumers fetch detail if needed; payload stays small.
P9All timestamps ISO-8601 UTCLocale display layered at UI; storage never local time.
P10PII / PHI classified explicitlyRetention, residency, and telemetry redaction follow the class.

2. Common primitives (TypeScript shape)

// Shared across every service via @ghasi/shared-types
type Branded<T, B extends string> = T & { readonly __brand: B };
type ISODate = string; // RFC 3339, UTC
type ISOInstant = string; // RFC 3339 with timezone
type ULID = string;
type UUID = string;
type SHA256 = string;
type JWS = string;
type Locale = string; // BCP-47 — e.g., 'ps-AF', 'fa-AF', 'en', 'ar-AE'
type ISO4217 = string; // currency code
type FHIRId = string; // opaque FHIR resource id
type FHIRReference = string; // e.g., "Patient/abc-123"

type TenantId = Branded<UUID, 'TenantId'>;
type FacilityId = Branded<UUID, 'FacilityId'>;
type ActorId = Branded<UUID, 'ActorId'>;
type UserId = Branded<UUID, 'UserId'>;

interface Money { amountMicro: number; currency: ISO4217; }

interface Identifier {
system: string; // e.g., 'urn:oid:1.2.4.0.13.1.4.1.1002.1.1.1.1.1' (national MPI)
value: string;
assigner?: FHIRReference;
period?: { start?: ISOInstant; end?: ISOInstant };
}

interface AuditEnvelope {
actorIdHash: SHA256;
tenantId: TenantId;
correlationId: UUID;
occurredAt: ISOInstant;
}

interface AIProvenance {
model: string;
modelVersion?: string;
promptTemplateId?: string;
promptHash?: SHA256;
decisionId?: UUID;
local: boolean;
generatedAt: ISOInstant;
reviewedBy?: UserId;
reviewedAt?: ISOInstant;
citations?: FHIRReference[];
}

3. Platform ERD (high level)

4. FHIR resource ownership matrix

Resource ownership is normative — only the owning service writes its FHIR resources; other services read via events or the interop-service FHIR gateway.

FHIR ResourceOwner (service)Notes
Patientregistration-serviceMPI master; interop flows via NID + DOB + phone match.
Practitioner, PractitionerRoleprovider-directory-serviceCredentials + roles.
Organization, Locationfacility-serviceHierarchy DAG.
Endpointfacility-serviceHL7 / FHIR endpoints per facility.
Encounterpatient-chart-serviceEncounter aggregate.
AllergyIntolerancepatient-chart-service(merged from legacy allergies module)
Conditionpatient-chart-service(merged from legacy problem-list)
Observationpatient-chart-serviceVitals merged; lab Observation owned by laboratory-service.
Observation (lab)laboratory-serviceLab-class; released to chart post-verification.
DiagnosticReport (lab)laboratory-service
DiagnosticReport (rad)radiology-service
ImagingStudyradiology-service
ClinicalImpression, Procedurepatient-chart-service
ServiceRequestorders-serviceCPOE source.
MedicationRequestmedication-servicePrescribing intent.
MedicationDispensemedication-service (pharmacy)Fulfillment.
MedicationStatement, MedicationAdministrationmedication-service
Immunizationimmunizations-serviceAppend-only in field contexts.
CarePlan, Goal, CareTeamcare-plan-service
Communication, CommunicationRequestcommunication-serviceSecure msg + notifications.
DocumentReference, Binarydocument-servicePDF / scanned docs.
Coverage, EligibilityResponseclaims-serviceCoverage / insurance.
Claim, ExplanationOfBenefit, Remittanceclaims-serviceRCM.
ChargeItem, Invoice, Accountbilling-service
Consentidentity-service (+ audit)Consent authority.
AuditEventaudit-serviceTamper-evident.
Appointment, Slot, Schedulescheduling-service
QuestionnaireResponsepatient-portal-servicePortal-driven intake.
Subscriptioninterop-serviceFHIR subscription routing.
CodeSystem, ValueSet, ConceptMapterminology-service

5. Consolidated ID prefix registry

All ULID / UUID IDs carry a prefix for logs and URLs (opaque UUIDs still at DB layer). Registry matches 07 epics §2.

DomainPrefixExample
Tenanttnt_tnt_01HW7G3XV8H5Z
Facilityfac_fac_01HW7G4Q5F2Z3
Providerprv_prv_...
Patientpat_pat_...
Encounterenc_
Order (ServiceRequest)ord_
MedicationRequestrx_
MedicationDispensedisp_
Appointmentapt_
Immunizationimm_
CarePlancpl_
Observationobs_
Conditioncond_
AllergyIntolerancealg_
DiagnosticReportdrpt_
ImagingStudyimg_
Documentdoc_
Coveragecov_
Claimclm_
Invoiceinv_
AuditEventaud_
Consentcns_
Communicationcom_

Prefix uniqueness is enforced by standards/NAMING.md CI lint.

6. Multi-tenancy model

6.1 Row-level tenancy (default)

Every service DB schema includes tenant_id UUID NOT NULL on every tenant-scoped table. PostgreSQL RLS policies enforce isolation:

ALTER TABLE patient ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON patient
FOR ALL TO application_role
USING (tenant_id = current_setting('app.tenant_id')::uuid);

tenant_id is set from the JWT claim in a PostgreSQL session variable at the beginning of each request via a NestJS interceptor. Cross-tenant queries are denied by the database, not only by application logic.

6.2 Schema-per-tenant promotion path

Largest tenants (MoPH national registry, central referral hospital) can be promoted to schema-per-tenant without API change. The Drizzle schema discriminator reads from app.tenant_schema session variable. Promotion involves:

  1. Creating tenant-specific schema.
  2. Migrating rows.
  3. Re-pointing search paths.
  4. Keeping RLS as defense-in-depth.

See 15 tenancy-decision-matrix for the per-service decision.

6.3 Cross-tenant exceptions

  • Platform-admin super-admin reads: always via /internal/admin/** with elevated scope + synchronous audit write.
  • National registries (MoPH HMIS, national immunization registry): aggregates only; no PHI crosses tenant boundaries except through interop-service with explicit consent policy.

7. Base-table template (Drizzle, PostgreSQL 16)

Every service inherits:

import { pgTable, uuid, timestamp, integer, index } from 'drizzle-orm/pg-core';

export const baseColumns = {
id: uuid('id').defaultRandom().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
version: integer('version').notNull().default(1), // optimistic locking
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull(),
updatedBy: uuid('updated_by').notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }), // soft-delete (if allowed)
};

export const exampleTable = pgTable('example', {
...baseColumns,
payloadJson: jsonb('payload_json').notNull(), // FHIR R4 representation
// ... indexed columns
}, (t) => ({
tenantIdx: index('example_tenant_idx').on(t.tenantId),
tenantCreated: index('example_tenant_created_idx').on(t.tenantId, t.createdAt),
}));

Rules:

  • payload_json stores the FHIR resource; indexed columns denormalize for query performance.
  • version increments on every update; WHERE id = $1 AND version = $2 pattern prevents lost updates.
  • Migration-only; synchronize: true is forbidden.
  • Every index includes tenant_id as first column unless the query is tenant-global admin.

8. Data classification

ClassExamplesRetention defaultResidencyTelemetry treatment
PHI (Protected Health Information)Clinical notes, observations, med requests, imaging, lab results10 y (active) + 15 y (cold) for clinical; 25 y for minorsAfghanistan MoPH data residency; cross-border only via interop-service with consentNever in logs/metrics/traces unredacted
PII (Personally Identifiable Information)Name, NID, phone, address, DOB7 y after last contactSame as PHIHashed in telemetry with per-tenant salt
CredentialsPasswords, tokens, keysRotated per policy; never loggedDeny-list in all pipelines
OperationalAudit events, access logs7 y (WORM)Same residency as PHIAudit channel only
AnalyticsDe-identified indicatorsPer HMIS policyResidency-respectingTenant-salted hashes only
PublicFacility directory, provider directory (public subset), public health dashboardsIndefiniteNoneStandard telemetry

9. Retention matrix

DataHot (online)WarmColdMax
PHI (adult)5 y5 y15 y cold25 y
PHI (minor)until age 18 + 10 y10 y cold15 y cold25+ y
Audit (general)30 d1 y5 y (WORM)7 y
Audit (break-glass)1 y3 y6 y (WORM)10 y
Telemetry logs14 d90 d365 d395 d
Telemetry traces7 d90 d (error/PHI-adjacent sampled)90 d
Metrics30 d13 mo13 mo
AI transcripts30 d180 d (tenant config)365 d
Safety-flagged AI180 d2 y2 y
Financial (claims, invoices)1 y6 y5 y10+ y (regulatory)

10. Residency policy

Tenant home regionData pinned
af-kbl-1 (Kabul)All PHI, PII, audit, telemetry, AI transcripts
af-mzs-1 (Mazar)Same as Kabul
ae-duh-1 (Dubai, future)Same; cross-border replication only with DPA addendum
Cross-region DROff by default; enabled only for enterprise tier with MoPH approval

Multi-country readiness: region is a tenant attribute, not a code-path fork. New regions are a deployment concern.

11. Event payload conventions

Per 04 event-driven-architecture:

{
"specversion": "1.0",
"id": "uuid",
"type": "ghasi.{domain}.{entity}.{action}",
"source": "ghasi/{service-name}",
"subject": "{resource-kind}/{id}",
"time": "RFC 3339",
"tenantid": "uuid",
"actorid": "uuid-or-hash",
"correlationid": "uuid",
"datacontenttype": "application/json",
"data": {
"id": "resource id",
"version": 5,
"changedFields": ["..."]
}
}

Rules:

  • data carries only identifiers + minimum context. Consumers fetch the full resource as needed.
  • Versioned via ghasi.{domain}.{entity}.{action}.v{n} when breaking.
  • Schema registry enforces backward compatibility.

12. Sync / offline data conventions

Per 16 offline-first:

  • Local stores: Realm (provider mobile), SQLite / better-sqlite3 (desktop registration), IndexedDB/Dexie (web).
  • Mutation identity: every offline write carries clientMutationId; server idempotency key = (tenantId, clientMutationId).
  • Conflict policy per aggregate:
AggregatePolicy
Patient (registration)server-authoritative (MPI)
Encounterserver-authoritative
Observation (vitals)append-only
Immunization (field)append-only
ClinicalNoteLWW + diff (manual on concurrent edit)
MedicationRequestserver-authoritative (prescribing authority)
ConsentLWW + diff
CarePlanLWW + diff
Allergyserver-authoritative (safety-critical)
Documentappend-only (new revision)

Conflict events emit audit.sync.conflict.resolved with resolver identity and resolution type.

13. Integration with FHIR gateway / interop

  • interop-service hosts the consolidated FHIR R4 surface at /fhir/R4/*.
  • Resource ownership map (§4) drives delegation: gateway proxies GET /fhir/R4/Patient/{id} to registration-service; GET /fhir/R4/MedicationRequest/{id} to medication-service; etc.
  • HL7 v2 MLLP listeners preserve message control IDs for deduplication; parsed into canonical FHIR before delegation.
  • FHIR Subscription is centrally managed; routed notifications are persisted for replay.

14. Cross-service relationships (snapshot)

15. Data lifecycle operations

OperationOwnerNotes
Provisioning (new tenant)tenant-service + platform-adminRuns bootstrap migrations; seeds defaults.
MigrationPer serviceDrizzle migrations; forward-compat + backfill.
Bulk export (DSAR, tenant migration)patient-portal + per-service exportersZipped FHIR Bundle + audit report.
Erasure (GDPR / DSAR)DPO workflowTombstones rows; audit event; propagates to analytics.
Archive (cold)SRE + data platformWarm → cold tier policies per retention matrix.
Restore (incident / recovery)SRERPO ≤ 15 min clinical, ≤ 1 h operational; RTO ≤ 1 h.

16. Governance

  • Data model changes require an RFC (rfcs/data-model/…) if they touch FHIR canonical shape or cross-service events.
  • Breaking event schema = type.v2 + 2-release deprecation window on v1.
  • Every migration passes: forward apply → backfill → backward-compat smoke → load smoke.
  • Per-service DATA_MODEL.md is the source of truth for service internals; this doc governs the platform invariants.

17. Open questions

  • Final decision on NID encryption-at-rest key model — per-tenant KMS key vs. platform key with per-tenant salt.
  • Whether Observation for vitals should migrate fully into laboratory-service (unified Observation store) or remain in patient-chart-service. Current plan: patient-chart-service owns vitals; lab-class Observations remain in laboratory-service.
  • Long-term pgvector strategy for AI retrieval over clinical documents — per-tenant schema vs. single index with row-level filter.