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
| # | Principle | Implication |
|---|---|---|
| P1 | FHIR R4 is the system of record for clinical meaning | Local tables are operational; cross-service clinical joins go through FHIR or published events. |
| P2 | Every persisted row carries tenant_id, created_at, updated_at, version | Enforced by base migration and Drizzle ORM mixin. |
| P3 | No shared databases | One logical DB per service; cross-service reads are events or REST. |
| P4 | Row-Level Security (RLS) enforces tenant isolation | Fallback when an app-level bug slips through. |
| P5 | Opaque IDs at external boundaries | UUIDs or prefixed ULIDs; never sequential DB IDs exposed. |
| P6 | Immutable clinical artifacts | Corrections create new versions; provenance preserved. |
| P7 | Soft-delete where retention policy requires | Hard-delete only with explicit policy (DSAR, GDPR). |
| P8 | Events carry minimal identifiers | Consumers fetch detail if needed; payload stays small. |
| P9 | All timestamps ISO-8601 UTC | Locale display layered at UI; storage never local time. |
| P10 | PII / PHI classified explicitly | Retention, 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 Resource | Owner (service) | Notes |
|---|---|---|
Patient | registration-service | MPI master; interop flows via NID + DOB + phone match. |
Practitioner, PractitionerRole | provider-directory-service | Credentials + roles. |
Organization, Location | facility-service | Hierarchy DAG. |
Endpoint | facility-service | HL7 / FHIR endpoints per facility. |
Encounter | patient-chart-service | Encounter aggregate. |
AllergyIntolerance | patient-chart-service | (merged from legacy allergies module) |
Condition | patient-chart-service | (merged from legacy problem-list) |
Observation | patient-chart-service | Vitals merged; lab Observation owned by laboratory-service. |
Observation (lab) | laboratory-service | Lab-class; released to chart post-verification. |
DiagnosticReport (lab) | laboratory-service | — |
DiagnosticReport (rad) | radiology-service | — |
ImagingStudy | radiology-service | — |
ClinicalImpression, Procedure | patient-chart-service | — |
ServiceRequest | orders-service | CPOE source. |
MedicationRequest | medication-service | Prescribing intent. |
MedicationDispense | medication-service (pharmacy) | Fulfillment. |
MedicationStatement, MedicationAdministration | medication-service | — |
Immunization | immunizations-service | Append-only in field contexts. |
CarePlan, Goal, CareTeam | care-plan-service | — |
Communication, CommunicationRequest | communication-service | Secure msg + notifications. |
DocumentReference, Binary | document-service | PDF / scanned docs. |
Coverage, EligibilityResponse | claims-service | Coverage / insurance. |
Claim, ExplanationOfBenefit, Remittance | claims-service | RCM. |
ChargeItem, Invoice, Account | billing-service | — |
Consent | identity-service (+ audit) | Consent authority. |
AuditEvent | audit-service | Tamper-evident. |
Appointment, Slot, Schedule | scheduling-service | — |
QuestionnaireResponse | patient-portal-service | Portal-driven intake. |
Subscription | interop-service | FHIR subscription routing. |
CodeSystem, ValueSet, ConceptMap | terminology-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.
| Domain | Prefix | Example |
|---|---|---|
| Tenant | tnt_ | tnt_01HW7G3XV8H5Z |
| Facility | fac_ | fac_01HW7G4Q5F2Z3 |
| Provider | prv_ | prv_... |
| Patient | pat_ | pat_... |
| Encounter | enc_ | — |
| Order (ServiceRequest) | ord_ | — |
| MedicationRequest | rx_ | — |
| MedicationDispense | disp_ | — |
| Appointment | apt_ | — |
| Immunization | imm_ | — |
| CarePlan | cpl_ | — |
| Observation | obs_ | — |
| Condition | cond_ | — |
| AllergyIntolerance | alg_ | — |
| DiagnosticReport | drpt_ | — |
| ImagingStudy | img_ | — |
| Document | doc_ | — |
| Coverage | cov_ | — |
| Claim | clm_ | — |
| Invoice | inv_ | — |
| AuditEvent | aud_ | — |
| Consent | cns_ | — |
| Communication | com_ | — |
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:
- Creating tenant-specific schema.
- Migrating rows.
- Re-pointing search paths.
- 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_jsonstores the FHIR resource; indexed columns denormalize for query performance.versionincrements on every update;WHERE id = $1 AND version = $2pattern prevents lost updates.- Migration-only;
synchronize: trueis forbidden. - Every index includes
tenant_idas first column unless the query is tenant-global admin.
8. Data classification
| Class | Examples | Retention default | Residency | Telemetry treatment |
|---|---|---|---|---|
| PHI (Protected Health Information) | Clinical notes, observations, med requests, imaging, lab results | 10 y (active) + 15 y (cold) for clinical; 25 y for minors | Afghanistan MoPH data residency; cross-border only via interop-service with consent | Never in logs/metrics/traces unredacted |
| PII (Personally Identifiable Information) | Name, NID, phone, address, DOB | 7 y after last contact | Same as PHI | Hashed in telemetry with per-tenant salt |
| Credentials | Passwords, tokens, keys | Rotated per policy; never logged | — | Deny-list in all pipelines |
| Operational | Audit events, access logs | 7 y (WORM) | Same residency as PHI | Audit channel only |
| Analytics | De-identified indicators | Per HMIS policy | Residency-respecting | Tenant-salted hashes only |
| Public | Facility directory, provider directory (public subset), public health dashboards | Indefinite | None | Standard telemetry |
9. Retention matrix
| Data | Hot (online) | Warm | Cold | Max |
|---|---|---|---|---|
| PHI (adult) | 5 y | 5 y | 15 y cold | 25 y |
| PHI (minor) | until age 18 + 10 y | 10 y cold | 15 y cold | 25+ y |
| Audit (general) | 30 d | 1 y | 5 y (WORM) | 7 y |
| Audit (break-glass) | 1 y | 3 y | 6 y (WORM) | 10 y |
| Telemetry logs | 14 d | 90 d | 365 d | 395 d |
| Telemetry traces | 7 d | 90 d (error/PHI-adjacent sampled) | — | 90 d |
| Metrics | 30 d | 13 mo | — | 13 mo |
| AI transcripts | 30 d | 180 d (tenant config) | — | 365 d |
| Safety-flagged AI | 180 d | 2 y | — | 2 y |
| Financial (claims, invoices) | 1 y | 6 y | 5 y | 10+ y (regulatory) |
10. Residency policy
| Tenant home region | Data 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 DR | Off 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:
datacarries 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:
| Aggregate | Policy |
|---|---|
| Patient (registration) | server-authoritative (MPI) |
| Encounter | server-authoritative |
| Observation (vitals) | append-only |
| Immunization (field) | append-only |
| ClinicalNote | LWW + diff (manual on concurrent edit) |
| MedicationRequest | server-authoritative (prescribing authority) |
| Consent | LWW + diff |
| CarePlan | LWW + diff |
| Allergy | server-authoritative (safety-critical) |
| Document | append-only (new revision) |
Conflict events emit audit.sync.conflict.resolved with resolver identity and resolution type.
13. Integration with FHIR gateway / interop
interop-servicehosts 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
Subscriptionis centrally managed; routed notifications are persisted for replay.
14. Cross-service relationships (snapshot)
15. Data lifecycle operations
| Operation | Owner | Notes |
|---|---|---|
| Provisioning (new tenant) | tenant-service + platform-admin | Runs bootstrap migrations; seeds defaults. |
| Migration | Per service | Drizzle migrations; forward-compat + backfill. |
| Bulk export (DSAR, tenant migration) | patient-portal + per-service exporters | Zipped FHIR Bundle + audit report. |
| Erasure (GDPR / DSAR) | DPO workflow | Tombstones rows; audit event; propagates to analytics. |
| Archive (cold) | SRE + data platform | Warm → cold tier policies per retention matrix. |
| Restore (incident / recovery) | SRE | RPO ≤ 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 onv1. - Every migration passes: forward apply → backfill → backward-compat smoke → load smoke.
- Per-service
DATA_MODEL.mdis 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
Observationfor 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.