Repository navigation
4.3.1
Added
- A fourth backup CronJob for the audit domain (#3399). The Helm chart
rendersbackup.auditas soon asdatabase.audit.existingSecretgives the
audit trail a database of its own, with its own claim, credential and
schedule, and drops--schema=auditfrom the clinical dump in the same
render. Until then nothing changes: the trail shares the clinical database
and travels with that dump. Before this, an audit database placed elsewhere
was backed up by nothing and the operations page told the operator to dump it
by hand. - An erasure tombstone on the party change-event stream (#3417). A
physically deleted party now leaves one contribution-less row on the party
domain's own outbox before the delete commits, naming the party and every
PARTY_RELATIONSHIPerased with it, so a consumer of the party stream is
told to delete what it derived. GDPR Art. 19 makes the controller
communicate an erasure to each recipient the data reached, and the clinical
outbox has carried the same row since #3416. - A dispatch-only storage benchmark lane (#3370). It runs the storage
benchmark against a PostgreSQL 18 service with durability on, compares every
operation with the record committed for the same schema generation, and fails
the run when a p50 or p99 rose further above it than the dispatched tolerance.
The summary lists each operation's baseline, measurement, delta and verdict
and names both machines; the fresh record leaves as a run artifact and the
committed baseline changes only in a pull request. Exploration, never a
conformance record: it earns no performance class. - Restriction of processing, at whole-EHR or single-object grain (#3324).
A restriction register records who asked, on what ground and when, and the
mark it drives stops every path that would process the object: a point read,
a versioned read and a revision history answer403with a body naming the
restriction, AQL omits it at every scope, an EHR Extract skips it and refuses
outright for a restricted EHR, the change-event stream neither emits nor
delivers an event about it, and a write to it is refused. The stored rows are
untouched — storage is the only processing GDPR Art. 18(2) leaves. Lifting
stamps the register rather than erasing it, so the sequence Art. 18(3) turns
on survives, and a whole-record lift leaves an object-scoped restriction
standing.POST/GET {base}/admin/restrictionand
POST {base}/admin/restriction/liftdrive it, each act an access record of
its own. - A per-EHR objection to research processing (#3325). While it stands, the
record leaves every full-population AQL query, every export and the event
stream, and a query naming theehr_idor a read for care is untouched: GDPR
Art. 21(6) reaches research processing, not the care record. The
public-interest override the article admits is recorded beside the objection
and says on whose authority.POST {base}/admin/research-objection. - A retention register, and a list of what has fallen due (#3346). The
period per content category and jurisdiction with the legal citation it rests
on, a per-EHR anchor, a whole-record hold and a per-object hold, and a view
that lists the EHRs whose period has run with the counts of objects due and
objects exempted. Nothing is deleted on a timer: an openEHR record is
indelible, so acting on the list is the controller's decision, taken through
the physical delete. Five routes under{base}/admin/retention/, and a new
book page, Retention, restriction and
objection. - An access-log retention ceiling beside the floor (#3346). Set
[audit.store] sgb_v_309_controller = truewhere the deploying organisation
is, or acts for, one of the controllers SGB V § 307 names for a German
telematics-infrastructure application, and boot refuses any
retention_daysabove the three-year period § 309 Abs. 1 sets — including
0, keep forever. A floor and a ceiling that contradict each other are
refused outright rather than silently resolved in favour of one.
Fixed
- A date with text after it compares as no date (#3436).
openehr_date_days
read the leading four, six or eight characters of a value and ignored the
rest, so20210102XYZ,2021-01-02 BC,2021-01-02 Europe/Amsterdamand
2021acompared and ordered as dates in AQL. BASEfoundation_types
master06-time_types.adoc §Iso8601_date admits none of them, and each now reads
as NULL, which is a comparison miss rather than an error.2021-01-02T10:30:45
and its space-separated and lowercase-tvariants are unchanged.
Changed
-
The release profile links with thin LTO (#3098).
[profile.release]
gainslto = "thin", the whole-binary optimisation the Cargo book describes
for a CPU-bound path; the overflow-checks,panic = "unwind"and line-table
posture is unchanged.codegen-units = 1was tried in the same cycle and
withdrawn: the arm64 release build exhausted its runner twice. A release
build takes longer; the throughput before/after is measured under #3452 once
the box can be idle. -
LATEST_VERSIONunderCONTAINSis one key probe per object (#3453).
The AQL emitter used to express "this row is the current trunk version" as
two correlatedEXISTSsubqueries over the head table, which let the planner
reach the version row byvo_idalone through the commit-time index and walk
every trunk version of the object for every node row: 75 601 of the 93 255
buffers a class-spopulationCONTAINSread. The head row is now joined on
its primary key and the version row reached through its own,sys_version
bound; the plan-shape tests pin the index and a ceiling of eight buffers per
probe. Results are unchanged. -
The server needs only
btree_gist, which it installs itself (#3433). The
second-generation schema calls nothing fromuuid-ossp,pgcryptoor
pg_trgm, so the PostgreSQL image no longer installs them and the CI step no
longer creates them.btree_gistbacks the temporal key on
linkage.subject_ehrand the server creates it at boot, so a plain
PostgreSQL 18 database with a migration credential that may create it serves
with nothing preinstalled. -
The conformance statement is signed by a named person for the company
(#3438). A company signs through someone: the party statement's signatory is
now "Ruben Talstra, for Vernum Projecten B.V." with the date of the change,
and the rendered statement follows.AI_STATEMENT.mdnames Ruben Talstra as
author and maintainer, since the owner of the work is the company. -
The Licensor and copyright holder is Vernum Projecten B.V. (#3435). Every
statement of the holder names the company:LICENSEand the per-crate
licence texts,REUSE.toml, the SPDX header of every first-party file
(emitted for the generated crates, so they were regenerated), the licensing
page, the landing footer and the conformance party statement, whose rendered
statement and certificate were regenerated with it. The licences themselves
are unchanged — the application and the three hand-written engines stay
Business Source License 1.1 with the same Additional Use Grant, Change Date
and Change License, the five generated model crates stay Apache-2.0, and
releases up to v4.0.17 and crate versions up to 0.0.56 stay MIT as published,
under the holder they were published with. Ruben Talstra remains the
maintainer credit the banner,MAINTAINERS.md, the OCIauthorslabel and
the chart maintainers carry, and theurn:rubentalstra:ferroehrconformance
identifier is an identifier rather than a holder statement and stays. -
The
productiondeployment profile refuses the two compatibility
defaults (#3323). The per-EHREHR_ACCESSdefault isopenand the audit
trail fails open, both chosen so the conformance instrument and the
development stacks work out of the box. Neither default changes; what changes
is thatdeployment_profile = "production"now refuses to start while either
stands, naming the key, the value and the remedy, unless the deployment
accepts it by name indeployment_accepts— the mechanism the profile
already uses. The two new gap tokens areopen_ehr_access_defaultand
audit_fails_open. GDPR Art. 25(2) asks that by default personal data not be
made accessible without the individual's intervention to an indefinite number
of natural persons; the obligation binds the controller's production posture,
which is what the profile expresses. -
The deployment posture reads the audit pool and every domain database
(#3398).shared_clusternow includes the audit pool's own cluster identity,
so an audit database co-located with a pseudonymisation domain is reported
instead of being invisible (Swiss DSV Art. 4 Abs. 5 keeps the log on a system
separate from the one processing the data).migrate_on_runtime_credential
is evaluated per domain database rather than only from[db] migrate_url: a
domain relocated to a database of its own is prepared on that domain's
runtime DSN, which is the same gap under a different key. -
ext.openehr_timestamprefuses a zone name and aBCera (#3427).
PostgreSQL's own date/time input reads2021-01-02T10:30:45 Europe/Amsterdamand2021-01-02T10:30:45 BC, and the separator-position
gate in front of it let both through. BASEfoundation_types
master06-time_types.adoc §Iso8601_date_time ends the value at the offset, so
both now read as NULL: an AQL comparison against such a stored value misses
rather than matching a date the record does not carry. The check costs the
helper 93 ms per 50 000 readings against 65 ms without it, measured on the
same corpus where the first generation took 117 ms. -
The archive export lists multimedia blobs from the reference index
(#3420). It used to walk every exported body in process to find the blob
keys; it now readsblob_ref, the index the node write path maintains in
the same transaction as the nodes it describes, once per domain. The archive
it writes is unchanged. -
The
extvalue helpers are each in the form that measured fastest, and read
fewer things than PostgreSQL's own date parser did (#3351). Five of the
seven no longer open a subtransaction per row: they guard their casts ahead of
them instead of trapping an error. The two whose whole body is parsing a date
or a time keep the trap, because measurement makes it the cheapest validator
available — over 50 000 readings they cost 58 ms and 70 ms, where the same
bodies validating by hand so the cast can run untrapped cost 65 ms and
235 ms — and each carries that measurement in its function comment. Every
helper reads faster than the one it replaces, and a value any of them refuses
reads as NULL. Three readings tighten as a result, all of them
values openEHR does not define: a date whose year carried leading whitespace
or a sign is now unreadable, a date handed to the time parser no longer
yields a number, andext.openehr_timestamprefuses a date that is not
zero-padded (2021-1-2), an ordinal date (2021-002), and the words
PostgreSQL accepts (now,today,yesterday,epoch,infinity). The
first two made a stored value read as the current time. In the
other direction it now reads the lowercasetand space separators on a
reduced-precision date, which it used to read only at full precision. -
A physical EHR delete reaches everything the erasure has to reach
(#3347, #3403).DELETE {base}/admin/ehr/{ehr_id}runs one clinical
transaction that removes the EHR and, through the foreign-key graph, its
versions over both storage tiers with their nodes, attestations,
contributions, commit audits, item tags, folder memberships, restriction and
retention marks, multimedia blob references and pending change events; the
subject proxies of a subject whose only record this was go with it, and an
erasure tombstone is appended to the change-event stream, which each
registered reader receives and applies by deleting what it derived (GDPR
Art. 19). The cross-reference row naming the subject is then erased in the
linkage domain, on the bulk route as well as the single one, and the
externalized blobs no surviving version references leave the object store.
The audit records naming theehr_idstay, because Art. 17(3)(b) withholds
erasure where a legal obligation requires the processing and the
access-logging periods are that obligation. -
Multimedia blob collection is an index lookup rather than a scan
(#3347).clinical.blob_refand its party twin record which stored version
references which externalized blob, written by the node write path, carried
across the archival tier move by their foreign key and removed with the
version. The collector reads that index instead of pulling every node body of
the deleted record into the process and substring-matching it against every
node of both domains. -
An OPT 1.4 template whose ordinal symbol has no
<value>element is
refused with the fix in the message (#3395). The ITS-XML profile schema
typesC_DV_ORDINAL.listas the RMDV_ORDINAL, whosesymbolis a
DV_CODED_TEXTcarrying a mandatoryvalue, so such a template is not a
document the format admits and the refusal stands. Beside the element and
line it already reported, the422now says to add<value/>, that an empty
element is valid, and that the rubric is resolved fromterm_definitions.
CKM's exporter writes the element; Archetype Designer's omits it. -
The migration-immutability rule binds SHIPPED files only (owner ruling
2026-09-15). A migration is immutable once it is in a release (present at
the latestvX.Y.Ztag); a file onmainthat no release carries has been
applied by no installation and is fixed in place. The guard
(scripts/checks/migration-immutability.sh) judges the pull request's diff
against the release tag's tree and re-arms at every cut; nothing changes for
an installation on a released version. -
BREAKING — the storage schema is rewritten, and a database created by an
earlier release must be recreated (#3342, #3344). The rewrite is greenfield
(owner ruling 2026-09-14): the new migration sets replace the old ones
outright, nothing is upgraded in place, and the server refuses at boot a
database whose migration record predates the rewrite. Take a dump of anything
worth keeping before upgrading, recreate the database, and let the server
migrate it. What changed, and why:- The
ehrschema becomesclinicalanddemographicbecomesparty, each
laid out as a sequence of natural migration files with one concern apiece
rather than one squashed baseline. versionis append-only: it carries no validity interval, so a
supersession writes no close-out statement, and the validity of a version
is derived from itscommitted_at. One mutablevo_headrow per versioned
object answers "what is current", and none of the columns a commit updates
appears in an index, so that update is heap-only.version,nodeandvo_attestationare partitioned by storage tier, so
archiving an EHR is row movement inside one relation set rather than a copy
into mirror relations: thecoldandcold_demographicschemas, every
*_allunion view and thevo_archivemarker table are gone, the archive
marker is two columns ofvo_head, and a foreign key holds across the tier.nodegains the promotedname_codeandname_terminologythe AQL node
predicate needs, and drops the unreadcitem_num.- The clinical and party change-control and node relations are rendered from
one shared DDL template, so the two domains cannot drift. - The party domain gains
party_relationship_target, the target-side index
behindPARTY.reverse_relationships. - The archive file format carries a version's
committed_atinstead of a
validity interval. An archive written by an earlier release does not load.
- The
-
BREAKING — the EHR id / subject cross-reference moves to the linkage
domain, and erasure reaches it (#3345).linkage.party_ehrbecomes
linkage.subject_ehrand absorbsehr.ehr_index: one temporal relation
holding both the party-to-EHR map and the openEHR Service Model's EHR Index
associations (I_EHR_INDEX), which is what that service is for — an EHR
persisted with only an EHR id, and a separate index to obtain the subject
identifier. TheEHR_INDEXoperations and the subject-proxy subject
resolution now run on the linkage pool; the clinical schema keeps only the
promotedehr.subject_id/subject_namespacepair, under the pseudonym
guard, because the wire bindsehr_get_by_subjectand the one-EHR-per-subject
409to EHR_STATUS content. The subject-proxy relations (sp_subject,
sp_variable,sp_data_set,sp_sample) key on a derived opaque
subject_keyinstead of the caller's own subject identifier, so no
unguarded subject identifier remains in the clinical domain (GDPR Art. 4(5)),
and a catalog test asserts it. Deleting an EHR now removes the
cross-reference rows naming it, throughlinkage.erase_ehr— a
SECURITY DEFINERfunction theferroehr_linkagerole may execute while
still holding noDELETEon the table — so an erased record leaves no row
behind saying whose it was (Art. 17(1)). Subject-proxy registrations and
index associations made under an earlier release do not carry over; the
storage rewrite above requires a fresh database in any case. -
BREAKING — one pool and one DSN per storage domain, and the runtime roles
are named after them (#3343). Each of the four storage domains —clinical,
party,linkage,audit— now has its own configuration table,
[storage.<domain>]withurl/url_file, and its own connection pool
carrying only that domain's schema on itssearch_path. Every domain defaults
to the shared[db].url, so a configuration file that names no[storage]
table at all, and the compose quickstart, behave exactly as before. What
changed for a deployment that had already separated its credentials:[db].demographic_url,[db].demographic_url_file,[db].linkage_urland
[db].linkage_url_fileare gone. Move them to[storage.party]and
[storage.linkage]; the old keys are refused at boot by name rather than
ignored.- The chart's
database.demographicExistingSecret,
database.linkageExistingSecretand their*Key/*Urlsiblings become
database.<domain>.{existingSecret,existingSecretKey,url}forclinical,
party,linkageandaudit. The mounted files are
storage.<domain>.urland the environment names are
FERROEHR__STORAGE__<DOMAIN>__URL_FILE. - The runtime roles are the domain roles and nothing else:
ferroehr_clinical,ferroehr_clinical_reader,ferroehr_party,
ferroehr_party_readerandferroehr_linkage, beside the DDL-only
ferroehr_migrator. The first generation'sferroehr_ehr,
ferroehr_ehr_reader,ferroehr_demographic,ferroehr_demographic_reader
and the genericferroehr_app/ferroehr_readerpair are gone: no
migration creates or grants to them, the compose database image no longer
provisions them, and a DSN that authenticates as one of them holds nothing.
A login role is granted the domain role of the pool it serves
(GRANT ferroehr_clinical TO <login>); the clinical role also writes the
local Audit Record Repository. - The compose backup service
ferroehr-backup-demographicis
ferroehr-backup-party, its directory variable
FERROEHR_BACKUP_DEMOGRAPHIC_DIRisFERROEHR_BACKUP_PARTY_DIR, and the
chart'sbackup.demographic.*values arebackup.party.*.
A DSN naming another host moves that domain to a database or cluster of its
own, which is the separation a schema inside one database cannot make: a base
backup, WAL archiving and physical replication carry every schema of a
database together (PostgreSQL 18, Backup and Restore). Schema preparation
follows: theextset is applied per database by whichever domain reaches it
first, so two co-located domains share one copy and a relocated domain carries
its own. One layout is refused rather than half-applied — thelinkage
migration set revokes a function thepartyset creates, so those two are
prepared in the same database.The boot gate grew two refusals. Two domains configured on DIFFERENT DSNs that
authenticate as the SAME database role are refused, because a separation that
exists only in the configuration reads as one that holds. And a domain role
that does not exist is a warning underdeployment_profile = "sandbox"and a
refusal underproduction, where absent roles mean absent grants and the
check would otherwise pass by having nothing to measure.ferroehr config checkprints the resolved domain layout, naming no DSN. -
The Helm chart is 10.1.1, a major bump (#3342, #3343, #3344, #3378). The
values contract changed in three ways a values file can notice: the per-domain
database.<domain>blocks above replace the flatdemographicExistingSecret
andlinkageExistingSecretkeys,config.tenancyno longer exists and is
refused at render with the reason, and the per-domain backup CronJobs dump the
generation-2 schemas (clinical, ext, auditandparty) instead of the
retired mirror schemas. The docker-compose backup
services dump the same sets, and all six jobs now pass--strict-names, so a
--schemapattern matching nothing fails the dump instead of writing an
artefact that silently lacks that half (PostgreSQL 18, pg_dump §Options). -
A newer runtime base image (#3439). The server and the viewer image build
ongcr.io/distroless/cc-debian13:nonrootat digestsha256:54df941e, the
digest that tag resolves to today. The
org.opencontainers.image.base.digestlabel moves with it in both
Dockerfiles and in the two publishing lanes, which is what the image-labels
guard checks. -
Four runtime dependencies move to their current patch (#3442).
lapin
4.11.0 retries the initial AMQP connection properly when recovery is on,
which is the mode the events transport runs in;reqwest0.13.5 fixes proxy
authentication picking the wrong credentials when several proxies match a
request, and a blocking-client timeout panic;tokio-rustls0.26.5 returns
more data perpoll_readon a TLS stream;fancy-regex0.19.2 cuts pattern
build time. No API a caller here uses changed. -
jsonschema0.55.1 (#3443). It compiles the vendored ITS-JSON schema
behindopenehr-its'sschema-validationfeature. The releases since 0.53.0
correct the keyword location an error names and several schema
canonicalisation defects; the 626-testopenehr-itssuite, the ITS-JSON
corpus gate among it, passes unchanged. The eight publishedopenehr-*
crates step to 0.0.67 with it, since the requirement is part of their
packaged manifest.
Removed
- The Subject Proxy service (#3431). It realised SM
I_SUBJECT_PROXY_SERVICEandI_DATA_BINDINGover thesp_*tables, had no
REST route and no consumer outside its own configuration. Gone with it: the
[subject_proxy]configuration section and its FHIR-frame executor, the
sp_*tables, the erasure reach into them, the Helmconfig.subject_proxy
values and the Subject Proxy book page. No openEHR specification obliges a
CDR to realise every SM component; SMmaster02presents the platform as a
set of services a deployment composes.docs/architecture.mdlists the
component as not realised. - Multi-tenancy (#3378). Multi-tenancy is achieved by running separate
instances: one instance, one database, one set of domain roles per
organisation. That is where openEHR puts it. An openEHR system is "a
distinct logical repository corresponding to an organisational entity that is
legally responsible" for the data, and it is "distinct from any underlying
virtualisation infrastructure or cloud computing facility, which may house
multiple logical EHR systems in a multi-tenant fashion" (BASE
architecture_overview/master06-design_of_the_ehr.adoc§The EHR System) — so
tenancy belongs to the layer that hosts several systems, not inside one. The
same chapter's §System Identity is why housing several organisations in one
system was wrong rather than merely unnecessary:system_idnames that one
legally responsible system, it "becomes embedded in the version identifiers of
committed -- and possibly signed -- content", and it "cannot easily be changed
afterwards". No relation carries a tenant column, no row policy or session GUC
scopes a read, and the[tenancy]configuration, the tenant middleware, the
/admin/tenantroutes and the Helm and compose tenancy keys are gone. The
pseudonymisation domains and their role barriers are unrelated to tenancy and
stay.
Security
- rustls 0.23.45 (RUSTSEC-2026-0285): the TLS stack no longer accepts TLS 1.3 handshake messages sent at the wrong encryption level after a key change (RFC 8446 §5.1). Lockfile bump only; no configuration changes.