3. Storage model
The storage model is stated in terms of an abstract column type (ColTy)
rather than any engine's SQL. Each port binds the abstract types to its dialect
in one function (ddl::col_sql), and that binding is the only place engine
types appear. Everything above it — the map, the shredder, the reconstructor —
is engine-independent by construction.
Base tables#
- M3.1 Every resource type gets a base table named for the resource
(
r5.patient). Its primary key isid, of typeText. - M3.2 Base-table system columns:
id Text PRIMARY KEY,version_id BigInt NOT NULL(monotonic per resource, starts at 1),last_updated Timestamptz NOT NULL.Resource.metais otherwise stored like any other element. - M3.3 Every scalar (non-repeating, primitive-typed) element of the resource becomes a typed column on the base table.
Child tables#
M3.4 Every repeating element becomes a child table. A child table carries:
rid Text NOT NULL— the root resource id, referencing the base table withON DELETE CASCADEsemantics,ords— the 1-based index at each repeating ancestor crossing from the resource root down to and including this element ({2,1}= second parent instance, first child instance),- primary key
(rid, ords), - typed columns for every scalar element reachable without crossing another repeating element.
The array form (rather than one ordinal column per level) is what lets recursive elements (
Questionnaire.item.item, viacontentReference) share one table at any depth: recursion appears as longerordspaths.M3.4a
ordsis a path of ordinals, not an engine array type. Its storage binding is a dialect concern (X15.5), and three properties of its value domain MUST survive whatever binding a port chooses:- Negative ordinals are meaningful. When two cyclic
contentReferencereferrers share one table, the second pushes negated ordinals so paths stay unambiguous. The domain is-32767..=-1 ∪ 1..=32767;0never occurs. Any unsigned or magnitude-only encoding is wrong. - The empty path is valid and frequent. Resource-level extensions and
element ids shred with
ords = {}into aNOT NULLprimary-key column. Empty MUST remain storable and distinguishable from null and from every other value. - Depth is unbounded for recursive types. Non-recursive tables reach depth 6 at most in R5, but 23 R5 resource types (18 R4, 13 R3) own recursive tables whose depth is data-dependent and uncapped. A fixed-width encoding covers ~99.9% of tables and still fails.
The database MUST NOT be required to order, compare, subscript, or unnest
ords. It enforces uniqueness as part of a primary key and returns the value intact; everything else happens in Rust. That is what makes a text image an adequate binding on engines without an array type.- Negative ordinals are meaningful. When two cyclic
M3.4b Whatever the binding, the stored image of
ordsMUST be the array literal produced byfmt_ords—{1,2},{},{-1,3}— sofmt_ords/parse_ordsare shared unmodified and a database can be compared value-for-value against another port's.M3.5 Non-repeating complex elements (datatypes and backbone elements) flatten into the nearest enclosing table as prefixed columns (
Patient.maritalStatus.text→patient.marital_status_text); only their repeating descendants open tables. Two exceptions force a table for a non-repeating element, with a fixed ordinal of 1: (a) a flattened width that would approach the engine's column limit (generator threshold 150 columns,G2.6— this catches the openvalue[x]choices with ~54 types), and (b) backbone elements targeted cyclically by acontentReference(ImplementationGuide.definition.page). There are no shared "coding" tables; each usage site owns its rows.
Type mapping#
M3.6 FHIR primitive → abstract column type:
FHIR ColTyboolean Boolinteger, unsignedInt, positiveInt Intinteger64 (R5) BigIntdecimal Numeric— original textual precision MUST survive round-tripstring, code, id, markdown, uri, url, canonical, oid, uuid, xhtml, base64Binary Textdate Text+ derivedDatecolumn<name>_sortdateTime, instant Text(verbatim) + derivedTimestamptzcolumn<name>_sortfor ordering/searchtime Text(fractional-second lexical fidelity)Partial dates ("2026", "2026-07") make FHIR temporal values non-representable in native types without loss, hence verbatim text plus a derived sort column, computed by the engine at write time (partial values sort at their period start; offset-less dateTimes sort as UTC).
M3.6a A port's binding of
NumericMUST NOT be a fixed-scale decimal or a binary float. Both lose the lexical formM3.6requires:DECIMAL(65,30)returns1.50as1.500000000000000000000000000000, andREALcannot hold1.50distinctly from1.5at all.Numericbinds to a text type, and range search is served by a derived sort column, not by the storage column.M3.6b A port's binding of
TextCMUST be a collation that is binary and NO PAD. Binary, becauseTextCbacks:exactmatching and key identity, and a case- or accent-insensitive default collation would silently turn exact equality into fuzzy equality. NO PAD, because under a PAD SPACE collation'Smith' = 'Smith 'is true, which widens:exactand weakens primary keys.M3.6c A port's binding of
JsonbMUST NOT be a JSON-typed column on any engine that re-normalizes JSON on storage or retrieval. The history hash chain commits to bytes canonicalized in Rust (X15.2); a column that rewrites key order or number spelling would make the bytes read back differ from the bytes signed, and every chain would fail verification. Bind it to a text type.M3.7 Elements bound
requiredto a FHIR value set get aCHECK (col IN (…))constraint generated from the code system; other binding strengths are unconstrained columns. Where an engine does not enforceCHECK, the port MUST say so in its annex rather than emit a constraint that is accepted and ignored.
Choice elements#
- M3.8 A choice element
value[x]becomes one column (or child table, for complex types) per allowed type —value_boolean,value_quantity_…— plus a generatedCHECKthat at most one alternative is populated.
References#
- M3.9 A Reference element stores:
<name>_ref_type Text,<name>_ref_id Text(parsed from relative literal references),<name>_ref_url Text(absolute/other references, verbatim), plus columns fordisplayand expandedidentifier. Parsing MUST be reversible: the originalreferencestring reconstructs exactly. - M3.10 Referential integrity across resources is NOT enforced by foreign keys (FHIR permits dangling references). A port MAY offer an advisory integrity report; it MUST NOT reject writes for dangling refs.
Extensions and primitive extensions#
- M3.11 Extensions are stored relationally as typed leaf rows in one
generated table per resource type:
<resource>_ext(rid, path, ords, modifier, ext_ord, url, leaf, v_kind, v_text, v_num, v_bool), PK(rid, path, ords, modifier, ext_ord, leaf).path/ordslocate the attach point (dotted JSON-name path,""for the resource itself; ordinals at each repeating crossing).ext_ordis the 1-based index in the extension array (modifierdistinguishes modifierExtension);urlis the top-level extension url, denormalized for querying.leafaddresses one scalar inside the extension's content as a dotted path whose all-digit segments are 0-based array indexes (valueCodeableConcept.coding.0.code); nested extensions are ordinary leaves (extension.0.valueString).v_kind∈ s/n/b/z tags the JSON scalar kind; numbers keep their lexical form inv_textand a queryable numeric inv_num. This one uniform encoding covers every extension value type — including arbitrarily nested complex values — with no JSON and no per-type tables. - M3.12 Primitive extensions (
_birthDateetc.) reuseM3.11with the primitive's path (and the entry index, for repeating primitives); element ids ride the same table asext_ord = 0, leaf = 'id'rows. Reconstruction MUST re-emit the_fieldform exactly, including null padding in parallel arrays. - M3.13
Resource.containedresources are stored in a per-resource table<resource>_contained(rid, ord, resource Jsonb). Elements typedResource(Bundle.entry.resource,Parameters.parameter.resource) becomeJsonbcolumns the same way. These are the sanctionedJsonbusages besides history: such values are anonymous whole resources of unknowable type, so normalizing them buys nothing. Subject toM3.6c. - M3.14 The FHIR type graph contains one true datatype cycle:
Reference.identifier: IdentifierandIdentifier.assigner: Reference. Static expansion cuts a cycle at the element that would re-enter an in-expansion type (….identifier.assigner), and stores anything below the cut as leaf rows (M3.11encoding, minus extension columns) in a per-resource<resource>_deep(rid, path, ords, leaf, v_kind, v_text, v_num, v_bool)table — lossless, relational, and vanishingly rare in real data.
Audit columns#
M3.15 Every
<resource>_historytable carries, besidesH5.1's columns, an audit envelope:actor Text(the authenticated principal responsible for the change, or'unauthenticated'),actor_source Text(how the principal was established, e.g.header:X-Fhir-Principal),client Text(source address as observed),request_id Text(the correlation id), andreason Text(a caller-supplied purpose of use, when given). These columns are written by the same statement that appends the history row, inside the same transaction as the data change — an audit record that can be lost independently of the change it describes is not an audit record.M3.16 History is tamper-evident. Each history row carries
prev_hashand, for each hash algorithm ofM3.16a, a digest column over the row's canonical serialization (X15.2) concatenated with the previous version's digest for the same algorithm and resource id (the first version chains from that algorithm's length in zero bytes). Chains are per resource id, so appends stay concurrent. Theverify-auditoperation MUST recompute every chain in every algorithm and report the first break in each.M3.16a The chain MUST be computed under at least two hash algorithms of different design families, and MUST include SHA-256 (Merkle–Damgård, FIPS 180-4) and SHA3-256 (sponge, FIPS 202).
The point is family diversity, not digest length. MD5 and SHA-1 both fell to the same line of cryptanalysis, and both are Merkle–Damgård; two digests drawn from one family would buy far less than their bit counts suggest. A clinical record may be retained for decades — longer than anyone can confidently promise a single hash function will stand — so the chain should not rest on one construction.
Both named algorithms are FIPS-approved, so a strict regime is satisfied by either. Where one must be named going forward, name SHA-3: NIST published FIPS 202 precisely so that an approved hash would exist that is not a SHA-2 variant.
Verification MUST recompute every configured algorithm and report each separately rather than reducing them to a single verdict, so that a reader can rely on whichever algorithm their regime recognises.
BLAKE3 (ARX tree) would add a third family and is deliberately not required: it is not FIPS-approved and MUST NOT be treated as the control of record where that matters.
M3.16b Digests MUST be computed by the application, never by the database, and a deployment SHOULD additionally keep a keyed tag.
The digests are unkeyed over a published pre-image, so anyone who can write to the database can also produce a correct digest for what they wrote. Computing them in SQL puts the means of forgery in the same place as the data, and forecloses the only real fix: a MAC whose key the database never holds. A key stored where the attacker already has write access protects nothing.
Computing in the application also has a portability consequence that decided the matter independently: a digest over whatever one engine's JSON type happened to produce could never be verified by another engine, so the chain format would not survive a port. See
X15.2.What the unkeyed chain buys, stated honestly so nobody over-claims it: it detects careless or unaware modification — a migration, a stray
UPDATE, a row restored from the wrong backup — and it supports an external witness, because a chain head recorded off-box makes truncation and wholesale rewriting detectable even against an attacker who can recompute digests. It does not, alone, stop an informed attacker with write access.The keyed tag is
HMAC-SHA-256(FIPS 198-1 over 180-4, so the FIPS story stays clean) over the same pre-image, stored as<key-id>:<hex>:- The key MUST NOT be written to the database, logged, or sent in a query, and
MUST be at least 32 bytes: a placeholder like
changemereaching production would yield tags an attacker could reproduce by guessing. - A file SHOULD be the source, and MUST be supported. An environment
variable is visible in
/proc/<pid>/environ, survives into crash dumps, is reported by orchestrators, and is inherited by every child process; a file is none of those, and is what Kubernetes secrets and systemd credentials already produce. A key file readable by group or other MUST be refused, not warned about — a warning is read once at startup while the file stays readable for the life of the deployment. - Key material SHOULD be zeroed when dropped. Freed memory is not scrubbed, so a key otherwise lingers in the heap and is recoverable from a core dump.
- A retired key that cannot be read MUST be an error, never a silent omission. Dropping one turns its rows unverifiable, and an operator who did not intend that should learn it at startup rather than from an audit.
- Key configuration MUST apply to every operation that reads history, not only to a service. Verification without the key reports every keyed row as unverifiable, which is correct and useless.
- The key id MUST travel with the tag. Without it, rotating a key would invalidate every historical row at once — indistinguishable from mass tampering. Retired keys MUST stay loadable, so rotation is additive rather than a flag day.
- Verification MUST be constant-time. A timing oracle would let an attacker with write access recover a valid tag byte by byte without ever holding the key.
- Only a tag mismatch is a finding. A missing tag, a tag naming a key this process does not hold, and a malformed tag MUST each be reported as what they are and MUST NOT be reported as tampering. Reporting a key-distribution problem as a forgery would burn an incident response.
- The key MUST NOT be written to the database, logged, or sent in a query, and
MUST be at least 32 bytes: a placeholder like
M3.16c A port MUST be able to emit a chain checkpoint: a single value covering every chain head in the namespace — resource type, id, latest version, and its digests — such that the value changes if any chain gains a version, loses one, or has its head altered. The
chain-witnessoperation prints it, and it MUST be keyed when a key is configured, so that whoever holds only the data cannot recompute a matching value.This is what the per-row tag cannot do. A MAC proves a row was not rewritten; it says nothing about a row that is gone, and a chain missing its most recent version verifies perfectly, because nothing left behind refers to what was removed. Only a value recorded outside the database closes that gap.
Checkpoints are also emitted as INFO log lines on an
audit_checkpointtarget, so a deployment already shipping logs has a witness for free. The dedicated target is what makes this practical: an operator can route and retainaudit_checkpointon its own schedule without keeping every other line, and the checkpoint carries no PHI — only counts and digests — so it may be retained far longer than ordinary application logs, and in places patient data must not go.A checkpoint MUST be emitted at startup and after an erasure (
M3.18), and SHOULD be emitted on an interval a deployment configures. Erasure is singled out because it is the one sanctioned deletion: a checkpoint taken immediately after it separates a recorded, intentional removal from the unrecorded kind.The value is only a witness if it lands somewhere the database cannot reach. Logs shipped off-host qualify; logs written to a table in the same database, or to a disk the same compromised account can rewrite, do not. A port cannot enforce this and MUST NOT imply it has: the guarantee is a property of the deployment's log path, and the documentation MUST say so.
M3.16d A key that can no longer be trusted MUST be retirable without losing the history it signed. The
chain-resignoperation counter-signs every history row under the current key.This is only for a suspected compromise. Ordinary rotation is additive (
M3.16b): keep the old key loadable and nothing needs re-signing.- Re-signing MUST verify every chain first and MUST abort entirely on any finding. Re-signing rows that do not currently verify would give forged history the new key's authority, which turns the recovery procedure into the attack. It MUST be one transaction, so a partial re-signing cannot be left behind.
- Counter-signatures MUST be appended, never written over the original tag. History is append-only, and re-signing in place would be the application doing what the append-only guard exists to prevent. The original tag is also evidence: replacing it destroys the record of what the retired key attested and leaves no way to tell a legitimate re-signing from a forged one.
- A counter-signature MAY stand in for an original tag only where that tag cannot be checked — absent, or naming a key no longer held. A row whose own tag mismatches MUST remain a finding whatever later vouched for it.
- Each counter-signature MUST record who ran it, when, and why.
M3.16e A port MUST be able to generate a signing key (
chain-key-new), creating the file readable only by its owner from the moment it exists.The shell equivalent,
openssl rand -hex 32 > key, applies the process umask — commonly022, giving a fileM3.16brequires be refused — and leaves the secret world-readable in the window beforechmod. Generation MUST refuse to overwrite: silently replacing a signing key would orphan every row it had signed. The key MUST NOT be printed, since a secret echoed to a terminal lives on in scrollback and shell history.On an existing install,
init --upgradeadds the new digest columns but MUST NOT backfill them. The rows are recoverable and the digests could be computed, but a chain assembled after the fact attests only that the rows look consistent now — which is exactly what an attacker who rewrote them would also produce.verify-auditMUST therefore report the new chain as beginning where its first digest appears, the same treatment rows predating the audit columns already receive. Manufacturing evidence is worse than admitting its absence.M3.17 History is append-only in the database, not merely by convention.
initMUST emit aBEFORE UPDATE OR DELETEtrigger (or the engine's nearest equivalent) on every history table that raises an exception, and the documentation MUST describe theREVOKE UPDATE, DELETEgrants a deployment applies to the application role. Escaping this is then a deliberate DBA act, never an application bug.Where an engine cannot express the trigger, the port MUST say so in its annex and MUST NOT let the grant documentation stand in for the missing control — a revocable grant and an in-database guard fail differently, and the whole point is defence that survives an application bug.
M3.18 Erasure (GDPR Art. 17) is the one sanctioned exception, and it is explicit:
purge <Type> <id> --reason <text>removes the resource's history rows and replaces them with a single tombstone row recording who purged what, when, why, and the chain it terminated — so an erased record leaves a verifiable hole rather than a silent one. Purge requires an explicit erasure acknowledgement and is logged at warn level.
Engine bindings for ColTy#
Non-normative summary of each port's ddl::col_sql; the annexes govern.
ColTy |
PostgreSQL | SQLite | MySQL | MariaDB | SQL Server | Oracle |
|---|---|---|---|---|---|---|
Bool |
boolean |
INTEGER |
TINYINT(1) |
TINYINT(1) |
BIT |
NUMBER(1) |
Int |
integer |
INTEGER |
INT |
INT |
INT |
NUMBER(10) |
BigInt |
bigint |
INTEGER |
BIGINT |
BIGINT |
BIGINT |
NUMBER(19) |
Numeric |
numeric |
TEXT |
TEXT |
TEXT |
NVARCHAR(MAX) |
VARCHAR2(64 CHAR) |
Text |
text |
TEXT |
TEXT |
TEXT |
NVARCHAR(MAX) |
CLOB |
TextC |
text COLLATE "C" |
TEXT COLLATE BINARY |
TEXT COLLATE utf8mb4_0900_bin |
TEXT COLLATE utf8mb4_nopad_bin |
NVARCHAR(450) COLLATE Latin1_General_100_BIN2 |
VARCHAR2(450 CHAR) |
Date |
date |
TEXT (ISO-8601) |
DATE |
DATE |
DATE |
DATE |
Timestamptz |
timestamptz |
TEXT (ISO-8601 UTC) |
DATETIME(6) |
DATETIME(6) |
DATETIME2(6) |
TIMESTAMP(6) |
Jsonb |
jsonb |
TEXT |
LONGTEXT |
LONGTEXT |
NVARCHAR(MAX) |
CLOB |
The Oracle column was all ⚠ until 2026-08-03: fhir-oracle's col_sql was
a verbatim copy of MySQL's, the port was Scaffold level, and the DDL it
generated was not an Oracle schema. That was F-08, now fixed — every
binding above has been executed against a live Oracle 26ai Free and read back
from user_tab_columns (M14.23d), and the port is Store level (F-68).
An earlier revision of this section still described the pre-F-08 state in the
present tense (F-77). Numeric binds VARCHAR2, not NUMBER, because
NUMBER normalizes 1.50 to 1.5 (M3.6a, M14.7); Text binds CLOB,
which can be neither indexed nor =-compared — the case the U1–U10
adjunct channel exists to answer.
PostgreSQL's jsonb binding is the one row that violates M3.6c, and the
consequence was live rather than theoretical: fhir-postgresql used to
derive the chain pre-image with (($1::text)::jsonb)::text, so its canonical
bytes were whatever jsonb produced when it reordered keys and rewrote
number spellings.
M3.16b had moved the digest into Rust there; X15.2 moves the canonical
form too. canon.rs is now present in all six map/src directories and
fhir-postgresql derives its pre-image from it, so a PostgreSQL chain can be
verified by any port holding that file — proven by chain_portability.rs, which
recomputes one from the exported rows alone. Closing this was a chain format
change: a database written before it must be reloaded. Tracked as
audit.md F-07, fixed.
Part of the fhir-databases specification.