Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Maintenance rule: every pull request that changes user-visible behaviour adds
its entry in the same PR as a fragment under changelog.d/, in the format
changelog.d/README.md describes, and leaves this file alone. Cutting a
release runs scripts/release/changelog.sh --assemble <version> <date>, which
writes the fragments and any entry still under Unreleased into the new
version’s section and moves the link references on.
The architecture is docs/architecture.md, the output of the research
program on the v0.0.1 milestone. Releases on the 0.0.x line started with the
repository, its gates, its documentation and the server shape in 0.0.1; the
federated query and identity resolution shipped in 0.0.3; cross-node result
shaping, single-node routing and the targeting mechanisms in 0.0.6.
Unreleased
0.0.20 - 2026-11-12
Security fixes until 2031-11-12, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- Add an
[[auth.attributes]]table withmapping = "iua"andauthorization_details_purposes = truefor everypersons,patientsormachinesissuer whose callers declare a purpose of use; without it every such caller is refusedpurpose-of-use-required. Apatientsormachinestable names no field butpurpose_of_use. Move a[[trust.issuer]] professional_issuing_authority = "<claim>"to that table’sfields."professional.issuing_authority_name" = [{ claim = "<claim>" }]; the old key now refuses to boot (#1615). - An imaging report receiver (
[[fhir.receive]]of the imaging category) needs FerroBRIDGE’s imaging report template at v0.0.24, which slots the CKMopenEHR-EHR-CLUSTER.organisation.v1, and that archetype among its archetypes: the imaging mapping ofeehrxf0.6.6 and later (this release ships 0.7.0) does not compile against the earlier revision, and the gateway refuses to start with it. Both are vendored underdocs/specs/ferrobridge-imaging/(#1860).
Changed
- The quickstart runs FerroPIX 0.0.29, the newest release, whose
private_key_jwtclient assertions are valid for 60 seconds and whose configured issuers are masked in every rendering of its configuration. The quickstart’s FerroPIX configuration is unchanged (#1474). - The professional, the organisation and the purposes of use a caller’s
token states are read only through its issuer’s
[[auth.attributes]]table (apersonsissuer) or its[[auth.federation]]table, asferrohealth-smartreads them; the gateway’s own reader of the IHE IUA extension and theauthorization_detailspurposes is gone (#1615). - The compose quickstart and the end-to-end harness run FerroEHR 5.7.0, node
and database image alike, pinned by digest, in place of 5.6.3, and the
gateway reaches every quickstart node under the caller. Each node trusts
the quickstart’s development issuer as its issuer of persons, binds every
person who signs in to a
PERSONit creates at the first request, and admits the gateway only as a delegate acting for the person a token names: each node’s endpoint has an onward lane, so a caller’s query reaches the node under the token the issuer exchanges the caller’s token for (RFC 8693), and no node is reached under a Basic user. The nodes run in the issuer’s network namespace, each on its own port, and fourferroehr-definitions-*one-shots load FerroEHR’s professionalPERSONandROLEtemplates, vendored underdocs/specs/ferroehr-definitions/, before the nodes start. A quickstart volume from an earlier release holds no such templates or persons: rundocker compose down -vonce before you bring the stack up again (#1615). - The release compose example reaches node B through an onward lane in place
of a user and a password, and the onward credentials page states what a
FerroEHR 5.7.0 node lists for the gateway: the onward issuer as a
personsissuer with the node’s own audience, and the gateway’s client among its delegates. v0.0.20 is the first FerroFED release that reaches a FerroEHR node under the caller: a deployment whose gateway writes to FerroEHR adds the node’spersonsissuers once its gateway runs v0.0.20 and every FerroEHR endpoint has its onward lane (#1615). - The gateway runs on
ferrohealth-smart0.19.1, which signs each client assertion of a grant for 60 seconds in place of 300, the lifetime the quickstart’s development issuer admits on a token exchange (#1615). - A received imaging report’s performing organization reaches the member’s
structured record. The imaging mapping of
eehrxf0.6.6 and later writes anOrganizationperformer into the Imaging service slot of the examination result as anopenEHR-EHR-CLUSTER.organisation.v1, over FerroBRIDGE’s imaging report template at v0.0.24. A performer that is noOrganizationis declared to the sender as not carried (#1860).
Removed
[[trust.issuer]] professional_issuing_authority: the claim naming the agency that issued the professional’s identifier is thefields."professional.issuing_authority_name"source of the issuer’s[[auth.attributes]]table (#1615).- The named refusals that shipped in v0.0.19 for one release are deleted,
under the one-release rule for removal aids. Each such key is now refused
as an unknown key: the unit-suffixed keys (
federation.per_node_timeout_ms,emergency.window_s,store.sweep_interval_sand the rest, #1800), theurl,credentials,tlsandtimeout_mskeys of every outbound table that moved under itsendpointtable (#1805, #1815), a node’suser,password,password_file,oauthandonwardoutside its nested[credentials."<id>".credentials]table (#1806), and[trust.issuer.patient] act_is_representative. The Upgrade notes of v0.0.19 give each step (#1855). - The operator console no longer refuses
[gateway] timeout_msby name, under the one-release rule for removal aids: v0.0.19 retired it forgateway.timeout, and the console now refuses it as an unknown key (#1873).
Fixed
- An issuer written with userinfo (
https://user:password@…) in[[auth.attributes]],[[auth.federation]],[[auth.party]]orauth.scopeless_subjectsno longer shows its password inferrofed config checkor in the configuration’sDebugrendering: it renders ashttps://***@…, as[[auth.issuer]]already did. The gateway moves toferrohealth-smart0.19, which holds the issuer of every configuration table as aSecretUrl, withferrohealth-session0.14,ihe-atnaandehds-logging0.13,ferrobridge-termandferrobridge-validator0.6,eehrxf0.7, andihe-itiandnl-generic-functions0.5, the releases on the same line (#1864). - The information sheet names the interoperability component this release
ships,
eehrxf0.7.0, where it named 0.3.2, and the pin matrix names theeehrxf,fhirconnect,openehr-mapping-core,ferrobridge-term,ferrobridge-validator,ihe-atnaandehds-loggingrequirements the build uses (#1874).
0.0.19 - 2026-11-11
Security fixes until 2031-11-11, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- Write the spans of time of
ferrohealth-smart0.15 as durations under keys that name no unit: on each[[auth.issuer]]clock_skew,jwks_max_age,jwks_refresh_intervalandtimeout;introspection.cache_max_age;[auth.dpop]max_ageandclock_skew; on each[credentials."<id>".credentials.oauth]refresh_margin,timeoutandprevious_key_overlap; and[onward.keys] max_onward_lifetime, until now a bare number of seconds. The old keys are refused as unknown keys (#1799). - Write
[auth] scopeless_subjectsentries as tables withissuerandsub(#1799). - Move
[trust.issuer.patient] act_is_representativetoact_represents_patient = trueon the[[auth.issuer]]ofkind = "patients". The old key refuses the start, naming its replacement. A patient token that carries an RFC 8693actclaim on an issuer without it is now refused as an invalid token (#1799). - In the operator console, write
[oidc] timeoutand[session]sign_in_timeout,idle_timeoutandabsolute_timeoutas durations, and move[oidc] ca_fileto[oidc.tls] trust_roots_file(#1799). - Move the
[server]keys offerrohealth-server0.8 to their durations and sizes:request_timeout,header_read_timeout,idle_timeout,drain_delay,shutdown_timeout,retry_after(whole seconds),body_limit,compression_min_sizeand[server.tls]reload_interval, as inrequest_timeout = "30s". The old keys (request_timeout_msand the others) are refused as unknown keys (#1799). - Move
[telemetry.otlp]client_identity_fileandtrust_roots_fileinto[telemetry.otlp.tls], which also takesmin_version(#1799). - Write every span of time and amount of data of FerroFED’s own sections
as a duration (
"30s","5min") or a size ("16MiB") under a key that names no unit:federation.per_node_timeout,overall_timeout,binding_ttlandmax_node_answer_size,federation.localization.timeout,gateway.bindings_drain_timeout,signing.node_jwks_cacheandrotation_overlap,emergency.window,config.pollandconfirm,store.timeoutandsweep_interval,fhir.document_url_lifetime,eehrxf.terminology.endpoint.timeout,pdqm.timeout,pmir.timeoutandcheck_interval,nl_gf.mitz.timeout,registry.mcsd.refresh_interval,deadlineandmax_size,audit.repository.spool_max_sizeandspool_write_timeout,audit.repository.endpoint.timeoutandretry.max_backoff, andxcpd.audit_repository.spool_max_size,spool_write_timeout,connect_timeout,send_timeoutandretry_max, with theFERROFED__overrides to match. Each old key (per_node_timeout_ms,window_s,spool_max_bytesand the others) refuses the start andconfig check, naming its replacement. The operator console writesgateway.timeoutin place ofgateway.timeout_ms(#1800). - Move the address, timeouts, TLS and credential of
[audit.repository]and[eehrxf.terminology]into their newendpointtable,ferrohealth-smart’s outbound table.[audit.repository]keepshostname,source_id,enterprise_siteand thespool_*keys, and[audit.repository.endpoint]takesurl,timeout(fromtimeout_ms),retry.max_backoff(fromretry_max_ms, the wait between two delivery rounds) andtls.[eehrxf.terminology.endpoint]takesurl,timeout(fromtimeout_ms),tlsandcredentials; one call is tried once unlessretry.max_attemptssays more. Each old key refuses the start andconfig check, naming its replacement (#1805). - Move a node’s
user,password,password_file,oauthandonwardfrom[credentials."<id>"]into its nested[credentials."<id>".credentials]table,ferrohealth-smart’s credentials table, with theFERROFED__CREDENTIALS__<ID>__CREDENTIALS__overrides to match.tls,nutsandonward_organisationsstay in the section. Each old key refuses the start andconfig check, naming its replacement. The operator console’s credentials form writes the nested table (#1806). - Move the NVI’s Nuts grant from
[nl_gf.nvi.credentials.nuts]to[nl_gf.nvi.nuts];[nl_gf.nvi.credentials]isferrohealth-smart’s credentials table, which refusesnutsas an unknown key (#1806). - Move the address, TLS and credential of every identity, localization and
consent service into its
endpointtable,ferrohealth-smart’s outbound table, as[audit.repository]and[eehrxf.terminology]moved:url,tlsandcredentialsof[[pixm.manager]],[pdqm],[pmir],[registry.mcsd],[nl_gf.nvi]and[nl_gf.mitz]go to[<table>.endpoint], and thetimeoutof[pdqm],[pmir]and[nl_gf.mitz](fromtimeout_ms) toendpoint.timeout. Each[[xcpd.gateway]]takes its own[xcpd.gateway.endpoint]with itsurl, and the shared[xcpd.tls]moves into thetlsof each gateway’s endpoint table. A table you leave partly written keeps its defaults: one try per call,1sfor PDQm and Mitz,5sfor PMIR and10sfor the others. The NVI’s Nuts grant stays in[nl_gf.nvi.nuts]. Each old key refuses the start andconfig check, naming its replacement (#1815). - The release asset
compose.yamlis now the self-contained quickstart. The gateway-only compose file of the production guide, which carried that name, is attached asgateway-compose.yaml: an operator who downloads it by name fetchesgateway-compose.yamland saves it ascompose.yamlbesideferrofed.tomlandregistry.toml, as Install and run shows (#1849).
Added
- The FHIR face receives an imaging report (Art 14(1)(d)): a
Medical-Imaging[[fhir.receive]]entry now loads over the HL7 Europe Imaging Report 1.0.0-ballot package closure, and a received report is held toBundleReportEuImaging, mapped by the imaging projecteehrxfships over FerroBRIDGE’s ADL 2 imaging report template, and written to the declared member by its ownehr_idalone, with its access record (#1716). - The performance gate sends a national contact point’s FHIR patient
summary under its
DPoP-bound treatment token, and the same summary as an emergency access under its emergency token, below the emergency limit. After the run it checks that the head’s store counted every emergency access the head answered (The performance gate, #1744). - A received imaging report’s answer declares each element of the report
and its study, and each section, that the member’s composition does not
carry, as an
incompleteissue of itsOperationOutcome, so the sender is told what the member’s record holds only in the original document (eehrxf0.6.1, hazard I-22) (#1838).
Changed
- The clinical safety risk file rates what a received discharge report can lose of its own content as hazard I-21, open at S4, P3. Its diagnoses, procedures and discharge medicines reach the member’s composition with every element the HDR mapping reads. Its hospital course and every section the template has no place for reach the member’s record only in the original document, and the answer does not name them. The instructions for use and “Receiving a document” say so (Receiving a document, #1794).
- The MyHealth@EU self-assessment rows 08 and 08.01 are re-assessed as partly met: a laboratory, discharge or imaging document or an image a member serves itself reaches the contact point byte for byte with its media type, a PDF and a PNG among them, and a report the gateway writes from an openEHR composition is a new document, no original (What stays with the contact point, #1795).
- The gateway runs on
ferrohealth-smart0.15.1,ferrohealth-session0.9.0,ihe-atnaandehds-logging0.9.1,ihe-iti0.0.84 andnl-generic-functions0.0.51, with FerroBRIDGE’seehrxf,ferrobridge-termandferrobridge-validator0.2.0,fhirconnect0.1.249,openehr-mapping-core0.1.151 and theopenehr-*crates at 0.0.136. A patient’s representative is the callerferrohealth-smartclassifies as one, and the gateway no longer reads theactclaim itself. A node grant’s owntlstable beside the node section’s TLS identity is refused, naming both (#1799). - Moved
ferrohealth-configto 0.4.1,ferrohealth-telemetryto 0.4.0,ferrohealth-serverto 0.8.0,ferrohealth-usage-reportto 0.4.0 and the performance gate’sferrohealth-perfto 0.8.0. The OTLP collector’s TLS is read and handed to the export byferrohealth-server’stelemetry::build, and[metrics.tls]is the family’s listener TLS table, so the gateway keeps no copy of either (#1799). - Every span of time and amount of data in the configuration reads as a
ferrohealth-configduration or size, so the file, theFERROFED__overrides,config check,ferrofed reportand the operator console’s settings show one form, such as"10s"or"16MiB"(#1800). - The gateway runs on
ferrohealth-smart0.16.3,ferrohealth-session0.10.0,ihe-atnaandehds-logging0.10.0,ihe-itiandnl-generic-functions0.2.0, and FerroBRIDGE’seehrxf,ferrobridge-termandferrobridge-validator0.3.0. The audit repository and the terminology server are reached throughferrohealth-smart’s outbound client, so[eehrxf.terminology]also takes a client credentials grant. A node’s grant presents the node’s TLS identity throughferrohealth-smart’s own form of it (resolve_sharing_tls) (#1805, #1806). - The record of an access may take what is left of the request’s own time
to be stored,
server.request_timeoutless the one-second combining margin, up to[audit.repository] spool_write_timeout, whose default rises from 2 s to 10 s. Under load a durable write to the spool can wait seconds for the disk, and a read is no longer refused503 access-unrecordedwhile the request still has time; past it the answer is still withheld (#1812). - Every PIXm, PDQm, PMIR, mCSD and XCPD client, and the NVI and Mitz
clients, is built from
ferrohealth-smart’s outbound client, throughihe-itiandnl-generic-functions0.2.0, one client per XCPD responding gateway. A service that refuses a token with401is sent the call once more with a fresh token, where the call failed before; the NVI’s Nuts grant rides on the outbound client as the product’s authorization, and a MyHealth@EU responding gateway’s client is built over the gated TLS of its revocation check, so it still sends nothing before the check clears the chain (#1815). - The quickstart, the performance gate and the end-to-end lane run FerroPIX
0.0.25, which admits a machine only with a client credentials token. The
quickstart adds a development token issuer,
ferropix-issuer(NAV’s mock-oauth2-server 6.1.5), in whose network namespace FerroPIX runs. The gateway asks FerroPIX as PIXm Consumer under theoauthgrant of[pixm.manager.endpoint.credentials], and the seed feeds it as the Patient Identity Source under its own token. FerroPIX takes no Basic machine user any more (#1825). - Configuration resolution builds the platform’s TLS verifier once per
process for each set of extra roots, through
ferrohealth-server0.8.4 withferrohealth-config0.4.4 andferrohealth-secret0.2.1, so a load, a reload orconfig checkno longer reads the system root store once per outbound table. On Linux each read took about 25 ms (#1834). - The quickstart and the end-to-end lane run FerroPIX 0.0.26, which changes
no configuration key over 0.0.25. The gateway and the console move to
ferrohealth-smart0.18.0,ferrohealth-session0.13.0,ferrohealth-config0.4.4,ferrohealth-server0.8.4,ihe-atnaandehds-logging0.12.0,ihe-itiandnl-generic-functions0.4.0, andeehrxf0.6.1 withfhirconnect0.2.1,ferrobridge-termandferrobridge-validator0.5.0, with no configuration key changed (#1838). - A password in the userinfo of an issuer or endpoint URL the configuration
names no longer reaches any rendering of the configuration,
Debugor serialized: every endpoint and issuer URL offerrohealth-smart0.18, theissuerof each[[trust.issuer]]and[[federation.contact_point]], and the console’s[oidc]issuer and redirect URIs onferrohealth-session0.13 are held as masking URLs. The start still refuses an issuer with userinfo (#1838). - ferrofed.eu takes the FerroHEALTH family layout: the hero badge is
rendered from the workspace version at assembly, the stylesheet link
carries a content hash, the release-by-release cards move to the book’s
new Releases page, the book opens in its azure light and navy themes with
its parts in the order Evaluate, Integrate, Operate, Contribute and a
Changelog page, and the README quick start is the anchored steps of
scripts/quickstart/tour.sh, which a new CI job runs (#1846). - The quickstart is one self-contained
compose.yaml, attached to each release with its.sha256sum, and the README and the book download it in place of a checkout. Every file it read from the repository is inlined as a Compose config, copied onto volumes by two one-shot services so the gateway, FerroPIX and their databases keep read-only root filesystems; a development issuer service, whose network namespace the gateway shares, signs the caller’s token; the reader writes the gateway’s signing key with oneopensslline; and anehr-seedservice seeds the four nodes with the reference implementation’s demonstration data, fetched at a pinned commit and refused unless each file has its pinned SHA-256. The gateway-only compose file of the production guide is attached asgateway-compose.yaml(#1849).
Removed
- The named refusals that shipped in v0.0.18 for one release are deleted,
under the one-release rule for removal aids. Each such key is now refused
as an unknown key: the outbound TLS keys left on a table outside its
tlssub-table,[credentials."<id>".oauth2],.fapi2andmtls_alias_hostsand their service forms,[trust.issuer.national_contact_point],[trust.issuer.requester],[trust.issuer.assurance],client_tokens_act_for_professional, and[stored_queries] urlandurl_file. The Upgrade notes of v0.0.18 give each step. The refusals this release adds for the keys it moves stay until v0.0.20 (#1787).
Fixed
- Every report the FHIR face writes from a member’s composition, the
laboratory, discharge and imaging report alike, names that member’s
Organizationas itsComposition.custodian, so an imaging report no longer carries the member unreferenced, which FHIR R4 documents.html §3.3.1 refuses; a written imaging report is held toBundleReportEuImagingover the Imaging Report package closure, which the package set now pins (eehrxf0.3.2, #1785). - A laboratory, discharge or imaging report the FHIR face writes from a member’s composition names, in the narrative of the section the gateway writes, each element of the record its mapping declared it did not carry, by its openEHR path and never by its value, as the patient summary does. An imaging composition’s findings and a discharge medicine’s timing are no longer left out without a word (#1826).
- A document received on the FHIR face is mapped with the declarations of
the project
eehrxfships for its category, so each element that project accounts for and the run carries into no structured element is relayed to the member as not carried. A received discharge report’s hospital course and a laboratory report’sbasedOnare no longer dropped without a word (#1827).
0.0.18 - 2026-11-10
Security fixes until 2031-11-10, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- Have a national contact point’s authorization server write the format of
the HP Identity Assertion’s
NameIDasehdsi.assertion.name_id_format, one of the three formats eHDSI SAML Profile 10.0.0 §2.1 allows.ferrohealth-smart0.9.4 refuses a contact-point token without it, as it refuses one without theNameID(Authentication, #1615). - Run
docker compose down -vonce before you start the quickstart on this release. FerroEHR 5.6.0 starts its audit store empty and refuses one that holds records an earlier release wrote, so the nodes do not start on aferroehr-pgdatavolume that FerroEHR 5.5.0 wrote (#1617). - The store applies migration 2, which adds
ferrofed.emergency_access, at the first start. A deployment that declares a national contact point and names its emergency purpose in[[access_log.emergency_purpose]]now denies a professional’s sixth emergency access in 24 hours; set[emergency]to tighten the limit, never to loosen it (#1631). [trust.issuer.national_contact_point]is removed, and a configuration that still sets it is refused, naming its successor. Declare the contact point as an[[auth.issuer]]ofkind = "federation"withsender_constraint = "required", an[[auth.federation]]table withcontact_point = true,mapping = "ehdsi", itscountriesand the constantprofessional.country_code,professional.issuing_authority_nameandprovider.issuing_authority_name, and a[[federation.contact_point]]naming the issuer, withcorrelation_headerandadditional_countriesmoved there. Have the contact point’s authorization server write the assertion’s attributes underehdsi, the TRC patient asehdsi.patient.id, and bind the token to the connector’s client certificate (National contact point, #1632).- With
[stored_queries] backend = "postgres", moveurlorurl_filefrom[stored_queries]to a new[store]table, pointed at the database the backend uses now, and setsslmode=requirein the connection string, withca_filewhen the database’s CA is not one of the platform’s roots. This release refuses[stored_queries] urlandurl_fileat start-up and inconfig check, naming each, and a[store]withoutsslmode=requireunder every profile. The gateway takes over the existing table with every definition it holds (FerroFED’s own store, #1636). - A gateway with a national contact point (
[[federation.contact_point]]) needs[store]too: give it a PostgreSQL database and role of its own, never a CDR’s. Without it this release refuses to start, namingfederation.contact_point[0]and[store](#1636). - A binary you build yourself no longer takes
--features postgres; drop the flag (#1636). - Write each issuer as an
[[auth.issuer]]table with itskind:personsfor your professionals,patientsfor an issuer whosepatient/grants[trust.issuer.patient]binds,machinesfor systems under the client credentials grant alone, orfederationwith its[[auth.federation]]table. Moveaudience,algorithms,clock_skew_seconds,jwks_max_age_seconds,jwks_refresh_interval_seconds,jwks_file,timeout_msandrole_claimsfrom[auth]onto every issuer table they held for, and name the issuer’sprofilewhen it is not a standards-only server (entra,keycloak,oktaornl). The start refuses a section-level key and an issuer withoutkind, naming each (#1682). - On each
personsissuer, list your own applications infirst_party_clients, and a third-party client or an RFC 8693 actor indelegates; a person’s token through any other client is refused (#1682). - Move an introspected issuer from
[[trust.issuer]]to its own[[auth.issuer]]withmode = "introspection"and an[auth.issuer.introspection]table:endpoint, and the client authentication in[auth.issuer.introspection.client](client_id,client_secretorclient_secret_file). Keep its[[trust.issuer]]policy, without the introspection keys (#1682). - Rename
[metrics] scrape_userstoscrape_clientsand[pmir] feed_userstofeed_clients, naming the client ids of your Prometheus scraper and your PMIR Registry at an[[auth.issuer]]the gateway trusts, and remove every[[auth.basic.users]]entry: the gateway admits no Basic caller, and a configuration that still writes one refuses to start, naming the user. Give the scraper anoauth2client-credentials setup in its scrape job, and the Registry a client-credentials grant at that issuer (#1704). - Move
[trust.issuer.assurance]to the issuer’s[auth.issuer.assurance]table (claim,minimum, and alevelsmap from each claim value tolow,substantialorhigh), move[trust.issuer.requester]to thenl.professional_number,nl.role,nl.uraandnl.organisation_typefields of the caller issuer’s[[auth.federation]]table, and removeclient_tokens_act_for_professional. Each removed key refuses the start, naming its replacement (#1704). - Remove any user name or password from an
[[auth.issuer]]URL, and keep only public keys in an issuer’sjwks_file:ferrohealth-smart0.13.0 refuses the start on either (#1748). - A national contact point’s authorization server that answers the exchange
profile authenticates the national connector before it reads an
assertion, keeps one record of admitted exchanges that every replica
shares, and refuses a replay
invalid_grant(National contact points, #1748). - Move every
[credentials."<id>".oauth2]and[credentials."<id>".fapi2]table to[credentials."<id>".oauth], and the section’smtls_alias_hostsinto it. This release refuses each removed key at start-up and inconfig check, naming theoauthkey that replaces it (Onward credentials, #1749):- The grant’s key moves from
[signing]to theoauthtable’s ownprivate_key_file, withprevious_private_key_fileandnext_private_key_filefor a rotation. The gateway publishes it in its JWK Set beside the[signing]keys. Pointingprivate_key_fileat the[signing]key file keeps thekidthe node’s authorization server knows; a new key needs itskidregistered there, or the JWK Set fetched again, before you switch. grant = "token_exchange"becomes anonwardtable beside theoauthtable, withkind = "persons", the exchanged tokens’issuer,audiencesandindicatorsnaming the node in place ofresource,actor_assertion = true, and[onward.keys] max_onward_lifetime. The node’s authorization server must issue a JWT access token that names that issuer, anaudamongaudiences, and the gateway asactor client; an opaque exchanged token now fails the node.- A
fapi2table becomes anoauthtable withsecurity_profile = "fapi2",client_auth = "private_key_jwt",assertion_audience = "issuer",issuer, andclient_key_fileandprevious_client_key_filerenamedprivate_key_fileandprevious_private_key_file. tls_client_certificate_bound_access_tokens = truebecomescertificate_bound = true. A grant that authenticates by, or binds its tokens to, the TLS client certificate presents the node section’stlsidentity;client_certificate_file,client_key_fileandca_filein itsoauthtable are refused.
- The grant’s key moves from
- Give every node’s
oauthgrantclient_auth = "private_key_jwt","tls_client_auth"or"self_signed_tls_client_auth". A node’s grant that namesclient_secret_basic,client_secret_post,client_secretorclient_secret_file, or leavesclient_authunset, is refused, naming the key (§13.1, N25). Identity service grants keep their client secret (#1749). - Remove
signing.assertion_lifetime_s: no[signing]key signs a client assertion any more, and every assertion lives 300 seconds. A file that sets it is refused at start-up, naming it.signing.rotation_overlap_smust be at least 300 plussigning.node_jwks_cache_s(#1749). - Move each table’s outbound TLS keys,
client_identity,client_identity_fileandtrust_roots_file, into itstlssub-table:[[pixm.manager]]to[pixm.manager.tls], and likewise[pdqm.tls],[pmir.tls],[registry.mcsd.tls],[xcpd.tls],[nl_gf.nvi.tls],[nl_gf.mitz.tls],[audit.repository.tls],[eehrxf.terminology.tls]and[credentials."<endpoint id>".tls]. This release refuses a key left on its table at start-up and inconfig check, naming thetlskey that replaces it (Mutual TLS to the identity services, #1750). - The gateway speaks TLS 1.3 alone to every outbound service unless the
table’s
tlssub-table lowers it withmin_version = "1.2". Set it on[xcpd.tls]when a MyHealth@EU responding gateway speaks only TLS 1.2, the minimum eHDSI Section II Security Services 10.0.0 §5.9.1 and eHDSI Cryptographic Algorithms 9.0.0 admit between NCPeH, and on any other table whose service speaks no TLS 1.3 (A MyHealth@EU route, #1750). - Move
client_identity,client_identity_fileandtrust_roots_fileof[xcpd.audit_repository]into itstlssub-table,[xcpd.audit_repository.tls], as every other outbound table took them with #1750; the sub-table also takesmin_version,"1.3"unless lowered to"1.2". This release refuses a key left on the table at start-up and inconfig check, naming thetlskey that replaces it (The audit repository, #1766).
Added
- The FHIR face writes a discharge report from a member’s openEHR
composition: a
[[fhir.report]]entry declares the template of the members’ discharge compositions, an OPT 1.4 or an ADL 2 template with its archetypes, and the document list names one discharge report per composition, typed18842-5, read as an HL7 Europe Hospital Discharge Report 1.0.0bundle-eu-hdrwritten by the shipped HDR mapping. The reserved stored queryeu.ferrofed.eehrxf::discharge-reportselects the compositions. ALaboratory-Reportsentry takes the place of the laboratory template of[[fhir.receive]](#1405). - The document list names the discharge reports of a member that serves
them itself as an HL7 Europe EU Health Data API Document Access Provider
(an
hl7-fhir-restendpoint declared forDischarge-Reports): the member is asked by its ownehr_idalone withPatient?identifier=andDocumentReference?patient=&type=, the type naming the codes ofeehrxf-document-type-discharge-report-vs(18842-5,100719-4) the search admits. Each of itsDocumentReferences is listed as it holds it, with each attachment URL readdressed to aBinary/{token}URL the gateway signs, and the read answers the member’sBinaryunchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded underDischarge-Reports. - The FHIR face writes an imaging report from a member’s openEHR
composition: a
Medical-Imaging[[fhir.report]]entry declares the template of the members’ imaging compositions, and the document list names one imaging report per composition, typed85430-7, read as an HL7 Europe Imaging Report 1.0.0-ballotBundleReportEuImagingwritten by the shipped imaging mapping. The reserved stored queryeu.ferrofed.eehrxf::imaging-reportselects the compositions (#1406). - The document list names the imaging documents of a member that serves
them itself as an HL7 Europe EU Health Data API Document Access Provider
(an
hl7-fhir-restendpoint declared forMedical-Imaging): the member is asked by its ownehr_idalone withPatient?identifier=andDocumentReference?patient=&type=, the type naming the codes ofeehrxf-document-type-medical-imaging-vs(85430-7,18748-4) the search admits. Each of itsDocumentReferences is listed as it holds it, with each attachment URL readdressed to aBinary/{token}URL the gateway signs, and the read answers the member’sBinaryunchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded underMedical-Imaging. - A national contact point’s dispensation and its discard follow the
MyHealth@EU rules. A token that lists permissions without
PPD-046is refusedpermission-missingwithERROR_SEC_GENERICon a dispensation anddiscard-refusedwithERROR_ED_DISCARD_FAILEDon a discard (eHDSI SAML Profile 10.0.0 §2.5). Each dispensation the owner creates is kept for 24 hours in FerroFED’s store, in the new tableferrofed.contact_point_dispensation, with the identity assertion’sorganization-idand the facility identifier onperformer.actor. A discard is admitted only within those 24 hours and from that organisation and facility; otherwise it is refuseddiscard-refusedbefore the member is asked (Requirements Catalogue 10.0.0 07.04). - The national contact point page documents the token exchange the
authorization server acting for NCPeH-A answers: the HP Identity
Assertion as an RFC 8693
saml2subject_token, the treatment relationship confirmation intrc_token, both checked asferrohealth-smart’s eHDSI validator checks them, and theehdsitoken it issues. The test suite’s harness authorization server answers that exchange (A national contact point, #1620). - An onward lane takes
cache = falsein its[credentials."<endpoint id>".onward]table, so every call exchanges a token at the issuer. It is meant for a performance measurement’s self-test, never for production; the cache stays on by default (#1620). - On the FHIR face, a national contact point’s patient-data request is
held to the patient of its treatment relationship confirmation. The
patient travels in the token’s
ehdsi.patient.id, assystem|value. A token without it is403trc-missing. Another patient, a resource of another patient, and a patient no member holds get one answer:404not-foundwithtrc-patient-mismatchand the service’s MyHealth@EU*_NOT_FOUNDcode. A mismatch is logged as atrc-patient-mismatchsecurity event that names the professional and both patients by pseudonym only (eHDSI XCA Profile 8.0.1 §4.2.1, §4.2.2) (#1622). - A
[[xcpd.gateway]]can be markedmyhealth_eu = true, a MyHealth@EU route. Before each discovery, the gateway checks every certificate the responding gateway presents against its CRL or OCSP responder. A revoked certificate, or one whose status no source gives, fails the discovery closed with nothing sent (eHDSI X.509 Certificates Profile 7.0.0 §3.2). Boot refuses a marked gateway beside a fixedassertionorassertion_file, and one withouthttps,client_identity_fileortrust_roots_file, naming the gateway (#1623). - A national contact point’s emergency access is held to the relayed health
professional’s limit (eHDSI Section II Security Services 10.0.0 §2.5; NCPeH
Architecture Specifications 10.0.0 §5.1.9): five per professional in any
24 hours by default, tightened with
[emergency] thresholdandwindow_s, and a looser limit refuses the configuration naming §2.5. Past the limit the request is denied403 emergency-limit-reachedbefore any member is asked, withERROR_SEC_GENERICon the FHIR face, an access record carrying the emergency mark and the warningEHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS, and the security eventemergency-limit-reached. The count lives in FerroFED’s own store, one statement per attempt, so it holds across replicas and restarts; with the store down an emergency access is refused503 emergency-uncounted(#1631). - Every refusal to a national contact point names its MyHealth@EU
Requirements Catalogue 10.0.0 §1.25 exception code: in the ITS-REST error
body’s
ncp_error, in the FHIROperationOutcome’sissue.details, and at the head of the access record’s outcome description. A refused token takes the codeferrohealth-smart0.9.2 gives it, the gateway’s own refusals the code of their catalogue row, and no legacy appendix 6.4 number is issued (#1633). - FerroFED’s own PostgreSQL store,
[store]: one database and login role of FerroFED’s own, never a CDR’s, for the state that must survive a restart and be one on every replica. Its connection string isurlorurl_file, a secret, and must setsslmode=requireunder every profile, verified againstca_fileor the platform’s roots;max_connections,timeout_msandsweep_interval_sbound it. Its migrations are part of the binary and run at start under an advisory lock, so replicas booting together apply each once, and a database a newer release migrated is refused. Thestorereadiness indicator is down while the database does not answer, a request that needs the store is then refused, and a sweep deletes the rows past their window. A configuration that declares a national contact point issuer or sets[stored_queries] backend = "postgres"without[store]refuses to start, naming both; otherwise the gateway runs with no PostgreSQL database (#1636). - The gateway admits
DPoPrequests (RFC 9449) under a new[auth.dpop]table: the proof is checked against the request and the token’s key, and a proof sent a second time is refused401with theDPoPchallengeinvalid_dpop_proof. With FerroFED’s own store set, every replica records each accepted proof inferrofed.dpop_proofby the SHA-256 of itsjti, in the one statement that checks it, so a replay to another replica is refused (§11.1), the sweep deletes a proof past its window, and with the store down aDPoPrequest is refused503(#1637). - On the ITS-REST face, a national contact point’s patient-data request is
held to the patient of its treatment relationship confirmation, as on the
FHIR face. A token without the
trc_patientclaim is403trc-missing. A query or a read by subject must name the TRC patient, and a request under{base}/v1/ehr/{ehr_id}or a query scoped to anehr_idmust reach one of that patient’s own{node, ehr_id}pairs. Another patient, an EHR of another patient, a TRC patient no member holds and one every holder restricts all get the answer an unavailable subject gets:404subject-unavailable, with nothing sent to any member. A query that names no patient and no EHR, the creation of an EHR and a DEMOGRAPHIC request are403trc-unbound. Each break of the binding is logged as thetrc-patient-mismatchsecurity event the FHIR face records (eHDSI XCA Profile 8.0.1 §4.2.1, §4.2.2) (#1640). OPTIONS {base}/declares the emergency access limit in effect underfederation.national_contact_point.emergency, asthresholdandwindow_s, so a contact point’s operator can check what the gateway holds its professionals to (eHDSI Section II Security Services 10.0.0 §2.5). The MyHealth@EU self-assessment rows 11.01.04 and 11.01.04.01 now name the limit as the gateway’s share of anomaly detection (#1660).- A test now shows that a person’s own grant lists and reads their laboratory reports on the FHIR face, and that the same grant is refused another patient’s reports (Regulation (EU) 2025/327 Art 3(1)). The rights page and the Annex II checklist name the test.
- The interoperability component maps a received medical imaging report,
Art 14(1)(d), and discharge report, Art 14(1)(f), with the projects
eehrxf0.1.170 ships, each held to its HL7 Europe document profiles (Imaging Report 1.0.0-ballot, Hospital Discharge Report 1.0.0). The gateway receives the discharge report (#1714, below); aMedical-Imaging[[fhir.receive]]entry refuses the start until the gateway’s package set holds the imaging package’s dependency closure (#1716). [credentials."<endpoint id>".onward], the onward lane of a node: every request a verified caller originates reaches the node under the token the table’s issuer exchanges the caller’s for (RFC 8693 token exchange, or the RFC 7523 JWT bearer grant under the Entra profile), throughferrohealth-smart’s onward client, which checks and caches each issued token. A person is carried at apersonsissuer, a professional of another domestic party at afederationissuer and a patient at apatientsissuer; every other caller fails that node with nothing sent. A refused exchange fails the node and never falls back to the gateway’s own token, which the lane’soauthtable keeps for the gateway’s own requests (#1703).- A
CONTRIBUTIONsent through a node’s onward lane names, in itsauditand in every version’scommit_audit, the committer placeholder FerroEHR takes and discards, naming theissandsubof the onward token the node authenticates; a person’s attestation takes the same placeholder, and an attestation through afederationorpatientslane is refused403before anything is sent. A header commit is forwarded as the client sent it, with no committer of the gateway’s (#1705). - A node’s onward lane of kind
federationcarries another domestic party’s organisation, a request with no named person, where its credentials section setsonward_organisations = true. The organisation reaches the node read-only: a write is refused403before any token is asked for. A lane withcorrelation_claimrefuses a caller whose verified claims do not carry the claim before the token endpoint is asked (#1710, #1615). - The FHIR face receives a discharge report (Regulation (EU) 2025/327 Art
14(1)(f)): a
Discharge-Reports[[fhir.receive]]entry holds each document to the HL7 Europe Hospital Discharge Report 1.0.0 profiles over the package’s dependency closure, maps it with the HDR projecteehrxfships over FerroBRIDGE’s ADL 2 discharge report template, and writes it to the declared member by its ownehr_idalone, with its access record. A[[fhir.receive]]entry takes an ADL 2templatewith itsarchetypesbeside an OPT 1.4 (#1714). - The book has a Chapter III conformity record for Regulation (EU) 2025/327: each obligation Articles 25 to 30, 36 to 41 and 49 place on the manufacturer, and each item of the EU declaration of conformity (Annex IV), with the date Art 105 applies it from, its evidence, its status (met, open, awaiting an act of the Commission, or the manufacturer’s) and its issue, and the list of what the manufacturer must still decide or do. It states no conformity, and
scripts/checks/chapter-iii.shholds it to the vendored text in CI.
Changed
- Moved the
openehr-*crates to 0.0.129, with FerroBRIDGE’sopenehr-mapping-core0.1.143. A map-mode member’s operational template, built from its ADL 2 template and ADL 1.4 archetypes, now writes each 1.4 term constraint in its ADL 2 form, so the template prints as ADL 2 that parses back, and the template is named by its full id, namespace and version modifier included. - Moved
ferrohealth-telemetryto 0.2.5 andferrohealth-usage-reportto 0.2.1. The laboratory report is received and served through the same result context as before, and the gateway’s behaviour is otherwise unchanged. The other family crates this release moves are named, at the version it ships, in their own entries. ferrofed reportwrites each part of its archive owner-only (mode 0600), so an unpacked report is readable only by whoever unpacks it.- A chained onward lane’s correlation claim is checked by
ferrohealth-smart0.9.4, which reads it from the caller’s verified claims: an opaque token whose introspection answer carries the claim is exchanged, where the gateway’s own check refused it (#1615). - The compose quickstart and the end-to-end harness run FerroEHR 5.6.3,
node and database image alike, pinned by digest, in place of 5.5.0. The
harness sets a restriction of processing at a node through
/ferroehr/rest/extensions/v1/admin/restriction, where FerroEHR 5.6.0 moved its own extension routes (#1617). - The gateway runs on
ferrohealth-smart0.10.1,ferrohealth-session0.4.0,ihe-iti0.0.78,nl-generic-functions0.0.45, andihe-atnaandehds-logging0.5.2 (#1620):- The access record of a request a national contact point relays names
the professional by the identity assertion’s
subject-id, which Implementing Regulation (EU) 2026/2099 Annex Table 1 makes mandatory, the provider’s name and address when the token states them, every further field the token asserts as anasserted-field, and the provider acted for asacts-for. A node is still told the professional by their identifier alone. - The record of a Dutch requester names its care provider as
acts-for, by its URA under the URA register’s root. - The TRC patient a contact point’s token binds is read by
ferrohealth-smart: apatient.idwhose system holds whitespace, or whose value holds a control character or an HL7 v2 delimiter, binds no patient, so the request is refusedtrc-missing. - An onward token issued for a contact point’s caller that outlives the identity assertion’s or the treatment confirmation’s window by more than 60 seconds is refused before it is sent (eHDSI SAML Profile 10.0.0 §2.1, §4.1).
- The access record of a request a national contact point relays names
the professional by the identity assertion’s
- A national contact point’s patient-data request whose token names no
patient of a treatment relationship confirmation (
trc-missing) now carriesERROR_SEC_DATA_INTEGRITY_NOT_ENSURED(MyHealth@EU Requirements Catalogue 10.0.0 requirement 11.01.02) in its ITS-RESTncp_errorand its FHIRissue.details, where it carriedERROR_SEC_GENERIC: the TRC patient is a missing assertion attribute, and the family answers one way (FerroHEALTH/FerroEHR#5102). A national connector that mappedtrc-missingby its code maps the new one. - A node and the access record are told only what the eHDSI assertion
carries of a contact point’s caller: the professional’s identifier,
issuing authority, role and
on-behalf-of, the provider’s identifier and issuing authority, and the country. No name and no address is conveyed or recorded; the record names the professional by their identifier and adds the assertion’s point of care, facility type, home community, authentication class and assertion ids (#1632). - A contact point’s token must be bound to the client certificate of its
connection (RFC 8705 §3,
sender_constraint = "required"), and a token that falls short of the profile is refused on every route, the self-description included.OPTIONS {base}/names, per contact point, the claim each field is read from by itsferrohealth-smartfield name (#1632). - The
postgresbackend of the stored-query registry keeps its definitions in FerroFED’s own store and reads its connection from[store].[stored_queries] urlandurl_fileare removed: a configuration that sets either refuses to start, naming the key and[store]. The definitions’ table is the store’s first migration, so a database the backend already uses keeps every definition. The PostgreSQL client is part of every build, and thepostgresfeature offerrofed-serveris gone (#1636). - The revocation check of a MyHealth@EU gateway is the family’s shared one,
ferrohealth-server0.5.1 featurerevocation, with the CRL and OCSP rules unchanged. Its TLS 1.2 offers the four ECDHE AES-GCM suites of RFC 9325, as the gateway’s own listeners do, and a CRL distribution point that covers only some revocation reasons is no longer read as a source that can clear a certificate (RFC 5280 §6.3.3). - The gateway runs on the family’s 0.9 identity line:
ferrohealth-smart0.9.4,ferrohealth-session0.3.1,ferrohealth-config0.2.10,ferrohealth-ui0.6.8,ihe-iti0.0.76,nl-generic-functions0.0.43, andihe-atnaandehds-logging0.4.0 (#1682). Every token is classified under its issuer’skindand profile, and one that names no caller the kind admits is refused as an invalid token, itscheckno caller kindin the security log. Apersonstoken is admitted only through a client the issuer lists infirst_party_clientsordelegates, and an RFC 8693actclaim only from a listed delegate. The access record names how a contact point relayed its accessor inrelayed-viaandrelayed-issuer, and the gate refuses a contact point’s token whose provider identifier is nourn:oid:orurn:hl7ii:identifier, or whose role is ISCO44withouton-behalf-of. - Every
[[auth.issuer]]holds its ownaudience, which takesgateway.public_urlwhen unset; a changedaudienceof an issuer the gateway already trusts waits for a restart, namedauth.issuer.audience(#1682). - An
[[auth.issuer]]can be set from the environment by element index, such asFERROFED__AUTH__ISSUER__0__KIND=persons, throughferrohealth-config0.2.10 (#1682). - The test harness’s revocable mutual-TLS front reads its certificates and
writes its CRL and OCSP answers with
der0.8.3,x509-cert0.3.0 andx509-ocsp0.3.0-rc.0, in place of 0.7.10, 0.2.5 and 0.2.1. The server binary and both images are unchanged: the gateway still reachesder0.7 andx509-cert0.2 throughferrohealth-server0.5.1 andtokio-postgres-rustls0.14.0 (#1690). - Moved FerroBRIDGE’s
eehrxfto 0.1.170,ferrobridge-termto 0.1.143,fhirconnectto 0.1.227 andferrobridge-validatorto 0.1.149, so the terminology client runs on the sameferrohealth-smart0.9 line as the gateway.ferrofed config checkand theCapabilityStatementnow nameMedical-ImagingandDischarge-Reportsamong the categories with no receiving member when no[[fhir.receive]]declares them. - The admin listener’s scrape and the PMIR identity feed admit a
client-credentials token (RFC 6749 §4.4) of a client
metrics.scrape_clientsorpmir.feed_clientsnames, whichferrohealth-smartclassifies as a system’s, and answer any other request401with aBearerchallenge, or403for another client’s or a person’s token (#1704). - A token
ferrohealth-smartclassifies as a system’s, or that only asystem/scope covers, reaches no patient data whatever professional it names, and the assurance level of every caller is the oneferrohealth-smartassessed under its issuer’s[auth.issuer.assurance]. The Mitz consent pre-filter reads who asks from the NL fields of a domesticfederationissuer’s[[auth.federation]]table (#1704). - Moved the
openehr-*crates to 0.0.130, with FerroBRIDGE’seehrxf0.1.179,fhirconnect0.1.235,openehr-mapping-core0.1.144,ferrobridge-validator0.1.151 andferrobridge-term0.1.145. A map-mode member’s operational template now names its parent archetype by its full id,…v1.0.0, asopenehr-adlbuilds it; the gateway no longer sets it to the reference as written. - The patient summary lists each entry of a composition that holds a mapped archetype more than once, such as two medicines in one medication list, as its own entry in the section its mapping places it. Such a composition was left out of the section and named in its narrative before.
- The patient summary the FHIR face writes moves to
eehrxf0.1.181 withfhirconnect0.1.236. A medicine a member records as a medication action under the ad hoc Medication Summary section of FerroBRIDGE’s patient summary template is now listed as aMedicationStatementin the medication summary, where it was left out; a vaccine under the vaccination list stays an immunisation. A[[fhir.mapping]]may name the EPS project’s root context,ferrobridge_eu_eps.context, for each section it feeds: the gateway runs a composition through it once and takes each section’s entries from that section’s slot, refusing an entry whose profile the section does not take. An element a mapping run leaves unread is counted in the narrative of each section that takes from the record. - The gateway runs on
ferrohealth-smart0.13.0,ferrohealth-session0.7.0,ihe-atnaandehds-logging0.8.0,ihe-iti0.0.83 andnl-generic-functions0.0.50 (#1748, #1766):- An
[[auth.issuer]]URL that carries a user name or password, and ajwks_filethat holds a symmetric key or private key material, refuse the start. - The published exchange profile of a national contact point’s
authorization server
(National contact points)
names two more obligations: it authenticates the national connector
before it reads an assertion, since a
sender-vouchesassertion names no presenter (eHDSI SAML Profile 10.0.0 §2.1), and it admits each exchange once, in one replay record every replica shares. The harness authorization server the test suite runs does both. - A refusal of a JSON document the gateway could not read, such as a key set that is no JWK Set or a malformed token endpoint answer, names the reader’s category, line and column, and no longer any part of the document (FerroHEALTH/FerroAUTH#351).
- An
- Every node grant obtains its token through
ferrohealth-smart’s client, and FerroFED’s own OAuth 2.0 client is gone (#1749):- The token request carries the scope as configured, no longer rewritten into the SMART on openEHR canonical form, and a token endpoint may carry a query (RFC 6749 §3.2). One with a user name, password or fragment is refused.
- A token with no stated lifetime is reused until the node refuses it,
and a token shorter than twice the refresh margin is reused for half
its lifetime. Each send to the token endpoint, the DPoP nonce resend
included, has the grant’s
timeout_msof its own. - A token exchange runs through the node’s onward lane. With no
scopeon itsonwardtable it asks for the caller’s covering scopes and refuses a call none covers before anything is sent; a configuredscopeis asked for as written. An exchanged token bound with DPoP reaches the node under the DPoP scheme, and one the node answers401is exchanged again. - The issuer of a node’s
oauthgrant is held to the cleartext policy, as its token endpoint is:httpsoutside the development profile.
- Every outbound client reads its TLS material through
ferrohealth-server0.6.4’sTlsClient, from atlssub-table that also takesmin_version. Material that holds no certificate and key in PEM is refused when the configuration loads, naming<table>.tls.client_identity, where the federation refused it before. The RFC 8705 §3.1 thumbprint a node’s grant binds its tokens to is taken from the certificate the gateway presents, and a reload compares the certificates, keys and roots it read, so a certificate renewed at the same path rebuilds the client (#1750). - The revocation check of a MyHealth@EU route speaks the minimum TLS
version of
[xcpd.tls], where it always admitted TLS 1.2 (#1750). ferrofed servedraws its startup banner with the family’sferrohealth_server::build_info::banner: the wordmark, the build lineferrofed <version>, and one aligned line per fact. The tagline, the maintainer, the manufacturer and its postal address are now the linesProduct,Maintainer,ManufacturerandAddress, and the facts are no longer split into groups by blank lines. The development notice and the support notification still follow the banner, in red on a terminal with colour (#1751).- The id a CDR names in the
ETagof a new EHR or a new version is read withopenehr-its’s entity tag, weak or strong, into the typedHIER_OBJECT_IDorOBJECT_VERSION_ID. A tag that names no such id is passed over for theLocation, where any non-empty tag was taken before. - Moved the
openehr-*crates to 0.0.135, with FerroBRIDGE’seehrxf0.1.187,fhirconnect0.1.241,openehr-mapping-core0.1.148,ferrobridge-validator0.1.154 andferrobridge-term0.1.148. The shipped laboratory mapping now carries a result’s typed reference ranges in both directions, where they were declared lost on receipt and left out when served. The stored-query registry, its routes and the operator console read a qualified query name, a version and a version pattern withopenehr-its’s ITS-REST types: the names and versions accepted and refused are the same, and the registry still refuses the reservedaqlwherever a name arrives. The console’s query form now refuses a stored query name or version outside the ITS-REST grammar before it asks the gateway, and a gateway answer that names one is reported as unreadable. - The FHIR face reads a member’s prescription search page through the
fhir-typesvalue tree. A page whoseentry,link,fullUrl,search.mode, resource or identifier holds a JSON value of another kind than its FHIR JSON form is still unusable, and the reason now names that element, where it said the answer was no FHIR resource in JSON (#1755). - A member’s own patient summary is read for its
Bundle.typeandBundle.identifierthrough thefhir-typesvalue tree, and an element FHIR R4 does not define still never refuses it. A success that is no documentBundlewith an identifier is now logged atWARN, naming the member’s endpoint and what the answer misses, never a value it carries (#1760). [xcpd.audit_repository]reads its TLS material throughferrohealth-server’sTlsClient, as every other outbound table does, and reaches the ITI-20 syslog repository through the platform’s verifier with the extra trust roots, at TLS 1.3 unlessmin_versionlowers it (The audit repository, #1766).SECURITY.mdnamessecurity@cadasto.comas the email route for a vulnerability report, in place ofinfo@cadasto.com, which stays the single point of contact for complaints and incidents and the address for licensing and other business questions.- The technical documentation quotes Art 39(5) and Art 39(6) of Regulation (EU) 2025/327: issuing the EU declaration of conformity is the manufacturer’s act, and the generator keeps the order of Annex IV until the Commission publishes its uniform declaration template.
Removed
- The flat
[auth] issuer = "…"form and the[auth]keysaudience,algorithms,clock_skew_seconds,jwks_max_age_seconds,jwks_refresh_interval_seconds,jwks_file,timeout_ms,modeandrole_claims, each refused naming the[[auth.issuer]]table it moved to (#1682). - The
[[trust.issuer]]keysintrospection_endpoint,client_id,client_secretandclient_secret_file: an introspected issuer is an[[auth.issuer]]withmode = "introspection"(#1682). [[auth.basic.users]],[metrics] scrape_users,[pmir] feed_users,[trust.issuer.requester],[trust.issuer.assurance]and[trust.issuer] client_tokens_act_for_professional, with the gateway’s own assurance-level type and claim readers (#1704).- The named refusals of keys v0.0.16 and earlier releases removed or
renamed are deleted, under the one-release rule for removal aids; each
such key is now refused as an unknown key. They are
[dev](removed in v0.0.14) and the[server]keys v0.0.16 renamed (server.public_url,server.replica,server.bindings_drain_timeout_msandserver.overload_retry_after_s). The Upgrade notes of the release that removed each key give its step. The refusals of the keys v0.0.17 removed stay for this release, since v0.0.17 was never released (#1787).
Fixed
-
A national contact point’s token that carries what a conforming eHDSI HP Identity Assertion provides is admitted. The gateway no longer asks for the ten Implementing Regulation (EU) 2026/2099 Annex fields, which the assertion does not carry:
ferrohealth-smart0.9 holds the token to the MyHealth@EU contact-point profile (eHDSI SAML Profile 10.0.0 §2.1, §2.3), the professional is named by the assertion’sNameID, and a purpose of use other thanTREATMENTorEMERGENCY, or a token missing the facility type, the home community, the point of care, the authentication class oron-behalf-offor an ISCO44role, is refused403 contact-point-attributes-required(#1632). -
The store’s readiness check no longer logs a warning on every failed probe while the database is down. It logs the change to down once, a reminder at most every five minutes while the store stays down, and one line, with how long the outage lasted, when the store answers again (#1644).
-
ferrofed reportshows the[store]table by name: its connection string as every URL is shown, scheme, host and port, its file paths redacted and its bounds kept, where it hid the whole table before. -
A key v0.0.16 renamed (
server.public_url,server.replica,server.bindings_drain_timeout_msandserver.overload_retry_after_s) is now refused at start-up and inconfig checknaming the key that replaces it, whether it is set in the file or as aFERROFED__override (#1653). -
A national contact point’s TRC patient that the cross-reference cannot resolve is logged as its own security event,
trc-patient-unresolved, naming the treatment relationship confirmation, on the ITS-REST and FHIR faces; it was logged aspatient-context-unavailable, the event of apatient/grant. The answer stays424 patient-context-unavailable. The metrics page lists every security event (#1662). -
The Annex II checklist page is rendered from
conformance/annex-ii.tsv, so the two no longer disagree, and every issue it cites is open and tracks its gap. Its notes now say that laboratory reports are served and received, and that hazards I-03, I-16 and I-18 stay open pending their review (#1668). The clinical safety risk file, the receiving page and the rights page record what FerroBRIDGE’s closed #1323 and #1481 built and what remains. -
The regulatory status, claims and category crosswalk pages say that laboratory reports and prescriptions from openEHR medication data are built, and they cite only open issues for discharge reports and imaging. The checks page says why no offline check can catch a closed issue cited as remaining work.
-
On the FHIR face, a national contact point’s
404is the one no-data answer of the treatment relationship binding whichever route answered it: the error layer reads the caller from the mark the gate sets on its answer, where it read a caller its position outside the gate never saw (#1691). -
A development gateway with no access log (
[audit] destination = "off") now counts a national contact point’s emergency accesses against[emergency]and denies them past the limit (eHDSI Section II Security Services 10.0.0 §2.5), where it granted every one uncounted. Production always keeps an access log and was not affected. -
A national contact point’s discovery-phase token, issued from the HP Identity Assertion alone before the patient is identified (eHDSI SAML Profile 10.0.0 §4), is admitted. It reaches the identification lookup,
GET {fhir-base}/Patient?identifier=, and a request for patient data under it is refused403 trc-missingwith nothing sent. It was refused at admission ascontact-point-attributes-required(#1745). -
A laboratory or discharge report the FHIR face writes from a member’s composition now passes its HL7 Europe document profile. Its
Patientcarries the name and birth date the demographics binding holds, and each one the binding does not hold is written with adata-absent-reasonofunknown(HL7 Europe Base 2.0.1patient-eu-core,eu-pat-1). The member the report was read at is aComposition.author, so the document rules of FHIR R4 admit itsOrganization. A demographics binding that cannot answer, or names no one patient, refuses the read with anOperationOutcomeand no document is written (#1777). -
The production guide’s example node grant authenticates with
private_key_jwtand aprivate_key_file, and says how to make that key. Theclient_secret_basicgrant it showed is refused byferrofed config checksince a node’s grant takes no client secret (§13.1, N25). -
The performance gate builds the base and the head once, in a job of its own, and hands them to each attempt as an artifact, so a slow build no longer uses up an attempt’s time limit. An attempt cancelled while the run goes on (a runner that shut down, or a job that reached its time limit) is followed by the next attempt instead of failing the gate. No threshold changes (#1807).
Security
Shipped with functional changes because: the ferrohealth-smart 0.9.1 fix (GHSA-jhg2-jwjp-hq74) comes only on the 0.9 line, whose breaking configuration and API every family crate the gateway links moves with, so no security-only patch of v0.0.16 can carry it; the 0.8.9 fix (GHSA-pfgx-xq28-2629) is superseded by it, and RUSTSEC-2026-0440 affects no shipped artefact (the VEX statement below records why). The ferrohealth-smart 0.13.0 fix (GHSA-r5p6-r9m2-c3pw) comes with the error type change its crate made breaking, so it ships on the same line, and the 0.13.1 patch (GHSA-5qm7-v4fw-p2v9) is taken on that line in the release that carries it.
- RUSTSEC-2026-0440 (
astral-tokio-taris unmaintained, with no patched version) does not affect FerroFED:testcontainersreaches the crate only in the test harness, and the server binary, theferrofedimage and theferrofed-consoleimage do not contain it.deny.tomlaccepts the advisory until atestcontainersrelease drops the crate (security/vex/rust-advisories.openvex.json). - Role claims hold per issuer (GHSA-pfgx-xq28-2629, CWE-863, CWE-639).
Before it, beside several
[[auth.issuer]]entries,[auth] role_claimsapplied to every trusted issuer’s tokens.ferrohealth-smart0.8.9 read an entry written<issuer>#<path>from that issuer’s tokens alone, and the 0.9 line this release runs on moves the list onto each[[auth.issuer]]table asrole_claims, read from that issuer’s tokens alone (#1682). Upgrade step: removerole_claimsfrom[auth], which now refuses the start naming its replacement, and set it on an issuer only where that issuer’s tokens carry roles (the gateway reads the professional’s roles from the IUAsubject_role).scopeless_subjectsstays refused as before. - A Basic user never holds a
system/scope, throughferrohealth-smart0.9.1 (GHSA-jhg2-jwjp-hq74, CWE-269, CWE-863). Before it, an[[auth.basic.users]]entry granted asystem/scope gave that user every resource of a type, the reach of a backend service. Such an entry now refuses the start, naming the user and the scope. Upgrade step: this release removes[[auth.basic.users]]altogether (#1704, under Removed), so move a person on Basic to apersonsissuer, and a machine to a client credentials client of amachinesorpersonsissuer (RFC 6749 §4.4). - A JSON document the gateway could not read no longer reaches its log
(GHSA-r5p6-r9m2-c3pw, CWE-532, CWE-209; upstream GHSA-mhv7-3975-6xq3).
Before it,
ferrohealth-smartbefore 0.13.0 refused such a document with a source error quoting the value the reader stopped at, and the gateway printed that chain when a start failed and logged it atwarnwhen a node’s grant obtained no token. Ajwks_fileholding a private or symmetric key, or a token endpoint answer of another shape, could put key material or an access token into standard error or the log. This release runs onferrohealth-smart0.13.0, whose refusals name only the category, line and column (#1748). Upgrade step: none beyond the upgrade; rotate any key or token a log line may have recorded. - A refused key set, introspection or metadata answer no longer keeps its
body in the error that carries it (GHSA-5qm7-v4fw-p2v9, CWE-532,
CWE-209).
ferrohealth-smartbefore 0.13.1 kept the body, up to 1 MiB, inKeySetStatus,IntrospectionError::StatusandDiscoveryError::Status, and printed it in theirDebugrendering, so a product that logged one with{:?}could write token or key material an upstream put in that body to its log. The gateway logs these errors by theirDisplayalone, which never carried the body; this release runs on 0.13.1, which keeps the body out of the error. Upgrade step: none beyond the upgrade.
0.0.16 - 2026-11-05
Security fixes until 2031-11-05, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- Write
server.public_url,server.replicaandserver.bindings_drain_timeout_msasgateway.public_url,gateway.replicaandgateway.bindings_drain_timeout_ms, andserver.overload_retry_after_sasserver.retry_after_s, with theFERROFED__overrides to match (FERROFED__GATEWAY__PUBLIC_URL). This release refuses each old key at start-up and inconfig check(#1490). - Nothing in an operator console’s configuration has to change (#1504). The
[oidc]endpoints are now optional: removeauthorization_endpoint,token_endpointandjwks_urito have the console discover them fromissuerwhen it starts, which then needs the provider reachable at start. A reverse proxy in front of the console passes/sessionthrough to it. - Remove
[server] site_rootandFERROFED_CONSOLE__SERVER__SITE_ROOTfrom the operator console’s configuration: the console serves no site bundle, and this release refuses the key at start-up and inconfig check, naming it (#1512).
Added
- The document list names the laboratory reports of a member that serves
them itself as an HL7 Europe EU Health Data API Document Access Provider
(an
hl7-fhir-restendpoint declared forLaboratory-Reports): the member is asked by its ownehr_idalone withPatient?identifier=andDocumentReference?patient=&type=, and is left out of the laboratory report query. Each of itsDocumentReferences is listed as it holds it, with each attachment URL readdressed to aBinary/{token}URL the gateway signs, and the read answers the member’sBinaryunchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded underLaboratory-Reports. - The FHIR face withdraws a dispensation it received:
PUT {fhir-base}/MedicationDispense/{id}withstatusentered-in-error, by the id the receipt’sLocationnamed, is held toMedicationDispense-eu-mpd, read at the member that id names, and sent there once as an update by the member’s own id, its body unchanged but for its top-levelid(FHIR R4http.html§3.1.0.4). A body whoseidis not the request’s is400, any otherstatus422, the member’s refusal409and its silence504; the withdrawal is recorded as an update underElectronic-Dispensations, andmetadatadeclaresupdateforMedicationDispense. MyHealth@EU 07.04 is met (#1468). - The operator console serves the session watch,
GET /sessionandPOST /session, which answer the idle and absolute lifetime the session has left and401once it is gone, so a page warns before the idle timeout and every tab ends together (#1505). - The book shows how to receive a laboratory report on the FHIR face: the
[[fhir.receive]]member forLaboratory-Reportswith the HL7 Europe Laboratory Report 2.0.0 package, theKDS_Laborberichttemplate and the laboratory mappingeehrxfships, and what the composition keeps of a result. A server test now receives a synthetic report end to end. - The document list (
GET {fhir-base}/DocumentReference) names each member’s laboratory reports when a[[fhir.receive]]entry receivesLaboratory-Reports: oneEehrxfMhdDocumentReferenceof the type11502-2per laboratory composition, read at aBinary/{token}URL the gateway signs, which writes the composition as an HL7 Europe Laboratory Report 2.0.0Bundle-eu-labthrough the laboratory mapping ofeehrxf. A member that does not answer the laboratory query fails the list under all-or-nothing, and each list and read is recorded underLaboratory-Reports. - Two read-only stored queries in the reserved namespace,
eu.ferrofed.eehrxf::medication-ordersandeu.ferrofed.eehrxf::medication-actions: each patient’s compositions that hold a medication order (imported copies left out) or a medication action, with each composition’s uid and template, which the prescription face’s map mode reads a member through. - A
[[fhir.serve]]member ofElectronic-Prescriptionscan be declared in the map mode,{ member = "node-a", mode = "map", template = "…" }, with its operational template: an OPT 1.4, or an ADL 2 template with the archetypes it specialises. The face then reads the member’s medication orders over ITS-REST witheu.ferrofed.eehrxf::medication-orders, keeps the compositions the member’s own system created, and maps each into an HL7 Europe MPD 1.0.0MedicationRequest-eu-mpdthrough the MPD project ofeehrxf, listed beside the native members’ prescriptions and read by the id the search names. The start andconfig checkrefuse a template the project does not compile against. - A dispensation of a prescription a map-mode member owns is written at
that member as one openEHR composition: the received
MedicationDispense-eu-mpdis mapped by the MPD project ofeehrxf, itsACTIONnames the order it carries out by the order’sOBJECT_VERSION_ID, and the face creates it once withcomposition_create. A map-mode member named inElectronic-Dispensationsnames thelanguageandterritoryits compositions are written in, andconfig checkrefuses it without them. A dispensation the owner already keeps under the same business identifier is refused409with nothing written.
Changed
- Moved to the
openehr-*crates 0.0.116 with FerroBRIDGE’seehrxf0.1.142,fhirconnect0.1.212,openehr-mapping-core0.1.138,ferrobridge-term0.1.141 andferrobridge-validator0.1.143, the versions those crates require of one another. The ITS-REST decoders now read a+in a query value as a literal plus (RFC 3986 §2.1), so the gateway no longer rewrites a client’s+before decoding, and a node’s RFC 9457 problem details refusal is read through the crate. A+in a query value and a node’s refusal behave as before. - A FHIR document the gateway maps from openEHR writes an archetype-local
code under
https://ferrobridge.eu/fhir/CodeSystem/archetype-local/, the baseeehrxf0.1.142 writes, in place ofhttps://ferrofed.eu/fhir/CodeSystem/archetype-local/. A consumer that matches the old system URI matches the new one. [[fhir.receive]]can name a receiving member forLaboratory-Reports, whicheehrxf0.1.142 receives as an HL7 Europe Laboratory Report 2.0.0 document;config checknames it among the categories with no receiving member until one does.- The remaining-dispensations extension is named
https://ferrobridge.eu/fhir/StructureDefinition/remaining-dispensations, aseehrxf0.1.142 defines it and FerroBRIDGE publishes it, and the documentation site no longer serves a copy athttps://ferrofed.eu/fhir/StructureDefinition/remaining-dispensations. The gateway still carries the extension as the member wrote it. - The operator console draws every page whole on its server with FerroUI
0.6.0 and runs no WebAssembly (#1457). A link loads its page as one
document, a form posts to the page it sits on and answers there, and
FerroUI’s small script modules add the menus, the dialogs, the row
editing, the find’s suggestions and the commit-confirm countdown on top;
every page reads and every action posts with scripts turned off. The page
no longer flickers or loses what was open when you act on it, and the
404 page no longer panics in the browser (#1458). The console’s
Content-Security-Policy admits scripts and stylesheets from its own
origin alone (
script-src 'self'; style-src 'self', no nonce and no'wasm-unsafe-eval'), and FerroUI’s stylesheet, theme and modules are served under/ferrohealth-ui/0.6.0/, cached for a year, in place of/ferrohealth-ui.css,/ferrofed.cssand the site bundle under/pkg/. The console image carries the binary alone. - The operator console moves to FerroUI 0.6.2 and
ferrohealth-server0.4.2. A select in a dense options row and a long identifier or link in a stacked table no longer scroll the page sideways at 320 px, and the code editor keeps its typing speed as a JSON or XML document grows. The console’s Content-Security-Policy is unchanged (script-src 'self'; style-src 'self', no nonce) and now comes from the family’s server-rendered console headers, FerroUI’s script modules are served under/ferrohealth-ui/0.6.2/, and About names FerroUI 0.6.2. - The gateway implements the Federation Tier with AQL 1.0.1, the erratum
to the routing of EHR-scoped version requests (§12.3, §12.4, §12.5.1,
N22, N23, N36, N41). A request under
{base}/v1/ehr/{ehr_id}/…, a version read or a versioned write, is located by the targeting header, the caller’s binding and theehr_idindex as before, and now, when none of them names a node and the path carries a version uid, by the member whosesystem_idis that uid’screating_system_id; an untargeted read of such a version reaches its creator without an ask-all probe, and a compositionDELETEwhose version a member created reaches that member where it was a400 target-required. A read that reaches a node holding an imported copy returns the copy withopenEHR-federation-system-idnaming that node, and a write that reaches it is refused409naming the controlling system and is never redirected.OPTIONS {base}/still reportsspec_version"1.0". - The operator console signs in and keeps its sessions through
ferrohealth-session0.1.1, the family’s console sign-in, overferrohealth-smart’s relying party; its own sign-in, session store and code exchange are gone (#1504, #1506). The routes stayGET /login,GET /auth/callbackandPOST /logout, and the cookies keep their names. - With
[oidc] issueralone, the console reads the provider’s endpoints from its discovery document at start (OpenID Connect Discovery 1.0 §4); a missing or unreachable document refuses the start, andconfig checknever asks the provider.[oidc]gainsrevocation_endpoint,ca_fileandtimeout_s, and[session]gainsstore,keyandkey_filefor a console that runs as several instances. - A sign-out with no
[oidc]ends the session and lands on/in place of answering503, and a refresh the provider cannot be reached for keeps the session for the next request in place of ending it. - Moved to FerroPIX’s
ihe-iti0.0.73 andnl-generic-functions0.0.40 (#1516). The mCSD directory is read throughihe-iti’sDirectoryEntry<Endpoint>, and the NVI client and the Nuts grant take the request authorizerihe-itinow carries. The gateway’s behaviour does not change. - Moved to FerroBRIDGE’s
eehrxf0.1.149 withfhirconnect0.1.218,openehr-mapping-core0.1.141 andferrobridge-validator0.1.144, on theopenehr-*crates 0.0.128. - The patient summary selects its medication entries by the template
section that holds them, as
eehrxf’scrosswalk::patient_summary::SOURCESplaces them: the medication summary takes a medication order under an ad hoc section, and the immunisations a medication action under the vaccination list section. A vaccine recorded as a medication action no longer reaches the medication summary, and a medication action under any other section reaches no section; record current medication as orders. - A
[[fhir.mapping]]takes an ADL 2templatewith thearchetypesit specialises, beside an OPT 1.4.opt. - Moved to
ferrohealth-server0.4.3,ferrohealth-session0.2.2,ferrohealth-smart0.8.8,ferrohealth-ui0.6.6,ihe-iti0.0.74,nl-generic-functions0.0.41, and FerroBRIDGE’seehrxf0.1.152 withfhirconnect0.1.219. A received document’s element that a status table of the mapping carries is no longer reported as lost, and the imaging and discharge codings carry the HL7 Europe EU Health Data API displays. The operator console draws its pages with FerroUI 0.6.6, and the book’s console screenshots are taken again. - The operator console’s sign-out goes to the provider’s
end_session_endpointonly when the session it ends holds an ID Token (OpenID Connect RP-Initiated Logout 1.0 §2). A sign-out with no session left, or one after a Basic sign-in, goes back to/.
Removed
- The operator console’s site bundle (its WebAssembly, the
/pkg/route that served it compressed, and the bundle budget), thescripts/release/console-site.sh,console-tools.sh,scripts/checks/console-bundle.shandconsole-paths.shscripts, and the Leptos, cargo-leptos, wasm-bindgen, wasm-opt and leptosfmt pins (#1457). A deployment that built the console itself runscargo build --release -p ferrofed-consolealone. openehr-federation0.0.57 dropsobject::Extra::newandorder::ResultOrder::unordered, each the same as the type’sDefault. A caller writesExtra::default()andResultOrder::default()(#1485).- The four
[server]keys that 0.0.15 still read under their old names:server.public_url,server.replicaandserver.bindings_drain_timeout_ms, now onlygateway.public_url,gateway.replicaandgateway.bindings_drain_timeout_ms, andserver.overload_retry_after_s, now onlyserver.retry_after_s. An old key is an unknown key, whichserveandconfig checkrefuse by its name, andconfig checkand the start-up log no longer name deprecated keys (#1490). [server] site_rootof the operator console, and itsFERROFED_CONSOLE__SERVER__SITE_ROOToverride: the console serves no site bundle, so the key is an unknown key, whichserveandconfig checkrefuse by its name (#1512).
Fixed
- The prescriptions page no longer describes a withdrawal of a dispensation
as routed: the FHIR face offers none, and a dispensation sent again under
the same identifier with
statusentered-in-erroris refused409duplicatelike any other, which a test now holds. The MyHealth@EU self-assessment row 07.04 says the same (#1450).
0.0.15 - 2026-10-30
Security fixes until 2031-10-30, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- A
[server.tls]or[metrics.tls]table that setsclient_ca_filemust setclient_auth = "required"beside it to keep requiring a client certificate (or"optional"to verify one only when a client presents it); without itconfig checkandserverefuse the table. A listener whose clients speak TLS 1.2 alone needsmin_version = "1.2"in its table, since the default is TLS 1.3. - Move a liveness probe of the gateway from
GET {base}/healthtoGET {base}/health/liveness.{base}/healthnow follows readiness and answers503while the gateway boots or drains, so a liveness probe left on it restarts a gateway that is draining (#1397). - Move the OTLP keys under
[telemetry.otlp]before upgrading; the old keys are refused as unknown.[telemetry] otlp_endpointbecomes[telemetry.otlp] endpoint,[telemetry] trace_sample_ratiobecomessample_ratio, and thetrust_roots_fileandclient_identity_fileof[telemetry.otlp_tls]move into[telemetry.otlp].[metrics] otlp_endpointbecomestelemetry.otlp.metrics = true(the default) beside the sameendpoint, and[metrics.otlp_tls]goes with it: the spans and the metrics now share one collector. A deployment that pushed only metrics setstraces = false; one that exported only spans setsmetrics = false, or its collector receives the metrics too. An inlineclient_identityis no longer read: write the PEM to a file and name it inclient_identity_file. [server]is the section offerrohealth-server, and the gateway’s own keys moved to a new[gateway]table: moveserver.public_url,server.replicaandserver.bindings_drain_timeout_msthere, and renameserver.overload_retry_after_stoserver.retry_after_s. This release still reads each old key under its new name and names it inconfig checkand the start-up log; FerroFED 0.0.16 refuses it, and setting an old key beside its new one is refused now. TheFERROFED__overrides follow:FERROFED__GATEWAY__PUBLIC_URL,FERROFED__GATEWAY__REPLICA.server.base_pathwrites the root as the empty path, its default: removebase_path = "/", or writebase_path = "". A/is refused.[server.caller_rate]needs bothrequests_per_secondandburst, where each defaulted (10 and 20) before.
Added
- The book’s page “MyHealth@EU self-assessment” records FerroFED’s own reading of every requirement id of the MyHealth@EU Requirements Catalogue v10.0.0: whether it binds the gateway, its status and the decision, page or test that shows it. The README carries a MyHealth@EU badge counting the rows met among those that bind the gateway; it is a self-assessment, not a MyHealth@EU compliance check or Operation Ready status, and carries none of the catalogue’s text, which is privileged-access (#1423).
[server] idle_timeout_ms(default 120000) closes a kept-alive HTTP/1.1 connection that sends no next request within that time, with a clean end of stream.server.header_read_timeout_msnow counts from the first byte of each request head: it used to count from the end of the previous answer, so it also closed an idle connection after 30 seconds, sometimes while a client was already sending its next request on it. Keepidle_timeout_mslonger than the idle timeout of the clients and proxies in front of the gateway, so they close an idle connection first. A change of the key waits for a restart. Both come withferrohealth-server0.4.0.
Changed
- The operator console runs on FerroUI 0.5 and hydrates the whole page: the server draws the first page and the browser draws every page after it, so following a link or posting a form no longer loads a new document. Every page reads and every action is a server function behind a form; with WebAssembly turned off every page still reads and every action still posts, its answer drawn on the page it came from. The removal of a member, a credential or a binding section from a configuration table still loads a new document once the page has hydrated, until FerroUI’s confirmation dialog takes a server action (#1414). The query console’s address keeps only the stored query and the version: any other key in it is dropped with a redirect, so nothing a link puts there is echoed into the page. The console’s WebAssembly grows to about 557 KB compressed, its budget raised to 680 KiB.
- The client listener and the admin listener are served, drained and given their TLS by
ferrohealth-server. The admin listener now drains with the client listener onSIGTERM, within the sameserver.drain_delay_msandserver.shutdown_timeout_ms. A listener with TLS speaks TLS 1.3 alone unless its table setsmin_version = "1.2", which keeps TLS 1.2 clients with the four ECDHE AES-GCM suites of RFC 9325 §4.2, and it offers HTTP/2 beside HTTP/1.1.[server.tls]and[metrics.tls]takeclient_auth(none,optionalorrequired): a client certificate is asked only when it says so, and aclient_ca_fileunderclient_auth = "none", the default, is refused, where it used to mean a required certificate. The tables also takereload_interval_s, which reads the files again on that period besideSIGHUPand an applied configuration revision.[server] header_read_timeout_ms(default 30000) closes a connection that does not send a request head in time. A refusal of a listener’s TLS names its table and the key at fault. - The health routes are the FerroHEALTH family’s, served by
ferrohealth-server0.3.7 over the indicator registry and lifecycle offerrohealth-health0.1.1; FerroFED’s own registry, lifecycle and readiness document are deleted (#1397).GET {base}/health/livenessis the liveness route,200while the process serves.GET {base}/healthnow answers the status of readiness with{"state":"up"}or{"state":"down"}, so it is503while the gateway boots or drains.GET {base}/health/readinesslistsindicatorsas an array of{"name", "state"}sorted by name, where it was an object keyed by name, and carries nodetail. Point a liveness probe at{base}/health/liveness: the example Kubernetes StatefulSet does, and a deployment that copied it before moves itslivenessProbepath from/health, or the kubelet restarts a gateway that is draining. A monitor that parsed the readiness body reads the array. - The gateway and the operator console hold every credential in the
secret types of
ferrohealth-secret0.2.0, the FerroHEALTH family crate, and read every<key>_filesibling by its key-or-file rule; FerroFED’s own copy inferrofed-registryis deleted (#1397). A secret file now loses only its trailing line ending, where FerroFED trimmed white space at both ends: a file whose secret starts or ends with a space or a tab now passes it on as written, so a deployer whose secret files carry such white space by accident removes it before upgrading. The refusals read as the family words them:<key> and <key>_file are both set; set exactly one,<key>_file names <path>, which could not be readand<key>_file names <path>, which is empty. - The installation report is sent by
ferrohealth-usage-report0.2.0, the FerroHEALTH family crate, on FerroPULSE’s envelope v2; FerroFED’s own section, envelope, reporter, state directory and window are deleted and the gateway supplies only itsstatus, its route groups and its node counters (#1397). The[usage_report]keys and their defaults are unchanged, so no configuration changes. A daily report now leaves out a counter that counted nothing in its window where it sent0, the boot line with the report on ends with the family’s wording (“no clinical content, identifiers, URLs or request paths”), and anendpointthe collector rule refuses is refused as an invalid value ofusage_report.endpointwhile the configuration is read. - The console, the request log, the HTTP server metrics, the Prometheus exposition and the OTLP export are
ferrohealth-telemetry0.2.3, the FerroHEALTH family crate; FerroFED’s own telemetry module and request log are deleted (#1397). The request line is written under the targetferrohealth_telemetry::request, with the fields it had.http.server.request.durationfollows the OpenTelemetry HTTP conventions to the letter: its buckets end at 10 seconds, where FerroFED added a 30-second bucket, it carriesnetwork.protocol.version, and the methodQUERYis named rather than_OTHER.GET /metricsanswers with the content typetext/plain; version=0.0.4, without thecharsetparameter. One[telemetry.otlp]table now names the collector both the spans and the metrics go to, withtracesandmetrics(bothtrueunless set),sample_ratio,trust_roots_fileandclient_identity_file;[telemetry] environmentnamesdeployment.environment.nameon both.[telemetry] logged_query_parametersis refused, since the request log carries theoffsetandfetchpaging values alone. A collector’s TLS files are read and checked whenservestarts, whereconfig checkused to read them too. - A
[server]value of the wrong type or form (listen,base_path,trusted_proxies, a zeroheader_read_timeout_msormax_concurrent_requests, a[server.tls]without itscertificate_fileorkey_file) is refused as the configuration loads, naming the key, where it was refused as the configuration resolved. The address a request came from is read byferrohealth-server, behind the sameserver.trusted_proxiesandserver.forwarded_header. - The gateway reads its configuration with the family’s loader
(
ferrohealth-config0.2.8). Every subcommand takes--set key=value, repeatable, applied over the file and theFERROFED__overrides, and a reload of the file applies the same pairs again. A refusal names the dotted key and the variable or--setpair that set it, with the closest known key for a misspelt one, and quotes no value; a file that is not TOML is named by its line and column. The running configuration names a value a--setpair set asset. An override such asFERROFED__LISTENwith no section now names a top-level key, and is refused as unknown where it was refused for its shape. - Every error the gateway composes outside the FHIR face is an RFC 9457
problem details document,
application/problem+json, with the status unchanged. Itstypeis the code’s anchor on the book’s Errors and status codes page, itsdetailthe sentence the ITS-RESTErrorcarried asmessage, itsinstancethe request id as aurn:uuid:URI, andcodeandrequest_idstay as extension members, so a client that branches oncodekeeps working. A client that readmessagereadsdetail. A failed fan-out still answers itsRESULT_SET, a node’s own answer on a single-node route still passes through as the node sent it, and the FHIR face still answers anOperationOutcome. The operator console reads the new body. - A
408past the request timeout and a413over the body limit answer anabout:blankproblem with the gateway’s request id asinstance, where they answered an empty and a plain-text body. - A member that refuses with RFC 9457 problem details, as FerroEHR 5.6.0
does, is reported in
meta.federation.endpoints[].errorby the problem’sdetail, or itstitlewhen it has no detail, bounded and masked as every node message is. The whole JSON document was quoted before. - The operator console removes a member, a credential or a binding section from a configuration table in the page, as every other configuration form does, once the page has hydrated: the confirmation dialog runs the removal through the configuration’s server function and closes when the removal is saved, and stays open with the refusal otherwise. With no WebAssembly it still posts the form.
- The access record of a routed openEHR read or write names the object it
reached as
AuditEvent.entity[data].what.identifier(the openEHR path, such as/ehr/<ehr_id>/composition/<uid>, with nosystem), where it wrote that path aswhat.reference, which is no FHIR literal reference (FHIR R4 references.html §2.3.0.2, §2.3.0.3); an Audit Record Repository that checks references, HAPI FHIR among them, refused those records. A read on the FHIR face keepswhat.reference(MedicationRequest/<id>). This comes with FerroAUDITihe-atnaandehds-logging0.3.2. - The client listener serves through the stack of
ferrohealth-server0.3.10: the request id, the request log, the client address, the panic catch, the request timeout, the body limit, the overload bound and the caller rate are the crate’s, with the gateway’s error bodies, and the FHIR face keeps its own base besideserver.base_path. An answer is now compressed with gzip or brotli for a client that accepts it, once it is 1024 bytes or longer:server.compression = falseturns it off andserver.compression_min_bytessets the size. - A
500 internalfor a request whose handler panicked, a503 overloadedand a429 rate-limitedname the gateway’s own request id in the body’srequest_id, where they named the client’sX-Request-Id; the response header still echoes the client’s id. - The panic line comes from the family’s panic hook (
ferrohealth-telemetry0.2.4): it is logged under the targetferrohealth_telemetry::panicwith the messagea thread panicked; the message is withheld, and names the source asfileandlinewith thethread, where FerroFED’s own hook wrotea thread panickedwith onelocationfield. It still names the request by the gateway’srequest_idand never the panic message. A log alert that matched the old target, message orlocationneeds the new ones. - Decisions A62 and A67 to A72 and the book’s page “A national contact point” are re-checked against the MyHealth@EU Requirements Catalogue v10.0.0 (2026-03-06), read under a reading grant and cited by section and requirement id only: they hold, A70 now says the gateway never refuses a contact-point request on the professional’s role or point of care, A72 leaves the PDF of the original summary to the contact point, rendered from the narrative, and the page says how the face’s failures map to temporary and permanent ones (#1424).
- When the member that owns a prescription refuses a dispensation (
409) or answers it with a5xx(502), the face’sOperationOutcomenow carries the member’s own issues after the gateway’s, unaltered, when the member’s body is anOperationOutcome, so a national contact point can relay why the prescription was not dispensable (MyHealth@EU Requirements Catalogue v10.0.0, §1.25) (#1426). - A national contact point’s token that lacks an Implementing Regulation (EU) 2026/2099 Annex attribute is still refused
403 contact-point-attributes-required, and the message now names each attribute that is missing, empty or not admitted by its Annex table and name, never its value, so the connector can tell the professional what to complete (MyHealth@EU Requirements Catalogue v10.0.0, §1.25) (#1427). - The patient summary lists a composition two members hold under one full version id (an imported copy, Federation Tier §10.3) once, from the member whose
system_idis the version’screating_system_id, else from the lowest endpoint id, and the section’s narrative names the members whose copy it left out; two different compositions stay listed (MyHealth@EU Requirements Catalogue v10.0.0, 10.03; A65 amended) (#1428). - The compose quickstart and the end-to-end harness run FerroPIX 0.0.12,
pinned by digest. Its operator API now answers errors as RFC 9457 problem
details, and it refuses a
[server.tls]table that setsclient_ca_filewhileclient_authis"none"; the quickstart’s FerroPIX configuration sets neither, so it needs no change (#1437). - The operator console runs on the FerroHEALTH shared crates: its
configuration is read with
ferrohealth-config, so every job takes--set key=value, a refusal names the key and the variable or pair that set it, andconfig checkprints the running configuration with every secret redacted; it serves and drains throughferrohealth-server, with its browser security headers and same-origin check; and it logs throughferrohealth-telemetry, whose[telemetry]section andRUST_LOGchoose what it logs. It serves the family’s health routes:GET /health/liveness,GET /health/readiness, which answers503once the console drains, andGET /health, which follows readiness and now answers{"state":"up"}where it answered{"status":"up"}. The example Kubernetes Deployment probes liveness and readiness on their own routes. ferrofed healthcheckand the live parts offerrofed reportask the gateway through the healthcheck client offerrohealth-server, which trusts only the certificateserver.tls.certificate_file(ormetrics.tls.certificate_file) holds and presentsserver.tls.healthcheck_identity_file, as before. The healthcheck prints the same lines with the same exit codes. A report part the gateway did not answer names the cause in the crate’s words, such ascannot connect to 127.0.0.1:8080.ferrofed healthcheckprints the line every FerroHEALTH product’shealthcheckprints, fromferrohealth_server::healthcheck::Outcome. The oldreadyis nowhealthy;not ready: readiness answered <status>is nowunhealthy: readiness answered <status>;not ready: no answer within 3000 msis nowunhealthy: readiness did not answer in time;not ready: no connection could be made,not ready: the TLS handshake failed: …,not ready: the request failed: …and thenot ready: …of a configuration that does not load are nowunhealthy: <reason>, with the cause chain as the reason; and the replaced listener certificate readsunhealthy: the listener presents a certificate other than server.tls.certificate_file holds; send SIGHUP so the gateway reads it. The exit codes are unchanged (0only on200,1otherwise). A probe, log rule or alert that matchedreadyornot readymust matchhealthyandunhealthyinstead (note thathealthyis a substring ofunhealthy).- FerroFED runs on
ferrohealth-server0.4.1, which keeps a product’s own fallback and the layers around it when the stack merges its routes (FerroHEALTH/FerroSYS#199). FerroFED was not exposed to the regression 0.4.1 fixes: the client surface and the FHIR face declare their own fallback inside the client authentication gate, so an anonymous request to a path no route matches under/v1/or/operator/is refused with401before and after. - The interoperability component is the published
eehrxf0.1.128 from FerroHEALTH/FerroBRIDGE, pinned exactly, in place of the crate this repository built (owner decision of 2026-10-28, decision A76). Every change to the component is made in FerroBRIDGE, and its pages are FerroBRIDGE’s book (https://ferrobridge.eu/docs/evaluate/interoperability-component.html); this book keeps the gateway’s routes, its section queries and the access record.fhirconnectmoves to 0.1.204,openehr-mapping-coreto 0.1.135,ferrobridge-termandferrobridge-validatorto 0.1.141 and theopenehr-*family to 0.0.109, the versions the component requires. A resource the patient summary maps from a composition now takes the FHIRconnect deterministic id of the composition uid and its entry path (FHIRconnect v1.0.0, engine/id-management.adoc, Deterministic IDs). The extension definition the site serves athttps://ferrofed.eu/fhir/StructureDefinition/remaining-dispensationsis read from the published crate.
0.0.14 - 2026-10-28
Security fixes until 2031-10-28, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- A dispensation sent to
POST {fhir-base}/MedicationDispensemust carry its own business identifier, anidentifierwith asystemand avalue; one without it is refused422required, namingMedicationDispense.identifier. Have the pharmacy systems and the national connector that send dispensations through the gateway set it (#1346). - Remove
[dev]from your configuration:[[dev.crossref]]and[[dev.consent_denied]]are refused at load. Resolve patients through a PIX Manager with[[pixm.manager]], as the quickstart does with FerroPIX (#1377). - If you edited the quickstart’s
compose.yamlordocker/quickstart/ferropix/ferropix.toml, carry FerroEHR 5.5.0’s and FerroPIX 0.0.10’s renames over: inferroehr-config,server.bindisserver.listen, each Basic user’s roles are its ownroleslist in place of[authz.rbac.basic_roles], and[ehr_index] provider = "local"is required; each node setsFERROEHR_CONFIG,FERROEHR__REST__SYSTEM_IDandFERROEHR__TELEMETRY__FORMATin place ofFERROEHR__SERVER__SYSTEM_IDandFERROEHR__LOG__FORMAT; FerroPIX’sserver.public_base_urlis the top-levelpublic_base_url. Rundocker compose down -vonce before the first start on the new images (#1386).
Added
eehrxf0.0.32 carries the discharge report crosswalk under itsdischargefeature: each Xt-EHREHDSDischargeReportelement with the eHealth Network ids of the Guideline on Hospital Discharge Report Release 1.1, its producer obligation, and the element of the HL7 Europe Hospital Discharge Report 1.0.0 composition that carries it, checked against the vendored packages. A crosswalk row now admits any context profile where the profile element references any resource (#1333).eehrxf0.0.33 carries the laboratory report crosswalk under itslaboratoryfeature: each Xt-EHREHDSLaboratoryReportelement with the eHealth Network ids of the Guideline on Laboratory Results Release 1.1, its producer obligation, and the element of the HL7 Europe Laboratory Report 2.0.0 composition orDiagnosticReportthat carries it, checked against the vendored packages. A crosswalk names companion profiles beside its document profile, andCrosswalk::check_withholds its rows to all of them (#1337).eehrxf0.0.34 carries the imaging crosswalks under itsimagingfeature: the Xt-EHREHDSImagingReportelements against the HL7 Europe Imaging Report 1.0.0-ballot composition and itsDiagnosticReport, and theEHDSImagingStudyelements against itsImagingStudy, each with the eHealth Network ids of the Guideline on Medical imaging studies and reports Release 1.1, checked against the vendored packages (#1339).- The patient summary carries the safety controls of the review of 2026-10-25
(#1327): the medication summary marks two members’ entries of one medicine,
strength and route as a probable duplicate in its narrative, naming both
members and merging nothing (#1343); the medical alert and the functional
status are always written, as
unavailable, with a narrative saying the gateway does not supply them (#1344); the medication summary also selectsopenEHR-EHR-INSTRUCTION.medication_order.v3, and a mapping whose context does not apply to a composition leaves it to the section’s other mappings (#1345); every element a mapping run dropped is named in its section’s narrative by path and member (#1350). - A dispensation received on the FHIR face is checked against the owner’s
dispensations before it is written: the gateway reads the patient’s
dispensations at the owner’s own endpoint, by its
ehr_idalone, matches the received one’s business identifier at the gateway, and refuses one the owner already holds with409duplicate, naming it, with nothing written. An owner that does not answer that read refuses the dispensation. A dispensation a client sends again after a time-out is never written twice (hazard I-12, #1346). eehrxfwrites the laboratory report and the discharge report documents. The laboratory report is aBundle-eu-labof HL7 Europe Laboratory Report 2.0.0, with theDiagnosticReport-eu-labbeside its composition; the two hold one identifier, type, subject and category and each names the other, and the results go in sections coded by study type (#1355). The discharge report is abundle-eu-hdrof HL7 Europe Hospital Discharge Report 1.0.0, with the encounter it reports on and its language (#1357). The patient summary and both reports share one assembly,document::Form.eehrxfwrites the imaging report document, aBundleReportEuImagingof HL7 Europe Imaging Report 1.0.0-ballot with theDiagnosticReportEuImagingbeside its composition. The composition’s type is the report’s code, both carry the imaging-report andMedical-Imagingcategories, and its organization authors are the report’s performing organizations, as the profile’sdr-comp-author-orgrequires, with the operator organization as custodian (#1375).- The node profile observes consent before release (N27, CP-19) at
FerroEHR: the harness sets a restriction of processing (GDPR Art. 18) on
one of two compositions through FerroEHR’s ADMIN API, and the new
restriction check reads the restricted composition withheld on the read
and the composition query. An end-to-end case shows the federated query
through the gateway leaving it out and a follow-up read of it answering
the node’s
403(#1389). - The gateway holds a stored query for the laboratory report,
eu.ferrofed.eehrxf::laboratory-report, read-only beside the patient summary’s section queries: every whole composition that contains anopenEHR-EHR-OBSERVATION.laboratory_test_result.v1, the entry the openEHR CKM template 1013.26.408 (asset version 3, DRAFT) names. The template is vendored byscripts/vendor/openehr-ckm.shwith its status recorded (#666).
Changed
docs/release.mdhas the step that affixes the CE marking (Regulation (EU) 2025/327 Art 41) to a release’s information sheet, instructions for use and EU declaration of conformity, and makes or updates its EU database entry (Art 49), from the first release placed on the market as an EHR system; the entry waits on the Art 49(4) delegated act, which the weeklyehds-actsjob watches. The regulatory status page links it (#1144).- The clinical safety risk file, the Annex II checklist, the regulatory status page and the instructions for use no longer cite closed issues as pending work: the hazards that wait on a clinical review cite #1327, the Annex II rows name the open issues that keep each point partial, point 3.4 records the security labels per category and origin. The production guide’s CDR checklist is a page of its own, Preparing each CDR, and the patient summary’s terminology rules are the page Terminology on the FHIR face (#1319, #1320, #1321, #1322, #1325, #1328).
- The clinical safety risk file records the safety review of the interoperability component of 2026-10-25, which the owner accepted: I-01, I-04 to I-06, I-11 and I-13 to I-15 closed at P1, I-09 accepted at S3, P2 by a benefit-risk judgement, and I-02, I-03 and I-12 kept open, each naming the control that closes it. The Annex II checklist and the book’s receiving and prescriptions pages follow it (#1327).
eehrxf’sCategory::crosswalk, which answered one crosswalk or none, isCategory::crosswalks, which answers the crosswalks of a category’s datasets in the order ofCategory::roots: two for imaging, one for every other category. A caller ofcrosswalk()takes the first ofcrosswalks()(#1339).- The operator console’s find answers under the field as you type: a panel
anchored under the top bar’s find asks the console’s
/search/answerroute and shows organisations, nodes, creating systems and stored queries grouped by kind, five of each, with the words searched for emphasised, and says when the registry or the stored queries did not answer; the arrow keys walk them and Enter opens one. Enter with none chosen, or “See all results”, opens the find page, which lists every match in the page, one card per kind. The console moves to FerroUI 0.4.0. - Moved to
ferrohealth-smart0.6.0, the family’s SMART and OAuth 2.0 resource-server crate, from 0.4.0; client authentication and the onward client credentials grant behave as before (#1342). - The operator console’s settings tabs mark a value’s source with FerroUI’s setting source: From the environment, Default, or From the file under managed revisions; a value the running revision sets, or the file with revisions off, is the page’s own source and carries no mark. The words the console wrote after every value (“from revision 1”, “from an environment override”) are gone.
- The compose quickstart resolves every patient through FerroPIX, the
FerroHEALTH patient index, which Compose runs beside the gateway on its own
PostgreSQL and seeds with the synthetic patients over ITI-104 before the
gateway starts. The gateway keeps no cross-reference of its own. The
patients now live in
docker/quickstart/patients.tsv, whichscripts/quickstart/seed.shreads too. Rundocker compose down -vonce before bringing an older quickstart up again (#1377). - The
binding-nlfeature carriesbinding-ihe: the Dutch binding resolves patients through FerroPIX’s PIXm Manager, the family’s patient index for Dutch BSNs too (#1377). - The compose quickstart and the end-to-end harness run FerroEHR 5.5.0 and
FerroPIX 0.0.10, each pinned by digest, and the browser journeys run
Chrome for Testing 156.0.8078.73. Each node names its configuration file
in
FERROEHR_CONFIG, sets itssystem_idasFERROEHR__REST__SYSTEM_IDand keeps FerroEHR’s in-instance subject lookup ([ehr_index] provider = "local"); FerroPIX readspublic_base_urlat the top level. The vendored W3C DID Resolution 1.0 moves to the Candidate Recommendation Draft of 2026-10-26 (#1386). - Moved to
ferrohealth-smart0.8.6, withihe-iti0.0.66,nl-generic-functions0.0.33,ihe-atnaandehds-logging0.3.1,ferrobridge-termandferrobridge-validator0.1.140 on the same line, so the build holds oneferrohealth-smartand oneopenehr-sdt(#1390). A verified Basic credential of[[auth.basic.users]]is now kept for 300 seconds by default (auth.basic.cache_ttl_seconds;0keeps the old behaviour), and[auth] ca_filenames extra trust roots for the issuers. An access record names each security label once, on itsehds-categoriesandoriginentities, and writes nometa.security, as IHE RESTful ATNA Rev. 3.6 Table 3.81.4.2.2.1-1 maps nometa: a repository that filtered on_securityreads the entities’securityLabelinstead. - The quickstart’s FerroPIX and its four FerroEHR nodes follow
FERROFED__USAGE_REPORT__ENABLED, so one variable stops every installation report the quickstart sends;FERROEHR__USAGE_REPORT__ENABLED, when set, still decides for the nodes (#1392). - Moved to FerroPIX’s
ihe-iti0.0.54 andnl-generic-functions0.0.29, FerroAUDIT’sihe-atnaandehds-logging0.2.10, andferrohealth-smart0.7.0, the versions those crates require of one another. The access record, the audit trail, client authentication and identity resolution behave as before (#367). - A draft EU declaration of conformity
(
scripts/release/declaration-of-conformity.sh --draft) opens by saying that it is not issued and claims no conformity: no conformity assessment against Chapter III of Regulation (EU) 2025/327 has been carried out, and the Article 40 testing environment does not exist yet (#940).
Removed
- The unpublished workspace crate
oauth-server-metadatais retired: the onward grants take the RFC 8414 duplicate-name refusal and metadata location fromferrohealth-smart0.6.0, and the engine keeps the issuer and mutual-TLS alias host rules, with no change in what is accepted or refused (#1342). - The static development cross-reference
[dev]([[dev.crossref]]and[[dev.consent_denied]]) is removed: FerroFED keeps no patient index in any profile and resolves every patient through a PIX Manager. A configuration that sets[dev]is refused at load naming the table. The operator deployment report dropsdevelopment_crossref, the identity-state export drops itsdevelopment_cross_referenceentry, the usage report no longer names thedevservice, and the console’s development banner no longer mentions a static cross-reference (#1377).
Fixed
- A patient summary section no longer writes one resource that mixes two entries: a composition that holds a mapping context’s start archetype more than once is left out of that context’s section and named in its narrative, with the member and the number of entries (#1350).
- A
CONTRIBUTIONsent asapplication/xmlis answered415(media-type-unsupported) before it is routed, naming the media typescontribution_createtakes, as a FerroEHR node answers it: ITS-REST 1.1.0 defines that body in JSON alone. It was a400(preceding-version-invalid) after the write was routed (#1391).
Security
Shipped with functional changes because: the fix is a dependency release that reached main beside the unreleased work of the next version, and a patch of v0.0.13 alone would ship the same pins.
ferrohealth-smart0.7.1 masks the userinfo of an OAuth section’sissuerandtoken_endpointin every rendering, a URL that does not parse included; before it a credential written into such a URL could reach a log through the section’sDebugor serialized form (CWE-532, GHSA-vphj-3f8c-5hpx). FerroFED takes it with the releases built on it:ihe-atnaandehds-logging0.2.11,ihe-iti0.0.60,nl-generic-functions0.0.30 andferrobridge-term0.1.137.
0.0.13 - 2026-10-25
Security fixes until 2031-10-25, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- The Kubernetes example moved to
deploy/kubernetes/base/: apply it withkubectl apply -k deploy/kubernetes/basein place ofkubectl apply -f deploy/kubernetes/. Its NetworkPolicy now admits only your reverse proxy’s namespace (ingress-nginxuntil you edit it) and the console to the gateway port, so a client inside the cluster that called the gateway directly needs a peer of its own; the StatefulSet’sterminationGracePeriodSecondsis 50 and its governing Service is the newferrofed-admin. Kubernetes refuses a change of a StatefulSet’sserviceName, so runkubectl delete statefulset ferrofed --cascade=orphanbefore you apply; the pods and their audit spool claims stay, and the new StatefulSet adopts them (#638). - Each
[[fhir.receive]]names, independencies, every package of itspackage’s dependency closure (each a.tgzor an unpacked directory), and the package of the MyHealth@EU value sets its profiles bind. A[[fhir.receive]]whose packages lack one a manifest names is refused at start and byconfig check, naming the missing package. For the HL7 Europe Patient Summary, the book’s receiving page lists the packages (#808).
Added
- The three medication clusters of the openEHR international Clinical
Knowledge Manager that the medication order’s slots admit,
openEHR-EHR-CLUSTER.medication_authorisation.v0,openEHR-EHR-CLUSTER.medication_order_summary.v0andopenEHR-EHR-CLUSTER.medication_supply_amount.v0(all DRAFT), vendored under CC BY-SA byscripts/vendor/openehr-ckm.shfor the openEHR mapping of prescriptions (#1043). - The console is ready for more languages: every string it draws, its
handlers’ plain-text answers and a revision diff’s change words among
them, comes from its catalog, and the stray-literal check runs with an
empty baseline. A pseudo-locale,
[server] locale = "qps", draws every word accented and two fifths longer, and a browser journey holds every page to fitting 1440 and 390 px in it (#1162). - The patient summary the FHIR face writes translates a code in no preferred
code system through the terminology server
[eehrxf.terminology]names: eachequivalentorequalmatch ofConceptMap/$translateinto a preferred system is added after the original coding, and a translation the server refuses or does not answer fails the summary. With no server configured, the code is kept as recorded, under Art 10(7) of the eHealth Network Guidelines on Patient Summary (#1213). OPTIONS {base}/reportslatency_ms_p50andlatency_ms_p95for each member endpoint, the median and the 95th percentile of the last 100 requests the gateway sent it, and leaves both out for an endpoint it has not asked; the operator console shows the two side by side on the node, endpoint and self-description lists and in the topology (#1220).- The admin listener answers the running configuration at
GET /admin/config/running, behind the operator scope: each deployment-wide setting with the value the replica runs, where it comes from (the file, an environment override, the running revision or the default), its default, and whether a change waits for the next start, with the value that start takes. A secret is named by its file, never by its value. Underconfig.ui = "managed",PUT /admin/config/settingssaves a revision with named settings changed, applied as any revision (#1235). - The running configuration of the admin listener and the operator
console’s new Authentication tab show who may call: the audience, the
operator scope, each token issuer and each trusted issuer, with every claim
name, the correlation header and the added countries of a national contact
point the file declares. The tab is read-only, since only the file sets
[auth]and[trust], and a secret is named by its file alone (#1269). - The public MyHealth@EU, eHDSI and Xt-EHR material is vendored with its provenance: the MyHealth@EU FHIR guides (core, Patient Summary, ePrescription and eDispensation, laboratory, discharge and imaging reports, vaccination), the NCPeH API guide at its current build 9.1.1-ci, the OpenNCP Transformation Manager’s CDA Schematrons and eHDSI coded element list, the newer HL7 Europe laboratory (2.1.0) and discharge report (1.0.0) releases, the Xt-EHR models 0.3.0 the MyHealth@EU guides use, and the Xt-EHR Metadata Foundation guide; the eHDSI CDA reference test documents and every published Xt-EHR deliverable are cached, since they state no licence. Decisions A51, A60 to A72, A81 and A102 are re-checked against them, and A66 adds a patient lookup by identifier on the FHIR face, which Xt-EHR D5.1 asks of every producer EHR system (#1290).
- The FHIR face looks a patient up by identifier,
GET {fhir-base}/Patient?identifier=system|value, as Xt-EHR D5.1 §3.4.6 asks of a producer EHR system: the patient is resolved at the gateway and no member is asked, the answer is asearchsetwith thePatientthe PDQm Supplier holds when a member holds the patient, an identifier no member holds and a restricted patient both answer the emptysearchset, a search by demographics is refused, a lookup that finds the patient is recorded in the access log, andmetadatadeclares the search (#1294). - Vendored the packages the MyHealth@EU guides and HL7 Europe Laboratory Report 2.1.0 and Hospital Discharge Report 1.0.0 depend on and the repository lacked: HL7 Europe Imaging Report 1.0.0-alpha, HL7 Europe Extensions 0.1.0, IHE MHD 4.2.2 and International Patient Summary 2.0.1, committed, and HL7 Terminology 7.2.0 and 7.4.0 and the R5 to R4 cross-version extensions 0.0.1-snapshot-2, pinned in the cache, with a test that fails when one of those manifests names a package no corpus holds (#1306).
deploy/kubernetes/is a kustomize package, and every release attaches it asferrofed-vX.Y.Z-kubernetes.tar.gzwith the gateway and console images pinned by digest. Its base adds apreStopsleep matching the drain delay, topology spread over zones and nodes, a headless Service for the admin listeners and a NetworkPolicy that admits the reverse proxy, the scraper and the console alone; its components add the operator console, an Ingress or a Gateway API route for each, a Prometheus Operator ServiceMonitor and an egress policy. The book’s Kubernetes page says how a changed configuration is reloaded without a restart (#638).telemetry.otlp_endpointandmetrics.otlp_endpointtake anhttps://collector, reached over TLS with the platform’s trust roots, and[telemetry.otlp_tls]and[metrics.otlp_tls]addtrust_roots_fileand a client identity (client_identityorclient_identity_file) for mutual TLS, each read and checked at start, so a remote collector needs no forwarder beside the gateway (#644).- A national contact point’s national connector end to end: the harness
national connector in
ferrofed-testkit, with synthetic Implementing Regulation (EU) 2026/2099 Annex attributes, reads the patient summary, the document list and the prescriptions over two FerroEHR nodes and posts a dispensation, behindFERROFED_E2E, and a restricting member leaves no trace in any answer. The book’s new page, A national contact point, says how to connect and configure one and what stays with the contact point (#735, #524). - A document received on the FHIR face is validated in full by
FerroBRIDGE’s
ferrobridge-validator: every constraint of its category’s profiles and of the profile each entry claims or its slice names, every invariant and terminology binding included. An error refuses the document with422, each warning is returned in the outcome of the write, and every constraint the validator did not evaluate is named there. Arequiredbinding the packages cannot decide is asked of the[eehrxf.terminology]server, and one it does not answer refuses the document with502.scripts/vendor/fhir-packages.shfetches the packages of the Patient Summary’s dependency closure that the repository does not commit (#808).
Changed
- Moved the quickstart and the end-to-end harness to FerroEHR 5.4.1 and Keycloak 26.8.1, pinned by digest, and the CI tools to zizmor 1.31.1, promtool 3.15.1, the HL7 FHIR Validator 7.0.3 and Chrome for Testing 156.0.8078.47, with the W3C DID Resolution draft of 2026-10-24 vendored (#1164).
- The console’s configuration page opens on the deployment-wide settings,
one tab per section as FerroUI’s design draws them (Federation
behaviour, Fan-out and paging, Consent, Stored queries, Access log and
FHIR face), each setting with its value, its source, its key and its
Next start mark, read from the gateway’s running configuration. Edit on
a tab saves the changed settings as a revision with a plain form post.
With configuration revisions off, the settings tabs show read-only.
/configurationnow opens Federation behaviour; the members are at/configuration/members(#1235). - Decisions A67 to A72 on the national contact point are re-checked against
the Xt-EHR deliverables D6.1 and D6.2, the closest published text of the
exchange format while the Commission has published no draft act, and each
is confirmed. The deliverables are pinned by
scripts/vendor/xt-ehr.sh(cache only, no licence statement), and the book’s page A national contact point names the sources and records that the MyHealth@EU Requirements Catalogue 9.0.0 is behind EU Login and not read (#1265). SECURITY.mdstates the response times every product of the Ferro family states: an acknowledgement within about 5 business days in place of seven days, an initial assessment within about 10 business days, both best effort, and a fixed release published within 7 days of the confirmed assessment for a Critical vulnerability, 30 days for High, 90 days for Medium and with the next release for Low (#645).- The interoperability component asks its terminology server
(
[eehrxf.terminology]) through FerroBRIDGE’s publishedferrobridge-term0.1.131 in place of FerroFED’s own client: the same$translate,$lookupand$validate-codein FHIR R4, over the same credential and TLS material, mutual TLS and private trust roots included. A code the server does not know is now named as such rather than by the HTTP status it came with, and every other refusal still carries the server’s status.eehrxf0.0.28 changes its terminology seam:TerminologyServertakes the system, code, target, value set and candidate it asks about and answers the matches, the display or the result, in place of the operations’ wholeParameters, andTerminologyErrorgainsNotFound; an implementation of the seam outside FerroFED changes with it (#925).
Fixed
scripts/gh/project.sh updatesfinds the FerroFED Roadmap again: it looks the board up throughrepositoryOwner(login:), which resolves an organisation project, in place ofuser(login:), which cannot (#1229).- The compose file every release carries runs the operator console beside
the gateway under a
consoleprofile (docker compose --profile console up), fromghcr.io/ferrohealth/ferrofed-consoleat the release’s version, with the gateway’s hardening, bound to 127.0.0.1, its OIDC client secret read as a file secret (#1258). - A read of a prescription or a dispensation by its locator id on the FHIR
face is held to the consent pre-filter as the search is: the gateway names
the patient behind the id’s
ehr_idin the[access_log] patient_namespaces, and a member the pre-filter denies is asked nothing and answers as an id the face never wrote, with no trace of the restriction. Before, the read went to that member (#1270). - A document list on the FHIR face that lists the gateway’s summary writes
one access record even when it asked no member, naming the members the
patient resolved at and the
ehr_idat each, since the list tells the caller that they hold the patient’s record (Regulation (EU) 2025/327 Annex II 3.2). Before, it wrote none (#1271). - An ITI-20 syslog record the gateway wrote just before it ended its
connection to the Audit Record Repository could be lost: the connection
was dropped with the repository’s TLS session tickets unread, so the
socket was reset. The gateway now stops every audit trail on its
SIGTERMdrain, and each forwarder closes its connection with a TLSclose_notifyfirst (RFC 5425 §4.4), as it now does before it reconnects.ihe-atnaandehds-loggingmove to 0.2.6 andihe-itito 0.0.50 (#1278). - A received dispensation’s answer named as not evaluated only the
invariants of
MedicationDispense-eu-mpd’s root element, and none of itsrequiredbindings, so the invariants of every other element it carries and every binding were passed in silence. Each invariant of an element the dispensation carries or a slice it fills, and eachrequiredbinding of the profile and ofMedication-eu-mpd, is now listed as awarningof typenot-supported(FHIR R4 conformance-rules.html §2.1.0.6, terminologies.html §4.1.5.1, #1291). - The quickstart
compose.yamlsends the usage report by default, as every FerroHEALTH compose file does, with its instance id on a newferrofed-statevolume:FERROFED__USAGE_REPORT__ENABLED=false, andFERROEHR__USAGE_REPORT__ENABLED=falsefor the four nodes, in your shell or.envstop it.scripts/checks/usage-report-off.shholds test lanes alone again and no longer reads the compose file (#1296). - The end-to-end harness’s SanteMPI no longer fails a run on SanteMPI’s
transient
IOExceptionfrom its publish-and-subscribe queue. Provisioning a security application or an identity domain, and an ITI-93 feed, are tried again a bounded number of times with a backoff. A record SanteMPI already holds is not created again, and any other refusal still fails at once (#1302). - The end-to-end harness’s SanteMPI reads the identity domains it holds
from
ASGN_AUT_TBL, the table of the pinned 2.5 line, where it read a SanteDB 3 table that image lacks and stopped every run that provisions SanteMPI (#1308).
0.0.12 - 2026-10-24
Security fixes until 2031-10-24, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)), shipped only in the newest release (Art 13(10)). This release is supported while it is the newest; upgrade to the newest release to get a fix, as SECURITY.md says.
Upgrade notes
- The release’s compose file and the Kubernetes example now set
FERROFED__USAGE_REPORT__STATE_DIR, so a gateway started from them sends the installation report to FerroPULSE. SetFERROFED__USAGE_REPORT__ENABLED=falsebefore you upgrade to keep it off (#1122). [auth]is now theferrohealth-smartsection every FerroHEALTH product reads (#1174). Move each issuer’s FerroFED policy (backend_clients,demographic_clients,client_tokens_act_for_professional,professional_issuing_authority,[auth.issuer.patient],[auth.issuer.requester],[auth.issuer.assurance],[auth.issuer.national_contact_point]and an introspected issuer’sintrospection_endpoint,client_idandclient_secret_file) to a[[trust.issuer]]entry naming the sameissuer, andauth.mode,[auth.edge]and[auth.purpose_of_use]totrust.mode,[trust.edge]and[trust.purpose_of_use]. Dropjwks_urifrom each issuer: the key set is read from the issuer’s RFC 8414 metadata, or fromjwks_file. Replace each issuer’soperator_scopewith the oneauth.product_scope, a string starting with__or a full URI you control.auth.audienceis a list, and the clock skew, key set and timeout keys areclock_skew_seconds,jwks_max_age_seconds,jwks_refresh_interval_secondsandtimeout_ms.- Every static bearer token is gone (#1163). Replace
bearer_tokenandbearer_token_filein a[credentials."<endpoint>"]section or a service’s credentials withuserandpassword_file, or with anoauthtable, the client credentials grant offerrohealth-smart; Mitz, the NVI and the terminology server takeuserandpassword_file. Replace[metrics] scrape_token_filewithscrape_users, naming users of[[auth.basic.users]]whose Argon2idpassword_hash_fileyou write, and give Prometheusbasic_auth. Replace[pmir] feed_token_filewithfeed_users, naming the Registry’s own[[auth.basic.users]]user, and give the Registry its password. - An
oauth2section that asks for a plain client-credentials token, with no token exchange, noDPoPkey, no binding to or authentication by the TLS client certificate and an assertion naming the token endpoint, is refused: move it to anoauthtable, whoseprivate_key_jwtsigns with its ownprivate_key_file. - A log filter that names
ihe_iti::atna(the audit spool and forwarder) namesihe_atna::atnainstead; the events are the same. - The operator console is now
ferrofed-console(#1219). Pullghcr.io/ferrohealth/ferrofed-consolein place ofghcr.io/ferrohealth/ferrofed-viewer, rename everyFERROFED_VIEWER__<SECTION>__<KEY>override toFERROFED_CONSOLE__<SECTION>__<KEY>andFERROFED_VIEWER_CONFIGtoFERROFED_CONSOLE_CONFIG, and run the binary asferrofed-console. The session and theme cookies are renamed, so every operator signs in once more after the upgrade. - If you edited the
ferroehr-configblock of the quickstart’scompose.yaml, FerroEHR 5.3.2 refuses the 4.3.9 Basic user keys at boot: renameusernametouser, give each user itsscopes(the quickstart grantsuser/composition-*.cruds,user/template-*.cruds,user/aql-*.crudsand__ferroehr.platform), and move each user’srolesto[authz.rbac.basic_roles]keyed by the user (#1226). - Bookmarks of the operator console’s
/members,/routingand/federationpages answer404: use/organisations, a node’s Routes tab under/organisations/{org}/{node}/routes, and/self-description(#1230). Each preference is now a cookie of its own (ferrofed_console_theme,ferrofed_console_density,ferrofed_console_motionand the others); the theme cookie isferrofed_console_themein place of 0.0.11’sferrofed_viewer_theme, so each operator chooses the theme once more. A deployment that builds the site bundle itself runscargo leptos build --split, asscripts/release/console-site.shdoes.
Added
- The gateway sends the FerroHEALTH installation report to FerroPULSE on its
envelope v2: one
startreport per process start and onedailyreport a day, carrying a random instance id, the version, the deployment kind, coarse host size, the member count as a range, the configured identity and consent services, latency per route group and the node request counts, and no patient data, AQL, path, node name or URL. It is on by default once[usage_report] state_dirnames a writable directory, which the release’s compose file and the Kubernetes example set;enabled = false(FERROFED__USAGE_REPORT__ENABLED=false) stops it, a CI environment stops it, and one boot line says which. The book’s usage report page lists every field and carries the privacy notice (#1122). - Mitz takes the OAuth 2.0 client credentials grant of
[nl_gf.mitz.credentials.oauth]beside basic credentials. The gateway obtains the token at the token endpoint, which must behttpsoutsideprofile = "development", caches it while it is fresh, and sends it with each closed authorization question as a bearer token; a question Mitz answers401is asked once more with a fresh token. Anoauth2,nutsorfapi2grant is still refused at load (#1180). - The configuration page explains how it works in a dialog that How configuration works opens. The History tab filters its revisions by origin in an Origin menu that works with no WebAssembly, and each origin is a badge of its own colour. A secret’s value field shows what you typed while you hold its eye, and stays masked otherwise (#1233).
- OpenVEX statements for FerroFED under
security/vex/: the Rust advisory the dependency gate accepts (RUSTSEC-2024-0436,paste) with its argument and dependency path, generated fromdeny.toml. Each release attachesferrofed-vX.Y.Z-vulnerability-record.json, every finding of a full scan of both images and the binaries with the statement that judges it, and is refused while a finding has none; each image is scanned before it is tagged, and the published images are scanned every week with the VEX applied (#1238).
Changed
scripts/checks/changelog-guard.shandcrate-version-guard.shtakeorigin/mainas the base when run with no refs, andstray-literals.shreads the console’s sources when given no directory, so a local run of the guards passes on a clean branch;docs/ci-cd.mdnames the guards that need a built input first (#1151).- The book’s dashboard and Integrity screenshots show an open incident, a resolved one and a cleared collision, each with its time, so the visual review covers the console’s timestamp rows (#1154).
- CI checks the operator console’s
view!formatting with leptosfmt 0.1.33, the version theui-gatesskill runs, before rustfmt, and the console’s sources pass it (#1155). - Only the newest release is supported.
SECURITY.md, the book and the release notes say that every earlier release stops receiving fixes when a new one is published, with no maintenance branches, no long-term-support line and no backports, and that upgrading is free. Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Regulation (EU) 2024/2847 Art 13(8)) and ships them only in the newest release (Art 13(10)); the end date each release states is the date until which that commitment runs. The startup banner’sSupportline readsnewest release only, fixes until YYYY-MM-DD, the notice for a release past its end date says to upgrade to the newest release, and the console labels the dateFixes until. Thesupported_untilmember ofOPTIONS {base}/keeps its name and value. - Client authentication, the SMART on openEHR grants and the gateway’s own
OAuth 2.0 client credentials run on
ferrohealth-smart0.3.1, the resource-server crate every FerroHEALTH product shares (#1174). Every token that does not verify is answered one401witherror="invalid_token"and no description (RFC 6750 §3.1, RFC 9068 §4), and the security log names the failed check ascheck. TwoAuthorizationfields, or one that is no credential, are a400credential-malformedwitherror="invalid_request". A403names the error code and no scope. An issuer’s metadata and key set are read when its first token arrives, so an issuer that cannot be reached is a503authentication-unavailablethen, never a refused start. - The metrics scrape and the PMIR feed authenticate HTTP Basic users of
[[auth.basic.users]], named bymetrics.scrape_usersandpmir.feed_users; one from another Basic user is answered403. - The audit trail and the access record come from the FerroHEALTH audit
library, FerroAUDIT: the ATNA ITI-20 sender, the BALP
AuditEventwith its FHIR Feed, and the European logging component are the published cratesihe-atnaandehds-logging0.1.0, pinned exactly, and their local copies incrates/ihe-itiandcrates/ehds-loggingare gone. What the gateway records, where it sends it and how it spools it are unchanged. The log events of the spool and the forwarder now carry the targetsihe_atna::atna::spoolandihe_atna::atna::forwarderin place ofihe_iti::atna::spoolandihe_iti::atna::forwarder. - The
openehr-*crates move to 0.0.90 withfhirconnect0.1.173,openehr-mapping-core0.1.124 andfhir-types0.1.127, the lineferrohealth-smart0.4.0 builds on, so the build holds one copy ofopenehr-sdtand of every other family member (#1181). ferrohealth-smart0.4.0 (#1186). Every[<section>.oauth]table takes the keys the crate adds:client_auth = "tls_client_auth"or"self_signed_tls_client_auth"withclient_certificate_fileandclient_key_filefor RFC 8705 §2 mutual TLS to the token endpoint,ca_filefor trust roots added to the platform’s, andassertion_audience = "issuer"(RFC 7523 §3). An issuer’s metadata and key set are still read on its first token, now by the crate; thekey-set-unavailableevent is written once per failed read. The answers do not change.- The
e2e-placementguard now refuses a CI workflow that could skip the container tests: a path filter on its triggers, a condition on thee2e (containers)job beyond the workspace check, or aconclusioncheck that does not wait for it. A change to the auth, credentials or configuration code, an e2e fixture or the configuration reference always runs the container tests (#1187). - The audit library and the identity crates move together:
ihe-atnaandehds-logging0.2.1 from FerroAUDIT,ihe-iti0.0.41 from FerroPIX on thatihe-atna, andnl-generic-functions0.0.26 from FerroPIX onferrohealth-smart0.4.0, so the gateway carries one copy of it. What the gateway records, where it sends it and how it spools it are unchanged; the log targets of the spool and the forwarder stayihe_atna::atna::spoolandihe_atna::atna::forwarder, now a stable interface ofihe-atna. The identity binding’s FHIR Feed reaches anhttpbase throughihe-atna’sFeedRepository::cleartext, which names the operator’s acceptance of clear text (#1194). - The workspace builds with Rust 1.99.1 in place of 1.98.1, and the MSRV stays at 1.98: the FerroHEALTH family builds with the latest stable toolchain and declares the release before it as its MSRV.
- The audit library moves to
ihe-atnaandehds-logging0.2.3, FerroAUDIT’s only supported release, with FerroPIX’sihe-iti0.0.43 on it. Nothing the gateway records changes: FerroFED declares its category map with exact matching, so its map digest is unchanged (#1211). - Breaking: the operator console is renamed from
ferrofed-viewertoferrofed-console(#1219): the crate and its binary (app/ferrofed-console), the image (ghcr.io/ferrohealth/ferrofed-consolein place ofghcr.io/ferrohealth/ferrofed-viewer, which is not published again), the release workflow and its attestation signer (.github/workflows/release-console.yml), the Dockerfile (docker/console/Dockerfile), the site and bundle scripts (scripts/release/console-site.sh,console-tools.sh,scripts/checks/console-bundle.sh,console-paths.sh), the CI job (console), the configuration file variable (FERROFED_CONSOLE_CONFIG), the environment prefix (FERROFED_CONSOLE__), the cookies (ferrofed_console_session,ferrofed_console_sign_in,ferrofed_console_theme, each with the__Host-prefix where secure) and the catalog (locales/en/ferrofed-console.ftl). - The quickstart’s four member CDRs and the test harness’s FerroEHR nodes
move from FerroEHR 4.3.9 to 5.3.2, the release on the same corrected base,
with the node database image beside them (#1226). FerroEHR 5.1.0 changed
its Basic user keys and checks a Basic user’s SMART on openEHR scopes on
every route, so the quickstart’s FerroEHR configuration names its user with
user, grants it the three resource-family scopes and FerroEHR’s product scope, and moves its roles to[authz.rbac.basic_roles]; the restricted harness node does the same for its two users. - The operator console is rebuilt on FerroUI 0.3.6 (
ferrohealth-ui, pinned exactly, with itssplitfeature) in islands mode (#1230, #1233, FerroHEALTH/FerroUI#161). The server draws every page whole from FerroUI’s components, stylesheet and FerroFED theme, and the browser hydrates only FerroUI’s interactive parts and the AQL editor, so every page reads and every action works with no WebAssembly. Every action is a plain form post to the page it sits on, answered by that page drawn again with the outcome at its top; a form the console or a replica refuses is drawn again as you typed it, the refused field marked. The pages follow FerroUI’s reviewed FerroFED design: a dashboard, the topology (with “How it is drawn”), the organisations with their nodes (each node with its endpoints and its routes), the services, the integrity incidents, the stored queries with their versions, the query console, the self-description on five tabs with the manufacturer the gateway declares, the configuration, Your preferences and a find in the top bar. The top bar names the replica that answered. Every state is FerroUI’s state mark (a shape and a word), and organisations and services carry their own glyphs. On the configuration’s Members and Credentials and bindings tabs, each row’s menu (⋯) holds Edit and Remove…, whose confirmation asks for the reason the revision keeps; endpoints, settings and new secrets are sub-cards whose rows Add and Remove change in place. The site bundle is built withcargo leptos build --split. - The operator console is ready for more languages, English the one that
ships (#1162). Its words and FerroUI’s come from Fluent catalogs, the
console’s under
app/ferrofed-console/locales/en/, so a language joins as a catalog directory with no code change. The locale is chosen once per request: the language the operator chose in Your preferences, else the best language ofAccept-Languagethe catalog holds, else the new[server] locale(defaulten), and the page writes it as<html lang>.scripts/checks/stray-literals.shfails CI on a console string outside the catalog beyond the per-file baseline (94 literals) the remaining strings hold while they move. - The release notes end with a link to the CRA information and
instructions to the user, and the release lane refuses a cut whose
Security section names an entry without its GitHub security advisory
(
GHSA-) or VEX statement, or sits beside other changes without “Shipped with functional changes because:” and its reason (Regulation (EU) 2024/2847 Annex I Part II(2), (4), (8)).SECURITY.mdstates that a security fix ships alone or the release says why, how a fixed vulnerability’s advisory is published with its release, and theinfo@cadasto.comroute for a report without a GitHub account.scripts/release/preflight.sh <tag>runs the release plan’s checks before the tag (#1237). ihe-iticomes from crates.io at=0.0.40, published by FerroHEALTH/FerroPIX, which owns the crate from here on; the local copy undercrates/is gone. Its features and its behaviour are the ones the gateway already used: the PIXm, PDQm, mCSD, PMIR and XCPD clients, withbalpfor the FHIR profiles’ audit records andatnafor the ITI-55 audit message, both written throughihe-atna.nl-generic-functionscomes from crates.io at=0.0.25, published by FerroHEALTH/FerroPIX, which owns the crate from here on; the local copy undercrates/is gone. Its Nuts grant now holds the authorization server’s issuer to the RFC 8414 rule offerrohealth-smart, so a plainhttpissuer is accepted on a loopback host only. The NVI, Mitz, LRZa and Nuts behaviour is otherwise unchanged.- The patient summary the FHIR face writes applies the exchange format’s
term-mapping rule: an RM
TERM_MAPPINGwhosematchis broader, narrower or unknown is written as no coding, and the section’s narrative carries it, naming the element, the relation, the target and the member it came from (eHN PS Art 10(3)). An equivalent mapping stays a coding after the original (#926).
Removed
- The Tailwind CSS CLI pin and its download are gone: the operator console
draws with ferrohealth-ui’s stylesheet, so
scripts/release/console-tools.shinstalls wasm-opt alone and the site build needs no Tailwind (#1150). - Static bearer tokens, inbound and outbound:
bearer_tokenandbearer_token_filein every credentials section,[metrics] scrape_tokenandscrape_token_file, and[pmir] feed_tokenandfeed_token_file(#1163). Machine-to-machine authentication is HTTP Basic or the OAuth 2.0 client credentials grant. - The operator console’s routes
/members,/members/{node},/routingand/federationare gone: the members are/organisationsand/organisations/{org}/{node}, a node’s routing table is its Routes tab, and the self-description is/self-description(#1230). The theme toggle is gone from the top bar; the theme is chosen in the operator’s menu and in Your preferences. The console’s server functions under/api/are gone, and with them its own stylesheet (style/theme.css) and scripts: it serves FerroUI’s as the crate ships them.
Fixed
- The configuration journeys read the focused element and the credential’s value field afresh while they wait, so a re-render after a save no longer fails them with a stale element (#1149).
- The endpoint details show a dash for a value the gateway did not report (the latency and row count of an excluded node, an organisation, or the error of a node that answered), where a stacked card on a phone showed a label with nothing after it.
- The configuration screen labels
node_idandsystem_idas “Node id” and “System id”, with the configuration key as the field’s hint (#1168). - The dashboard’s card-header link is FerroUI’s shared card action, so its arrow keeps the space before it (#1169).
- The book’s screenshot of the console’s Credentials and bindings tab, in
the light and the dark theme, is taken again and no longer shows the
removed
bearer_tokenrow, and the environment override example on the configuration page sets a password file in place of the removedbearer_token_file(#1179). - The interoperability component’s reference property admits
#from the Patient entry, which FHIR R4 resolves to the holding resource itself, so the test no longer fails when its generator draws that input; the case is kept as a regression seed (#1195). - The trace hygiene tests name their synthetic patient with letters, so a
span’s
busy_nsoridle_nscan no longer spell the identifier and fail the scan by chance (#1198). - A browser journey whose chromedriver loses its port to another process starts another chromedriver, up to three times, so parallel journeys no longer fail before they drive the browser (#1209).
- The operator console’s dashboard draws its cards two to a row, so the recent incidents table no longer overflows its card when an incident kind is long (#1207).
Security
Shipped with functional changes because: the three fixes reached main
between 2026-10-20 and 2026-10-23, and the owner cut this release from
main on 2026-10-24 with no security-only patch of 0.0.11 before it; only
the newest release is supported (SECURITY.md), so such a patch would be out
of support the moment this release lands and give no one a release to stay
on. Each fix is a dependency or base-image bump with no behaviour change of
its own.
- The TLS stack takes
rustls-webpki0.103.16 andwebpki-root-certs1.0.10, the releases that fix RUSTSEC-2026-0382 and RUSTSEC-2026-0383 (distrusted emSign intermediates; GHSA-w3hc-w475-673c). - The gateway and console images no longer trust the compromised eMudhra
(emSign) intermediates in the system trust store (Debian DSA-6572-1,
GHSA-w3hc-w475-673c, #1226). The distroless base
gcr.io/distroless/static-debian13:nonrootmoves to index digestd620678, which shipsca-certificates20250419+deb13u1, in theFROMofdocker/Dockerfileanddocker/console/Dockerfileand in the pin row.rustls-webpki0.103.16 andwebpki-root-certs1.0.10 were already in the lock file. ctutilsmoves from 0.4.2 to 0.4.4 (RUSTSEC-2026-0385, GHSA-rv83-4vc9-vqxg): before 0.4.4 its constant-time mask generation andu128comparisons could compile to a branch on a secret value. It reaches the gateway throughdigestand the Argon2 password hashing offerrohealth-smart(#1232).
0.0.11 - 2026-10-16
Supported until 2031-10-16, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)). A security fix ships as its own patch release on each supported line, as SECURITY.md says.
Upgrade notes
- The operator console refuses
offline_accessin[oidc] scopes. Remove it from the list and set[oidc] offline_access = trueinstead (#643). - A receiving member’s template that admits several
COMPOSITION.categorycodes and assumes none now refuses every received document of its category with a422(MissingRequiredSuffixon<root>/category|code). Give the template’scategoryan assumed value (AOM 1.4assumed_value) (#714). ferrofed_node_requests_totalcarries a new label,class:memberfor a member’s own answer or failure,client-errorfor a4xxit answered a routed request with. Such a4xxnow counts asoutcome="node-error", where it wasactive. The shippedFerroFEDMemberFailingrule leavesclass="client-error"out, so it pages on the same member failures as before; a rule or a panel of your own that readsoutcome="node-error"should leave it out too (#982).
Added
- Information a representative inserts, marked as theirs (#1003,
Regulation (EU) 2025/327 Art 4(2) and Art 5): an issuer that sets
act_is_representative = trueon[auth.issuer.patient]declares that the RFC 8693actclaim of its patient tokens names a proxy or a legal representative. A document the FHIR face receives under such a token is written with every entry naming the representative as itsprovider, aPARTY_IDENTIFIEDwhose one identifier is theactsub, never the person’sPARTY_SELF, and its access record carries theehds-representative-insertedentity. A token whoseactnames the representative by nothing, by its ownsubor by an identifier of the document’s patient is403with nothing sent. Without the declaration the gateway reads no representative, and the person’s own insertion staysPARTY_SELF.eehrxf0.0.19 addsRepresentativeandReceivedComposition::inserted_by_representative;ehds-logging0.0.24 replacesAccessRecord::subject_insertedwithinserted, anInsertionof the subject or a representative. RFC 8693 is vendored. - A
configuration-invalidrefusal on the operator console’s configuration page shows the gateway’s message, which names the key it refused and never a value, beside the status and the code. - The operator console is styled on the “Azure & Iron” brand tokens, in a light and a dark theme: tables with a header row and row separators, labelled form fields, refusals and incomplete answers in boxes that say what they are, and health states as words in a tinted cell. Every page fits a 320 px screen: a wide table scrolls sideways in its own box, never the page. The stylesheet is Tailwind CSS 4.3.3, built by cargo-leptos with no Node and no new JavaScript (#1037). The shell these pages sit in, its top bar, navigation and theme switch, is the one under Changed (#1075).
- The electronic prescription and dispensation datasets of the
interoperability component (#1038, Regulation (EU) 2025/327
Art 14(1)(b) and (c)):
eehrxf0.0.21 carries, under the featuresprescriptionanddispensation, the crosswalks from the Xt-EHREHDSMedicationPrescriptionandEHDSMedicationDispense1.0.0 models to the HL7 Europe MPD 1.0.0MedicationRequest-eu-mpdandMedicationDispense-eu-mpdprofiles, held to both vendored packages. Withfhir-r4, the newmedicationmodule reads a member’sMedicationRequestandMedicationDispenseinto the datasets with a typed refusal for every rule a resource breaks, writes them back in the MPD form, and checks a resource and eachMedicationit contains against the vendored MPD profiles, naming what the check cannot evaluate. The number of dispensations a prescription item has left travels in the extensionhttps://ferrofed.eu/fhir/StructureDefinition/remaining-dispensationsondispenseRequest, written only when the member states a value. - The members that serve electronic prescriptions and dispensations on the
FHIR face (#1039, Regulation (EU) 2025/327 Art 14(1)(b) and (c)):
[[fhir.serve]]declares, per category, the members that serve it on their ownhl7-fhir-restendpoint in the HL7 Europe MPD 1.0.0 profiles, andconfig checknames them. A category no member is declared for is answered as not served onGET {fhir-base}/MedicationRequest,GET {fhir-base}/MedicationRequest/{id}andPOST {fhir-base}/MedicationDispense:404with anOperationOutcomeof the issue typenot-supported, never an empty list, and no member is asked.GET {fhir-base}/metadatalistsMedicationRequestandMedicationDispenseonly for a declared category. The start is refused for a declared member the registry does not hold, one with no endpoint of its own serving the category, a native endpoint of either category that leaves out its MPD profile, and a face with no resolver. - The search of a patient’s prescriptions on the FHIR face (#1040,
Regulation (EU) 2025/327 Art 14(1)(b)):
GET {fhir-base}/MedicationRequest?patient.identifier=system|valueasks every member declared forElectronic-Prescriptionsthat holds the patient at its own endpoint, by its ownehr_idalone, following the member’snextlinks under its own base. The answer is onesearchsetwith each member’sMedicationRequestas the member sent it except itsidand its entry’sfullUrl, which carry the face’s locator, theidthe read takes (#1041); oneProvenancenaming the member, and awarningwhen two members hold a prescription under the same identifier._countpages the answer through the gateway’s own-offsetparameter. A silent member, and a member the cross-reference could not answer for (not-resolved), fails the search under all-or-nothing and is named underpartial; an unknown patient and a patient a consent decision hides get the same emptysearchset. The search takes thecomposition-search scope over every template and is recorded underElectronic-Prescriptions. - The read of one prescription on the FHIR face (#1041, Regulation (EU)
2025/327 Art 14(1)(b)):
GET {fhir-base}/MedicationRequest/{id}reads a prescription by theidthe search now gives it, which names the member’s own endpoint, itsehr_idand its ownidof the prescription, so the gateway holds no state between the two. The member theidnames is asked alone, by itsehr_idand its ownid(_id), and itsMedicationRequestis answered as it sent it, with theidread and the member named inopenEHR-federation-endpoint. Anidwith no owner answers404with one text whatever the reason; anehr_idtwo members claim answers409and raises the integrity incident; a member answering two prescriptions for oneidanswers409; a caller confined to one patient reads that patient’s prescriptions alone. The read takes thecomposition-read scope over every template and is recorded underElectronic-Prescriptions. The search names each prescription by the sameid, and itsfullUrlby the read; a prescription whose parts do not fit anidis listed as its member sent it, with awarningnaming the member and quoting no identifier. - The receipt of a dispensation on the FHIR face (#1042, Regulation (EU)
2025/327 Art 14(1)(c)):
POST {fhir-base}/MedicationDispensetakes oneMedicationDispense, held to the HL7 Europe MPD 1.0.0 profile before any member is asked, and writes it once to the one member that owns the prescription it names, its body as it was sent. The gateway resolves the patient fromsubject, asks every member holding the patient for its prescriptions by that member’s ownehr_id, and matches the prescription’s business identifier itself, so the identifier never reaches a member. A prescription named by the face’sMedicationRequest/{id}alone is refused422, since the member receives the body unchanged and can link it only by the identifier. A written dispensation answers201with aLocationnaming it by the face’s id; no owner answers404as an unknown patient does; two members holding the prescription answer409and raise the newPrescriptionIdentifierCollisionintegrity incident; the owner’s refusal answers409and its silence504, the outcome unknown and the write never sent again. The receipt takes thecomposition-create scope over every template, is recorded underElectronic-DispensationsandElectronic-Prescriptions, and is counted by outcome inferrofed_fhir_dispensations_total. ServingElectronic-Dispensationsnow needsfhir.receive_namespaces, the incident report keeps the last 20 incidents of each kind, and the capability statement declares exactly the interactions the face serves (#1110). - The documentation of electronic prescriptions and dispensations on the
FHIR face (#1045, Regulation (EU) 2025/327 Art 14(1)(b) and (c)): the
book’s prescriptions page now carries the member contract a serving
member must meet, the duplicate-dispensation rule among it, every route’s
answers in one table, the locator ids, completeness under
partial, the remaining-dispensations extension and its published definition, the access records and the dispensation counter, and the capability statement. The configuration reference, the instructions for use, the information sheet, the Annex II checklist and the rights of natural persons now name what the face serves and receives, and the information sheet lists the HL7 Europe MPD 1.0.0 package among its standards. - The FHIR R4
StructureDefinitionof the remaining-dispensations extension (#1053):eehrxf0.0.23 carries it asmedication::prescription::REMAINING_DISPENSATIONS_DEFINITION, a simple extension ofMedicationRequest.dispenseRequestwith avalueUnsignedInt, at most once, and the documentation site serves it athttps://ferrofed.eu/fhir/StructureDefinition/remaining-dispensationsand beside it asremaining-dispensations.json. It is tested against the FHIR R4 4.0.1 Extensibility, Defining Extensions and StructureDefinition pages, now vendored byscripts/vendor/fhir-r4.sh. - The cybersecurity risk assessment of the Cyber Resilience Act (#1058, Regulation (EU) 2024/2847 Art 13(2) and (3), Annex VII point 3): a book page reads the threat model and the clinical safety risk file against each point of Annex I, Part I, with the justification where a point does not apply, and is reviewed at each release. CRA information to the user (#1059, Art 13(18), Annex II points 3 to 9): a book page gives the identification, the intended purpose, the foreseeable risks, the support end date of every release, the instructions for secure use, update and decommissioning, and where the SBOMs are. The technical documentation index and the information sheet link both.
- A decommissioning page in the book (#1063, Regulation (EU) 2024/2847 Annex II point 8(d)): what a deployment holds and where, then the steps to take it out of service, from moving what a replacement needs and letting the audit spools deliver to keeping the access records for their retention period, retiring the signing keys and credentials, and removing the stores. CRA information to the user links it for point 8(d).
- The technical documentation indexes every point of Annex VII of
Regulation (EU) 2024/2847 (#1065): each point names the page, file or
release attachment that answers it, or the issue that completes it, in
conformance/cra-technical-documentation.tsv, whichscripts/checks/technical-documentation.shholds to the vendored Act and the tree as it holds the Annex III index. - A FHIR R4 definition behind every
https://ferrofed.eu/fhir/URL the registry document in FHIR form and the access log use (#1072): theconsent-refusal-code,payload-profileandehr-id-systemextensions ofEndpoint, theconnection-typeandaccess-log-origincode systems, and aNamingSystemfor each of the fivesid/*identifier systems. The documentation site serves each at its URL and beside it as.json, and the book’s new page, The registry in FHIR form, says how a FHIR client reads them from a host that sets the content type by extension, and why the archetype-local code systems, one per archetype, do not resolve. Each definition is tested against the FHIR R4 4.0.1 pages that govern its resource type, now with the CodeSystem, NamingSystem and terminologies pages vendored byscripts/vendor/fhir-r4.sh, and tied to the code that reads it, and a test fails on any otherferrofed.euFHIR URL without a published definition. - The basis of the five-year support period, under Regulation (EU) 2024/2847 Art 13(8), in the book’s regulatory status (#1088): the time a release is expected to stay in use between upgrades of the hospital platforms around it, the users’ expectations, the nature of the product, the five-year floor, the release cadence and the optional matters. The technical documentation index marks Annex VII, point 4, documented.
- The operator console names a development gateway in an amber banner under the top bar, with the static development cross-reference when it is in service, and its status pill shows the gateway’s version and replica. An operator without the operator scope sees the pill as before (#1096).
GET {base}/operator/deployment, on the operator surface behind the operator scope, reports the deployment profile the gateway started with (productionordevelopment), whether patients resolve through the[dev]cross-reference, the product version and the replica name, so the operator console can warn that it reads a development gateway. It carries no setting value, URL, credential or patient identifier.- The operator surface marks each stored-query version
read_onlywhen the gateway takes noPUTunder its name (its owneu.ferrofed.eehrxfnamespace, or the read-only definition files), andGET {base}/operator/stored-queries/{name}answers one stored query’s versions by its qualified name, a404when none is held (#1111). - The read of one prescription on the FHIR face answers an
ETagand aLast-Modified(#1118, FHIR R4http.html§3.1.0.2): when the member’sMedicationRequestcarriesmeta.versionId,GET {fhir-base}/MedicationRequest/{id}answersETag: W/"<versionId>", and when it carriesmeta.lastUpdated, it answersLast-Modifiedwith that instant as an RFC 9110 HTTP-date in GMT. A prescription with noversionIdor nolastUpdatedis answered without that header, and so is one whoseversionIdis not a valid FHIRidor whoselastUpdatedis not a valid FHIRinstant, which the gateway logs by its shape alone, never by its value. - The read of a received dispensation on the FHIR face (#1129, Regulation
(EU) 2025/327 Art 14(1)(c)):
GET {fhir-base}/MedicationDispense/{id}reads a dispensation by theidits receipt’sLocationnamed, at the member thatidnames alone, by the member’s ownehr_idand its ownid, with the read rules, theETagand theLast-Modifiedof a prescription read. It takes thecomposition-read scope, is recorded underElectronic-Dispensations, and the capability statement declares it beside thecreate. A test now holds every interaction and operation the capability statement declares to a route that answers it. - The version read of a dispensation and a prescription on the FHIR face
(#1136, FHIR R4
http.html§3.1.0.3):GET {fhir-base}/MedicationDispense/{id}/_history/{vid}andGET {fhir-base}/MedicationRequest/{id}/_history/{vid}read as the read does, at the member theidnames alone, with its scope,ETag,Last-Modifiedand access record, and answer the resource when the member’smeta.versionIdis{vid},404otherwise. TheLocationa dispensation’s receipt answers now reads back what it named, carries the member’s version only when that version is a FHIRid, and the capability statement declaresvreadfor both resources. - Configuration revisions (#576):
config.ui = "managed"keeps the configuration, less its file-only bootstrap core (profile,[server],[metrics],[auth],[config]and[dev]), as a history of revisions in an embeddedredbfile or a PostgreSQL database the replicas share. The file load is a revision, and an operator saves, compares, applies through the reload path and rolls back revisions on the admin listener under/admin/config, behind the issuer’s operator scope. Each revision is checked asconfig checkchecks the file and records who, when and why; a save on a stale base answers409 revision-stale; a secret is held only as its_filereference.config.ui = "off", the default, changes nothing. - Under
config.ui = "managed", the operator console manages members and stored queries beside the file (#577). Every member and stored-query version has one owner, whichGET /admin/config/resourcesnames: a member the registry document or a care services directory provides is read-only, and a console member, its organisation, node and endpoints together, is held in the revisions as[[registry.members]]and joined to the document’s.PUT /admin/config/members/{node_id}andPOST /admin/config/members/{node_id}/removesave a revision you preview with the diff and then apply or roll back; a change to a member the file provides is refused409 resource-file-owned, and the removal of no console member404 resource-unknown. A console member survives aSIGHUPreload, and when the file later provides the same member the file’s runs and the console’s copy is reported as shadowed.PUT /admin/config/stored-queries/{name}/{version}stores a stored-query version, which is never edited or removed (§12.7, N44). The console’s new Configuration page shows the revisions, the owners and the history, with a confirmed form for each change. The configuration file never setsregistry.members. - Under
config.ui = "managed", the operator console manages each onward credential and each binding section ([pixm],[pdqm],[xcpd],[nl_gf],[federation.localization],[federation.consent]) as one resource owned whole (#578): one the file sets is read-only and refused409 resource-file-owned, and the console’s survive aSIGHUPreload.PUT /admin/config/credentials/{endpoint_id}andPUT /admin/config/bindings/{section}save a revision with the resource’s settings and secret changes;…/removesaves one without it. A revision holds a secret only as its_filereference: a secret value is written to a new file of mode0600in the newconfig.secrets_dir, never logged, echoed or answered, and refused409 secret-store-unavailablewithout that directory. An applied change to a credential or a binding section awaits a confirmation,POST /admin/config/revisions/{id}/confirm, within the newconfig.confirm_ms(120000 ms by default); without one the gateway rolls it back, on every replica and across a restart. The newconfig.approvalnames resources a revision changes only once an operator other than its author approved it,POST /admin/config/revisions/{id}/approve. Client authentication stays in the file. The Configuration page shows each credential and binding section with its owner and its secrets by reference, and offers the confirmation and the approval with their deadline and who acts. - Under
config.ui = "managed", any configuration revision exports as TOML and TOML imports as a dry-run revision (#579).GET /admin/config/revisions/{id}/exportanswers the revision as an attachment under a comment header naming it, its author and its time, with every secret as its_filereference and without the bootstrap core.POST /admin/config/import?base=&reason=takes a TOML body of at most 1 MiB, checks it as a save, refuses a change to a resource the file provides409 resource-file-owned, and saves it unapplied with the originimport, answering its changes against the running revision; you apply it as any revision. The Configuration page offers each revision of its history as a download and an import form that shows the dry run’s changes. The book describes the file-first and console-first ways of working and how to move between them. [auth]and the listeners’ certificates reload without a restart (#636):SIGHUP, and the new admin listener actionPOST /admin/reloadfor a container no signal reaches, read the configuration,[auth]and the[server.tls]and[metrics.tls]certificate files again and apply them together or not at all. An issuer a reload adds is trusted from the next request on, on the client face and the admin listener alike; a request already being admitted finishes on the issuers it started with. A reload whose[auth]does not load, trusts no issuer of a federating gateway (classauth), or whose certificate files do not form a pair (classlistener-certificate) is refused as a whole.auth.audiencewaits for a restart.POST /admin/reloadneeds the operator scope, is recorded with its operator, and answers what it applied, or422(configuration-invalid) naming the refusal’s class.- Every report of the operator surface names the gateway replica that
produced it, since each replica keeps its own incidents, routing table
and dependency states (#637). The name is the new
[server] replicasetting, else theHOSTNAMEthe process runs with (a Kubernetes pod’s name), elseunnamed; a configured name outside 1 to 64 ASCII letters, digits,.,_and-is refused at load. Each answer under{base}/operator/carries it in aferrofed-replicaheader and each report in areplicamember, and the identity-state export namesreplicain place ofscope. The operator console shows on its members, integrity and stored-query views which replica answered; it reads one replica at a time, as the book’s operator console page says. - The operator console refreshes the operator’s access token on its server
when the provider issued a refresh token (RFC 6749 §6), within 60 seconds
of its expiry or in the second half of a shorter lifetime, so a session
lasts to its idle and absolute timeouts. A
rotated refresh token replaces the old one, the refresh token never
reaches the browser or a log line, and a refused or failed refresh ends
the session and sends the operator to sign in again. The new
[oidc] offline_accesssetting, off by default, asks for theoffline_accessscope withprompt=consent(OpenID Connect Core 1.0 §11);oidc.scopesno longer admitsoffline_accessitself (#643). - The technical documentation of Regulation (EU) 2025/327: a book page that
indexes every element of Annex III to where FerroFED documents it, with
the forms a release is placed on the market in and the hardware it runs
on, held by
conformance/technical-documentation.tsvand thetechnical-documentationguard in CI. A version whose harmonised components changed since the previous release is not released until the clinical safety risk file and the technical documentation have a review row for it (Art 30(2)); the release lane’splanjob runs the same check. The page also says how each release’s documentation and declaration are kept for ten years (Art 30(3), Art 39(4)) and how a translation is provided within 30 days of a request (Art 37(4)). - The register of economic operators of Regulation (EU) 2025/327 Art 35
and Regulation (EU) 2024/2847 Art 23: the procedure on the book’s
Complaints and incidents page and in
docs/post-market.md(what a row holds, who enters it and when, how long it is kept, how a market surveillance authority’s request is answered, and why a public download is not a row), the register’s public format indocs/registers/economic-operators.tsv, which holds no row because the rows are kept outside the public repository, and theeconomic-operatorsguard in CI. The guard’s--registermode checks the private register’s rows and refuses, across its git history, a row removed before its retention date or changed after it was entered. - Each access record carries a security label per category of the data it
reached and per origin of the data, in
meta.securityand on itsehds-categoriesandoriginentities, so your Audit Record Repository can limit who reads which records by category and origin (Regulation (EU) 2025/327 Annex II 3.4). A category is labelled with its own code and an origin with its endpoint id inhttps://ferrofed.eu/fhir/CodeSystem/access-log-originunless[access_log.labels]declares other labels, and an unclassified access carries every category’s label. A malformed label, or one for a category or endpoint the gateway does not know, is refused when the configuration loads (#797). - The operator can export the routing state a gateway process learned, for
a system that replaces FerroFED (Regulation (EU) 2025/327 Annex II 2.6),
with
POST /admin/identity-state/exporton the admin listener (#798). It needs a token with the issuer’soperator_scopeand is recorded with the operator who ran it. The answer is JSON with aformat_version, the federation id and every learnedcreating_system_idmapping with its node and endpoint, documented on the book’s “Exporting the identity state” page. It leaves out the resolution bindings and theehr_idindex, so it carries no patient identifier; each replica is exported on its own. - The access record of a query over many patients’ data (Regulation (EU)
2025/327 Art 9(1)): when the query projects the
ehr_id, the record names eachehr_idits delivered rows hold, at the member that answered it, and the patient behind it, up to[access_log] max_named_ehrs(50 by default); otherwise it carries anehds-populationentity stating the query’s scope and why noehr_idis named, beside the members it asked. The query each member receives is unchanged (#819). - The operator console runs the operator’s two admin actions on the
gateway’s admin listener (#909): the clear of a held
ehr_idcollision, below the integrity incidents, at one replica or at every replica in turn, and the stored-query drift repair, below the stored queries, through the first replica. The console’s new[[admin.replicas]]table names each replica’sadmin_url, which its server calls with the signed-in operator’s own access token; without it no action is offered. Each action asks for a confirmation and names the replica it acted on, and a refusal shows its status and stable error code. The admin listener must then bind an internal address the console’s server can reach, never a public one. - The interoperability component renders an archetype-local code (
localterminology) with the text its operational template defines for it, in the language asked, instead of sending it under eHN Patient Summary Art 10(7) (#927). A template that holds the term in another language only leaves the code to Art 10(7), and a local code the archetype does not define is refused. The component also ships the eHN Patient Summary §4 preferred code systems as URIs (eehrxf::terminology::ehn), and the terminology rules prefer them by default. ferrofed config checknames each Art 14(1) category the FHIR face receives, with the member its[[fhir.receive]]entry declares, and the categories no member receives: the setGET {fhir-base}/metadatanames. A configuration with no[fhir]gets neither note (#930).- The EU declaration of conformity of Regulation (EU) 2025/327 Art 39,
with every item of Annex IV, written for each release by
scripts/release/declaration-of-conformity.shfrom the records at the tag and the values only the manufacturer supplies inconformance/eu-declaration-of-conformity.toml. The release lane attaches it asferrofed-vX.Y.Z-eu-declaration-of-conformity.mdonce that file issues it, and refuses a release whose declaration would hold a blank; until then a release carries none and says so in a warning (#940). - The Annex II checklist of Regulation (EU) 2025/327: a book page that
holds every essential requirement of Annex II, by the Regulation’s own
numbering, to its status (met, partial, planned or not-applicable), the
page and the tests that show it, and the issue for any gap. It is kept as
conformance/annex-ii.tsv, and theannex-iiguard in CI checks each requirement against the vendored text and each named heading and test against the tree. The technical documentation cites it for Annex III, point 4, which stays partial while a point is unmet. - Under
config.ui = "managed", a revision applied or rolled back on one replica takes effect on every replica that shares the revision store. Each replica reads the running mark everyconfig.poll_ms(5000 ms by default, 100 to 600000) and applies a revision it does not run through the reload path. A replica that refuses the revision keeps its running configuration, logs the refusal and counts it inferrofed_config_revision_follows_total.GET /admin/confignames the revision the replica runs asrunning, whether it is the current one asin_step, and a revision it refused asrefused. The gaugeferrofed_config_revision_in_stepand the alertFerroFEDConfigurationDriftreport a replica out of step. - The book’s evaluate chapter holds each right Chapter II of Regulation (EU) 2025/327 gives a natural person against the logging and interoperability components, with who acts, the tests that show it and the gaps, and Annex II point 1.3 cites it (#961).
- The interoperability component ships NPU among the eHN Patient Summary §4
preferred code systems, as
http://npu-terminology.org, the URI the HL7 Europe Laboratory Report 2.0.0 includes it by (eehrxf::terminology::ehn::NPU), and the terminology rules prefer it by default (#968). EMDN is not shipped, because no vendored source names its system URI. - The access record of a routed EHR create names the EHR it left
(Regulation (EU) 2025/327 Art 9(1)): the
ehr_idthe member’sETagorLocationnamed (ITS-REST 1.1.0 EHR API), held to theHIER_OBJECT_IDform, is anehrentity with the patient the identity binding holds behind it, and the origin’screated-ehr; an answer that names none, noehr_idor two that differ recordscreated-ehr-unreadasunnamedormalformedand names no value.ehds-logging0.0.21 addsCreatedEhrand the origin’screated_ehr(#983). - The log of each semantic transformation the FHIR face applies (#985;
eHealth Network General Guidelines Release 3.1 Art 6(3)): the access
record of a patient summary and of a received document carries one
ehds-transformationentity per transformation, naming the direction (providedorreceived), the Art 14(1) category, the context mapping, its template, profile and mapping files, the package a received document was held to, the terminology releases and code system versions in force, and the elements transcoded, left untranscoded (eHN PS Art 10(7)) and dropped, each counted at its FHIR element path or the path a declared loss names. No value, code value or patient identifier is written.eehrxf0.0.18 addstransformation::Report,Mapping::filesandMapping::pin;ehds-logging0.0.23 addstransformation::TransformationandAccessRecord::transformations. - The configuration page of the operator console edits a console member’s
FHIR endpoints (
hl7-fhir-rest, the exchange-format endpoints) beside its Query API endpoints: the member form shows each with itsendpoint_id, base URL,ehr_ididentifier system, categories, profiles and status, and a save adds, edits or removes one as the form shows them, merged onto the held endpoint of the sameendpoint_id. A form drawn without the field keeps the held FHIR endpoints whole, the form that adds a member refuses anode_idthe gateway already holds, a base URL that is not an absolutehttporhttpsURL is refused, and every refusal names the endpoint by its position. Before, a FHIR endpoint was kept on save and changed only through the admin API. - A person’s own patient summary (#991): an electronic health data access
service (Regulation (EU) 2025/327 Art 3(1), Art 4) asks
{fhir-base}/Patient/$summarywith a token confined to the person and the scopepatient/aql-eu.ferrofed.eehrxf::*.s, and gets the summary from every member that holds them. A request for another patient is403(patient-confinement) before the PDQm Supplier or any member is asked. The access record names the person as the caller and as the data subject. A professional’s request is unchanged. - Information a person inserts, marked as theirs (#992, Regulation (EU)
2025/327 Art 5): a document the FHIR face receives under a person’s own
grant, a token whose
subnames the person and whosepatient/scopes are confined to them, is written with every entry naming the record’s subject, aPARTY_SELFwith no identifier, as itsprovider(openEHR RMENTRY.provider). Its access record carries theehds-subject-insertedentity. A person’s document that maps to no entry, or to aGENERIC_ENTRY, is422with nothing sent. A professional’s receipt is unchanged.eehrxf0.0.17 addsReceivedComposition::inserted_by_subject, andehds-logging0.0.22 addsAccessRecord::subject_insertedand its BALP entity, whichehds-logging0.0.24 replaces withinserted(#1003, above).
Changed
- The access record of a semantic transformation counts each loss the mapping run declared at the FHIR R4 element the loss is located at, read from the engine’s located losses, in place of the mapping, cell or output path the loss’s text named; a loss with no FHIR element is counted with no path.
- The harness and the quickstart run FerroEHR 4.3.9, node and database image alike, pinned by digest, in place of 4.3.6 (#1012).
- The FHIRconnect mapping engine builds on the same
openehr-*release as the rest of the gateway, so the server links one copy of that family in place of two (#1028). This moved it tofhirconnect0.1.111 onopenehr-*0.0.86; the release ships the later versions theopenehr-*0.0.87 entry below names. - A security fix ships as its own patch release on each supported line,
the releases that share a
<major>.<minor>version, with no feature changes in it (#1064, Regulation (EU) 2024/2847 Annex I Part II(2)). SECURITY.md, CRA information to the user, the cybersecurity risk assessment and the support sentence that opens each release’s notes say so. - The operator console reads a refused configuration file through the same TOML fault classification as the gateway and the registry, so a console refusal names its key, line and kind of fault by the same rules and never quotes the file.
- The operator console has a new shell. The top bar shows the gateway’s
status as a pill, a light, dark and system theme switch, and the
operator’s name with a menu that holds Sign out. The theme is light
until the operator chooses another; the server keeps the choice in a
second
HttpOnlycookie,ferrofed_viewer_theme, which holds only the theme’s name, and draws each page in it, so no page shows the wrong theme first. The navigation is grouped under Overview, Federation, Queries and Gateway, with an icon beside each entry and the current page marked. On a narrow screen it is a drawer: the keyboard opens it, Escape closes it, and the focus moves in and back. The sign-in is a centred card, a page the console does not serve and a fault are centred notices with the status and the way back, and the landing page is a dashboard. The footer is one line with the product, its version, Cadasto B.V. and a link to the new About page, which names the manufacturer in full as Regulation (EU) 2025/327 Art 30(1)(g) asks, with the gateway the console reads (#1075). - The operator console’s dashboard shows the federation at a glance: the
members active, the nodes up, the open integrity incidents, the stored
queries and the running configuration revision with the one pending, and
each endpoint’s health and the recent incidents, each linking on. The
members page shows health as a pill and leads to a page per member with
its endpoints. The
creating_system_idrouting table moves to its own Routing page with a filter, and the integrity page filters its incidents between open and resolved. A replica with no name is no longer named “unnamed” (#1076). - The operator console’s stored queries are listed by namespace, each version a
chip and the gateway’s own namespace marked read-only, with no AQL in the
list. A query’s detail at
/stored-queries/{name}shows its versions with a switch between them, when each was stored, and its AQL highlighted, and “Open in query console” chooses the query there by its name and version alone. The self-description shows whatOPTIONS {base}/declares as cards, with the manufacturer it names, and the whole document behind a disclosure (#1077). - The console’s query console is an editor card and a results card (#1078). The editor has AQL and Stored query tabs, parameters as name and value rows, offset and fetch on one row, and federation options in which the declared endpoints and organisations are checkbox chips and best effort is a switch. The results card shows a summary strip, one chip per node with its status, rows and latency, the rows in pages of 25 under a header that stays, the raw result set on its own tab, and a refused query as the card’s error state. The focus moves to the results after a run.
- The operator console’s configuration page is redesigned: five tabs, each
at its own path (Members, Credentials and bindings, Stored queries,
History, Import and export), with the card of the revision saved and not
applied above each, its changes marked
−and+, and its apply, a running revision’s confirm and roll back, a removal and a stored version each confirmed in a step of their own instead of a checkbox. A member’s endpoints, a credential’s grant settings and a binding section’s settings are rows of their own fields, never one line of separated or escaped text, and each secret is kept, set by a value, read from a file or cleared. A form’s outcome shows at the top of its card with the focus moved there, a refused field is marked where it stands, and the history compares any revision with the running one (#1079). The Integrity page’s collision clear and the stored queries’ drift repair confirm in the same step. - The operator console page of the book shows every console screen in the
light theme, with a link to the same screen in the dark theme: the sign-in,
the dashboard, the members and a member’s page, the routing table, the
integrity incidents, the query console empty and with a complete, an
incomplete and a refused answer, the stored queries and one query’s
detail, the self-description, each configuration tab and the member form,
the About page and the page for an unknown path (#1081). The testkit takes
them behind
FERROFED_SCREENSHOTS=1, and a test fails when a console route has no screenshot. - A security patch release cut on an older supported line no longer becomes
the repository’s latest release or moves the
latesttag of the gateway and console images: only the highest version released does. The release checklist describes how a security patch is cut from a supported line’s last tag on arelease/<major>.<minor>.xmaintenance branch (#1087). - The notice for a release past its support period, in the startup banner,
ferrofed config checkand the log, now says to move to the latest patch release of a supported line, since a security fix ships as a patch release on each supported line (#1090). ferrofed servelogs the address each listener bound on itslisteningandserving the admin listenerlines, so a listener configured on port 0 names the port the system gave it, andserver.listenandmetrics.listenmay both name port 0 on one address, since each bind takes a port of its own.- Hardening, the instructions for use and Upgrading state the security-fix
policy of
SECURITY.md(#1095): a security fix ships as its own patch release on each supported line, so an operator moves to the security patch release of the line it runs. They no longer say fixes go to the latest release only. - The capability statement of the FHIR face declares the
createonBundleonly when a member receives a category, as it lists the prescription resources only when a member serves them (#1110); a deployment with no[[fhir.receive]]no longer declares acreatethat refuses every document. - The console’s stored-query pages mark a version read-only as the gateway’s
operator surface marks it, with no copy of the gateway’s reserved-namespace
rule, and the detail of one stored query reads that name’s versions with
GET {base}/operator/stored-queries/{name}instead of every page of the listing (#1111). - The
openehr-*crates move to 0.0.87, withfhirconnect0.1.117,openehr-mapping-core0.1.111 andfhir-types0.1.113 on the same release, so the server still links one copy of each family. A received document is mapped to a composition whosecategorythe template decides: its assumed value, or its one admitted code, oreventwhen it leaves the category open. A template that admits several categories and assumes none now refuses the mapping, naming<root>/category|code, whereeventwas taken before. - The Complaints and incidents and Regulatory status pages,
SECURITY.mdand the post-market procedure name Commission Delegated Regulation (EU) 2026/881: it sets when the CSIRT that first receives a CRA Art 14 notification may delay passing it on, binds that CSIRT and adds no duty for the manufacturer, and a notification now says when a security update or user guidance is expected, the one ground that starts with the manufacturer (its Art 3(a)). Delegated Regulation (EU) 2025/1535 is read and does not reach FerroFED (#844, #852). SECURITY.md,CONTRIBUTING.md, the README and the book keep what is particular to FerroFED and link the FerroHEALTH family book at https://ferrohealth.eu/docs/ for what every product shares: the manufacturer, the vulnerability-handling policy, the Cyber Resilience Act from the manufacturer’s side, licensing and trademarks, and how the products fit together (#848).- A double-quoted query parameter on
GET {base}/v1/query/aqlandGET {base}/v1/query/{qualified_query_name}[/{version}]binds the string it names, quotes removed and escapes decoded, as the same string does in aPOSTbody:name="123"binds123, where the quotes were kept before. A patient identifier bound that way, quoted, inside text, namespace qualified or spelled in JSON escapes, is still refused400 identifier-elsewherebefore any member is asked, and one bound quoted as the subject now resolves as the bare identifier does. columns[].pathof a federated result set keeps the AQL variable as the client wrote it,c/uid/valueandp/id, as the Federation Tier §9.4 example renders it, where it was/uid/valueand/idbefore. Two variables of one class, such asc1/uid/valueandc2/uid/value, now render apart. A client that matched the stripped form reads the variable first.- The access record of a write names the version it left at the member
(Regulation (EU) 2025/327 Annex II 3.2): a routed create, update or delete
records the
OBJECT_VERSION_IDthe node’sETagorLocationnames as the origin’swritten-version, and theversion-uidof the composition it wrote; an answer that names no version, or noOBJECT_VERSION_ID, recordswritten-version-unreadasunnamedormalformedand names no value. A received document’s record names its version the same way. The originstatusof a routed read or write, and of a received document, is now the Federation Tier §11.1 status (active,node-error,time-out,offline,consent-denied) a query’s origins already carry, in place of the HTTP status orfailed(#946). - The clinical safety risk file rates hazard L-07 controlled for the access records the gateway writes, which are reviewed with ITI-81 at the Audit Record Repository and each state their retention period; keeping them for that period stays with the repository (#962).
ferrofed_node_requests_totalreads theoutcomeof a routed request and of an ask-all probe with the rule the access record names a routed request’s origin by (Federation Tier §11.1, N16): a node’s4xxanswer is nownode-errorwhere it wasactive, so the counter and the record never read one answer apart, and a newclasslabel,memberorclient-error, tells a member’s failure from a client’s mistake it answered correctly. A probed member’s404, which says it holds no such EHR, staysactive, and a request answered with aDPoPnonce challenge no proof could answer is now counted asnode-error. The dashboard’s member request panel splits byclass(#982).
Fixed
- The patient summary document holds no
OperationOutcome: a mapping run that declares a loss reports it in the transformation record of the access, and the documentBundlecarries only the summary’s content, as the HL7 Europe Patient SummaryBundleprofile slices it (#1008). - A reload names a changed
server.public_url,server.trusted_proxies,server.forwarded_header,server.replicaorsigning.rotation_overlap_sunder the settings that take effect only on a restart; it kept their start values without saying so. A test changes every key of a maximal configuration in turn and holds each to being applied by a reload or named as needing a restart (#1017). - The configuration page’s member form writes a semicolon inside an
endpoint as
%3Band reads it back once, so a member whose endpoint URL, identifier system or profile holds a;saves back through the edit form. Before, the form split such an endpoint at the semicolon and refused it. - The operator console answers
408 Request Timeoutto a request body not read whole within 30 seconds, and400to one that breaks off, before any session check or server function runs. - The operator console answers
413 Payload Too Largeto a request body sent chunked that grows past its limit, as it answers one whoseContent-Lengthdeclares it. Before, it answered500. - A stored configuration revision that does not read is logged by its
position, key and kind of fault, never by a line of the revision the TOML
or JSON reader quotes, so a credential in a URL query never reaches the
log. A configuration parse refusal now uses the same fault words as a
registry document refusal:
unknown field,duplicate key,invalid type, expected …. - The operator console draws its navigation and the Sign out button only for a signed-in operator, decided by the server from the session; a visitor’s landing page shows what the console is and the sign-in. When the session store cannot be read, a page answers 500 and says so, offering neither (#1036).
- The documentation of the receive check in
eehrxf0.0.23 says what the code does (#1054): therequiredterminology bindings are held byReceivedDocument::check_bindingsandterminology::bindings::check_required, whose results convert into the check’s findings, andCategory::document_typeandCategory::profilesgiveNonefor a prescription or a dispensation in every build, because HL7 Europe MPD 1.0.0 has no document profile. - A stored-query row the embedded or PostgreSQL store cannot read is reported by the field and the kind of fault, never by the reader’s error, which can quote the held text, so a damaged store’s log line carries no part of a stored definition.
- The document list on the FHIR face no longer answers
200when the cross-reference could not answer for a member (#1104): under all-or-nothing it fails with424naming that member asnot-resolved, as the patient summary and the prescription search do, and underpartialit names the member in anoutcomeentry beside the documents it lists. - A patient the cross-reference could not answer for at any member is no
longer answered on the FHIR face as a patient no member holds (#1107).
Under
partial, the patient summary names each such member asnot-resolvedin every section, where it answered404, and the document list names each one in anoutcomeentry, where it answered an emptysearchset. A patient summary underpartialnow also names a single member the cross-reference could not answer for. A patient every member answers it does not hold still gets the empty answer. - The operator console’s muted text in the light theme is a shade darker
(
#58687d), so it meets the 4.5:1 contrast of WCAG 2.2 SC 1.4.3 on every ground it can sit on, the inset panels and the accent tint included. A test computes the contrast of every text and ground pair of both themes from the stylesheet and fails a pair below 4.5:1, or a control edge below 3:1. - The operator console’s page of one member and the detail of a stored query no longer fail now and then with a cut-off answer. Each page’s title read the route’s parameters after the server had cleaned them up, which panicked the render; the title now falls back to the name the page rendered with. A test fetches the stored query’s detail fifty times in a row and checks its title each time.
- The four FerroEHR nodes of the local
compose.yamlstack, and every FerroEHR container the test harness starts, run withFERROEHR__USAGE_REPORT__ENABLED=false(#1128). A container does not inherit the CI runner’s environment, so FerroEHR’s own CI detection never fired, and each test run or local stack reported itself to FerroPULSE as an installation. - A reload applies a change to
[federation.consent], and to[federation.localization]beside the node selection and budgets the process started with; it kept both at their start values without saying so, and an applied configuration revision now changes them too (#578). - A write whose access record the audit spool has no room for is refused
503 access-unrecordedbefore any member is asked. Before, a routed ITS-REST write or a document received on the FHIR face reached its member first and was then answered503, so a client that sent it again wrote it twice. The gateway now reserves the record’s place in the spool before the write, and two writes never take its last place. Once a member was asked, its answer stands; a reserved record that still cannot be stored raises the security eventwrite-unrecordedand the alertFerroFEDWriteUnrecorded.AccessSinkinehds-logging0.0.17 gainsreserve, andihe-iti0.0.35 addsSpool::reserveandAuditRecorder::reserve. - Under
config.ui = "managed", a changed configuration file that aSIGHUPreload records as a revision is markedpending-restart, with the keys a restart applies, when the reload leaves settings for the next start. Before, it was markedapplied, soGET /admin/configclaimed a configuration ran that only partly did, while a revision saved and applied through the API was markedpending-restart. - The interoperability component no longer writes an openEHR terminology id
such as
localas a FHIRCoding.system(#967). An archetype-local code is written under the code system of the archetype it is defined in,https://ferrofed.eu/fhir/CodeSystem/archetype-local/followed by the archetype id;SNOMED-CT,LOINCandICD10are written as their FHIR URIs; and a code of a terminology with no known URI, or a local code with no template context, is written as no coding: its code is in the narrative, the result names it as unmapped, and the item follows eHN Patient Summary Art 10(7). - Under
config.ui = "managed", aSIGHUPreload whose changed file the reload refuses records the file’s revision and aconfig-revision-refusedrecord naming the class of the refusal, and leaves the running mark where it was, so no other replica on the store follows it. The revision is marked running only once the reload applied it, and the same file read again is tried again. A start likewise marks the file’s revision only once the gateway built what it runs, and one that fails leaves the mark and writes the same record (#972). - The operator console’s integrity view lists the collisions an operator
cleared recently, newest first, as the gateway reports them beside its
incidents. Before, the view left them out. A replica’s refusal of an
admin action, a body the console cannot read, and an admin listener it
cannot reach are each logged as that action, with the replica, the
status and the code, and never the
ehr_idor the stored query the operator named. Before, each was logged as a view the gateway refused. - The interoperability component writes an openEHR terminology version as a
FHIR
Coding.versiononly in the form the code system’s versions take (#977). A SNOMED CT version passes only as a URI naming an edition, such ashttp://snomed.info/sct/900000000000207008/version/20260201, and an HL7 Administrative Gender, RoleClass, RoleCode or ObservationCategoryCodes version only as dot-separated digits; any other is left out. Every other system’s version passes as recorded, because no vendored source shows its form. - The operator console logs a query the gateway refuses as a refused query, and a configuration action a replica refuses as a refused configuration action, naming the action (a member save, an apply, a rollback, a stored-query store and the rest) and the replica, with no identifier, AQL text or token. Before, a refused query was logged as a refused view and a configuration action as an admin action.
Security
- A registry error that names an endpoint’s base URL, such as two endpoints
sharing one, shows its scheme, host, port and path alone, never a user
name, password, query or fragment. Since the operator console shows a
configuration-invalidmessage, it never shows a credential carried in a URL. - A registry document the TOML reader refuses is reported by its position,
key and kind of fault, never by the line the reader quotes, so a
configuration-invalidmessage on the operator console never shows text of the document, such as a credential in a URL query. - The operator console’s site build installs the Tailwind CSS CLI and wasm-opt at pinned releases, checks each against its pinned SHA-256 before it runs, and refuses to build unless the tools on PATH match those digests, so cargo-leptos never downloads or runs an unchecked binary in the attested release lane (#1051).
0.0.10 - 2026-10-15
Supported until 2031-10-15, 5 years from this release (Regulation (EU) 2024/2847 Art 13(8), (19)). A security fix ships in the latest release, as SECURITY.md says.
Upgrade notes
- A gateway with a registry outside the development profile must set
[audit] destination(#623, #693), and it must be"repository"with an[audit.repository]table:"log"is refused there, because it records no caller and no patient and so cannot hold the access log Regulation (EU) 2025/327 Annex II 3.2 asks for. Point the gateway at your Audit Record Repository, withspool_diron durable storage, before you upgrade. - Set
server.shutdown_timeout_msto at leastserver.request_timeout_ms, or remove it so it takes the request timeout (#625).config checkandservenow refuse a shorter drain, and the v0.0.9 example configuration set 10000 beside a request timeout of 30000. Then give the container longer to stop thanserver.drain_delay_msplus the drain: the examples usestop_grace_period: 40sin Compose andterminationGracePeriodSeconds: 45in Kubernetes. - If you run the Kubernetes example, move from
deployment.yamltostatefulset.yaml(#628). Replace theencryptedstorage class with your own. Wait until the dependency report,GET {base}/operator/dependencieswith an operator token (#751), readsupfor the audit repository, so the spool on the oldemptyDiris drained, then delete the Deployment and apply the StatefulSet. - The listeners need no change: unset,
[server.tls]and[metrics.tls]leave them on plain HTTP as before (#632). Once you set either, remove it before rolling back to an earlier release, which refuses it as an unknown key. - If
[metrics] listennames an address other than a loopback one outsideprofile = "development", set[metrics] scrape_token_fileto a file holding a scrape token and give your Prometheus the same token withauthorization.credentials_file, or set[metrics.tls] client_ca_file(#635). Without either,serveandconfig checkrefuse the configuration. The Kubernetes example reads the token from themetrics-scrape-tokenkey of theferrofed-secretsSecret. - To run the admin listener’s stored-query distribution outside the
development profile, from the gateway’s host or another, set
operator_scopeon the[[auth.issuer]]your operators use and send a token carrying it (#629, #635). Without one it answers401 unauthenticated, from a loopback peer as from any other, where a loopback peer was served before. - If a backend client reads patient data through the gateway with a
system/scope or a token whosesubis itsclient_id, setclient_tokens_act_for_professional = trueon its[[auth.issuer]]and have its tokens name the professional it acts for inextensions.ihe_iua.national_provider_identifier(#661). Until both hold, such a request is answered401 natural-person-required. Then set[auth.issuer.assurance]on each issuer whose tokens reach patient data; without it no assurance level is required, andconfig checknotes the issuer. - A document received on the FHIR face whose codings carry no
displayis now refused with a422unless[eehrxf.terminology]names a terminology server to look the displays up (#687). Senders that omit displays need the server configured. - Read the dependency report at
GET {base}/operator/dependencieswith a token that carries your issuer’soperator_scope(#751).GET {base}/health/dependenciesis no longer served and answers404, so move every monitor that read it, and give it an operator token.ferrofed reportreads the report with--operator-token-file. A proxy rule that kept/health/dependenciesinside can go; the health family left open,{base}/healthand{base}/health/readiness, names no member. - Move any stored-query definition you hold in the namespace
eu.ferrofed.eehrxf, or a namespace under it, to a namespace of your own before you upgrade (#776). The gateway now reserves that namespace for its own read-only queries: aredbor PostgreSQL store, or a definition file, holding one there refuses the start, andconfig checkrefuses such a file. - If you wrote an
[access_log]map on a development build, rewrite its categories with the codes of HL7 Europe’sEEHRxFDocumentPriorityCategoryCS(#824), which are case-sensitive:patient-summarybecomesPatient-Summaries,electronic-prescriptionElectronic-Prescriptions,electronic-dispensationElectronic-Dispensations,medical-imagingMedical-Imaging,medical-test-resultLaboratory-Reportsanddischarge-reportDischarge-Reports, in[access_log.templates],[access_log.archetypes]and the keys of[access_log.retention.categories]. Declare each national category innational_categoriesas<system>|<code>, the absolute URI of your Member State’s code system (or one you control) and its code, and name it the same way in the map and the retention table. The old spellings and a national code without a system are refused when the configuration loads. A search of your Audit Record Repository for a category matches theehds-categorydetail<system>|<code>, never the bare code. - The registry document refuses an
[[organisation]]that manages no[[endpoint]](§8.1: “an Organization manages 1..* Endpoints”), soconfig checkand the gateway’s start fail on it, naming the organisation (#874). Give the organisation an endpoint it manages, or remove it. A registry read from an mCSD directory or in the FHIR form keeps such an organisation, and selecting it still answers404withno-destination.
Added
- The identity page of the book says how each member’s
ehr_ids reach the PIX Manager: the PIXm ITI-104 feed or the PMIR ITI-93 feed from each member, the bulk load of the EHRs that already exist, why the gateway feeds no Manager itself, and what to do when the hospital MPI does not answer PIXm (#622). It names SanteMPI 2.5.12 as a PIX Manager verified against two CDRs, with its setup and its limits, and the products checked that do not answer as the gateway needs. - The end-to-end lane resolves a patient through SanteMPI, a deployable PIX Manager each member feeds over ITI-93, and gets a federated query answered by both members (#622).
- The access log: every federated query, stored-query execution, routed
read and routed write that reaches patient data at a node is recorded
with the verified caller, as Regulation (EU) 2025/327 Annex II 3.2 asks
of an EHR system’s logging component and the GDPR asks of an access
trail (#623). Each record is a BALP
AuditEventsent through the[audit]spool to the Audit Record Repository. It names the provider, the person and their purposes of use, the patient and eachehr_id, the time, each endpoint asked with its node and outcome, and the categories of the data. The categories come from a map you declare in the new[access_log]table, from template and archetype ids to the Art 14(1) categories, national categories ornone. An access the map cannot classify exactly is recordedunclassified, with its ids as evidence, and is never refused. The gateway stores the record before the answer leaves. An access whose record cannot be stored answers503 access-unrecordedwith none of the data. A query run from the operator console is recorded with the operator as the caller. The book’s audit page describes the record and gives an Annex II 3.2 checklist. crates/ehds-logging0.0.1, the European logging component as a library (#623): the Annex II 3.2 access record, the Art 14(1) categories, the category map and its classification, the sink trait, and, behindbalp, the record written as a BALPAuditEventthroughihe-iti. It depends on nothing in FerroFED and on no interoperability component.- The book has a production guide that walks a deployment from nothing to a first federated query over two CDRs (#624): what to have in place, a checklist each CDR meets before admission, verifying and installing the release, the registry, an issuer recipe for Keycloak 26.2 or later that issues the RFC 9068 tokens the gateway accepts with the SMART on openEHR scopes and the purpose of use, the onward credentials, the PIX Manager, the access log, TLS, the checks before the start, admission, the first query and the operations that follow. The Keycloak recipe was verified against Keycloak 26.8.0.
deploy/nginx/ferrofed.confis a reverse proxy for the gateway: TLS on the public address, the base path, the JWK Set and PMIR feed routes passed, the health family kept inside, and an access log that names the gateway’s request id and never a query string (#624).scripts/checks/production-guide.shrunsconfig checkover every TOML block of the guide, then serves that configuration behind the shipped file in nginx and checks each route; CI runs it in the release compose job.[signing] next_key_file, a signing key the JWK Set at{base}/.well-known/jwks.jsonpublishes ahead of a rotation and that never signs, so every replica publishes a new key before any replica signs with it (#627).next_key_algorithmnames the algorithm the next key is meant for, andconfig checkrefuses a next key on another curve, namingsigning.next_key_file, or one that is already the current or previous key. The book gives the three-step rotation for any number of replicas and the node cache time it assumes.- The metrics now show what the gateway’s own clients see (#629).
ferrofed_http_requests_totalcounts every answered request by method, route template and status class, and the OpenTelemetry HTTP server metricshttp_server_request_duration_secondsandhttp_server_active_requeststime them and count those in flight. The cross-reference resolver’s calls are counted by outcome inferrofed_resolver_requests_totaland timed, and the localizer and demographics calls are timed beside their existing counters.ferrofed_security_events_totalcounts every caller refused at client authentication by its reason, and every identifier-hygiene event of theferrofed::securitylog, the outbound-gate stops among them, by kind. Every label comes from a closed set: a route is a template, never a path, and nothing a request sends reaches a label. - Each release attaches a Grafana dashboard,
ferrofed-dashboard.json, and a Prometheus rule file,ferrofed-alerts.yaml, fromdeploy/observability/, held to the exported metrics byscripts/checks/observability.shandpromtool check rulesin CI. The Kubernetes example serves the admin listener on port 9464 of each pod, annotates the pods for scraping, and opens that port to the monitoring namespace’s Prometheus alone in a newnetworkpolicy.yaml. - Upgrade notes, a configuration compatibility policy, and a versioned
stored-query store (#630). Each release’s changelog opens with “Upgrade
notes”, saying what an operator must change:
changelog.d/takes anupgradefragment, and CI runsconfig checkfrom the new build over the example configuration the last release attached, and fails when it is refused and no upgrade note is pending. A renamed key is read under its old name for at least one release, andconfig checkand the start-up log warn about it, naming the release that will refuse it; setting the old and the new key together is refused. Theredband PostgreSQL stored-query stores record a schema version (ferrofed_schemaandferrofed.schema_version), migrate forward in order when the gateway starts, and refuse to start over a store a newer FerroFED migrated, saying so. The book has an Upgrading page, with the notes for v0.0.8 and v0.0.9 and the compatibility policy, and a Rollback page. - The gateway bounds the load it takes on and passes on (#631). Past
server.max_concurrent_requests(512 by default) a request is answered503 overloadedwithRetry-After(server.overload_retry_after_s) before it reaches client authentication or a member; the health family is never refused. The optional[server.caller_rate]gives each verified caller, by issuer andclient_idand never by a forwarded address, a bucket ofburstrequests refilled atrequests_per_second; past it the answer is429 rate-limitedwithRetry-After. At mostfederation.max_in_flight_per_noderequests (64 by default) go to one member endpoint at once; one past it waits for a slot until its per-node deadline and is thentime-outwith nothing sent (§11.5, N38), so one slow member holds no other member’s requests. A zero in any of them is refused byconfig check, andferrofed_overload_refusals_totalcounts every refusal by its limit. The errors page listsoverloadedandrate-limited. - TLS on the gateway’s listeners (#632).
[server.tls]serves HTTPS with a certificate chain and key fromcertificate_fileandkey_file, and[metrics.tls]does the same for the admin listener. An optionalclient_ca_filerequires every client to present a certificate that CA signed, so only the proxy in front of the gateway is admitted. The listener negotiates TLS 1.3 or TLS 1.2 and nothing older, prefers TLS 1.3, and offers TLS 1.2 only with the four suites RFC 9325 §4.2 recommends (BCP 195).SIGHUPreads the files again, so a renewed certificate is presented from the next handshake with no restart; files that do not form a valid pair are refused and the running certificate stays.ferrofed healthcheckfollows[server.tls], accepting exactly the certificate the file holds and presentinghealthcheck_identity_filewhere a client certificate is required, and says when the file was replaced without aSIGHUP.config checkreads every file and refuses a missing, unreadable or mismatched one naming its key, never its content. Unset, the listeners speak plain HTTP as before, for a proxy that terminates TLS; the configuration page says which mode to use when.url_schemeon the HTTP server metrics readshttpson a TLS listener. - The book’s Evaluate part has a data-protection page (#633): every store and channel that carries personal data, with its retention and its protection; the controller and processor roles of a hospital and a regional deployment; DPIA inputs and a template for the GDPR Art 30 record; what the gateway offers an entity under NIS2; and what the documents claim about Regulation (EU) 2017/745, with what counsel must confirm.
- The book has a threat model and a hardening guide (#634). The threat model names the assets, the trust boundaries, the attacker positions and each mitigation with the setting or test behind it, and lists the risks a deployment carries with their issues. The hardening guide is a checklist for network placement, TLS, issuers, secrets, key rotation, the admin listener, the audit spool, resource limits, logs and the console.
- A troubleshooting page in the book leads from what an operator sees (a status and error code, a log line under its target, a metric, a refusal at start) to the cause, how to confirm it and the setting that fixes it, with a row for every error code the gateway answers, which a test holds to the gateway’s own table (#640).
- The client contract and the replicas section say how a follow-up write
behaves behind several gateway replicas (#641): each replica holds its
own resolution bindings, so a write that names no node is routed by the
replica that resolved the patient and refused
400target-requiredby the others. A client sendsopenEHR-federation-endpointon every write, or the operator keeps each client on one replica with balancer affinity. - The book’s Regulatory status page names the two implementing acts adopted under the EHDS Regulation, Commission Implementing Regulations (EU) 2026/2083 on MyHealth@EU and (EU) 2026/2099 on cross-border identification and authentication, with their dates (#652). Both are vendored beside the Regulation, with Commission Recommendation (EU) 2019/243 on the exchange format. A weekly check reads EUR-Lex and the Commission’s “Have your say” register and files an issue when an act the harmonised components wait for is adopted (#653), and the weekly pin-freshness read now reports a newer stable tag of every container image the test harness starts (#678).
- An access to patient data in the vital interests of the data subject is
marked in its access record (Regulation (EU) 2025/327 Art 11(5)): each
[[access_log.emergency_purpose]]names a purpose of use, such as the HL7 v3ActReasoncodeBTG, and a record whose verified token declares one carries the entityehds-emergency-access, with a description in words and the purposes that marked it. The mark is read from the token’s purposes alone and never inferred. It changes no dispatch and no answer: the purpose reaches each node in the conveyance, the node decides, and a node’s refusal stands. The gateway writes one log line under its request id for each marked access, andconfig checknotes an access log that names no emergency purpose. The book’s audit and authentication pages name the codes a deployment maps (#659). crates/ehds-logging0.0.9: theemergencymodule,EmergencyPurposesandEmergency, the record’semergency, and its BALP entity (#659).- The authentication assurance of every caller that reaches patient data
(#661, Regulation (EU) 2025/327 Annex II 3.1). Per issuer,
[auth.issuer.assurance]names the claim that carries the assurance,acrby default (RFC 9068 §2.2.1), the values that stand for the levelslow,substantialandhighof Regulation (EU) No 910/2014 Art 8(2), and the least level a query, an EHR request or a DEMOGRAPHIC request needs. A token below it, without the claim, or with a value listed at no level is answered401 authentication-assurance-insufficientwith the RFC 9470 §3 challengeinsufficient_user_authentication; no node is asked and no access record is written. The level is read from the token or the introspection answer, andconfig checknotes each issuer that declares none. Each node is told who acts (acting), the level reached (assurance_level) and the professional’s IHE IUAsubject_nameandnational_provider_identifierin the caller’sopenEHR-federation-clienttoken, and in nothing else the gateway sends. - Every request to a node now tells it the professional’s roles, from the IHE IUA
subject_role, and the agency that issued the professional’s identifier, from the claim an issuer’s newprofessional_issuing_authoritynames, beside the identifier and the provider’s identifier it already carried, so a node can identify the professional and provider of every registration (Regulation (EU) 2025/327 Art 13(4), Implementing Regulation (EU) 2026/2099 Annex Table 1). The outbound gate reads the new claims as it reads every caller claim (#662). - The patient summary’s
Patientcarries the header the demographics binding holds (eHN PS A.1.1, A.1.2, #663): the names, birth date, administrative gender, addresses and phone numbers and email addresses the PDQm Supplier of[pdqm]gives for the identifier the summary was asked by, asked before any member and never sent to one (N33). An element the Supplier does not hold is left out, and a missing birth date carries adata-absent-reasonofunknown. A patient the Supplier does not know is a404, several patients under one identifier a422multiple-matches, a patient with no name a422required, and a Supplier that does not answer a502or504; none of them asks a member. - The running system names its manufacturer, Cadasto B.V., with its postal
address and its single point of contact, as Regulation (EU) 2025/327 Art
30(1)(g) asks (#669):
GET {base}/andOPTIONS {base}/carry amanufacturerobject (name,postal_address,email,website), the startup banner andferrofed --versionprint it, as doesferrofed-viewer --version, and every page of the operator console names it in its footer. Both container images carry it in theorg.opencontainers.image.vendorand.authorslabels and a neweu.ferrofed.manufacturerlabel. The details are written once, and a test holds every surface andLICENSEto them. - The information sheet and the instructions for use of Regulation (EU)
2025/327 (#670). The book’s new “Information sheet” page gives every item
of Art 38(2): the manufacturer, the name, version and date of release, the
intended purpose, the six Art 14(1) categories and the standards with
their versions;
scripts/checks/versions.shholds its standards and version todocs/VERSIONS.mdand its date toCHANGELOG.md. The new “Instructions for use” page covers installation, the configuration of a deployment in the EU ([federation.consent] disclose = false, the assurance level per issuer, the access records, the category map), the sizing of the budgets and limits, how a client reads an answer safely, maintenance and its frequency, the limitations Art 28(b) requires, and accessible formats. The release checklist updates both at each cut. - The manufacturer’s post-market procedures under Regulation (EU) 2025/327
(#672): a complaint channel at info@cadasto.com, a register of complaints
and a register of non-conforming versions under
docs/registers/, corrective action, recall and withdrawal with the notices to authorities and users, and the serious-incident report to the market surveillance authorities “not later than three days” after awareness (Art 44(7)), separate from any NIS 2 notification. The book’s new “Complaints and incidents” page is the public account anddocs/post-market.mdthe working checklist;SECURITY.md,SUPPORT.mdand the issue chooser point to them. - A clinical safety risk file for the two harmonised software components of Regulation (EU) 2025/327, Annex II, point 1.1 (#673). The book’s new “Clinical safety risk file” page follows the risk management process of EN ISO 14971:2019 as a method, with no claim of conformity: severity and occurrence scales, an acceptability rule, and a hazard log for the logging component, the interoperability component and the hazards they share. Each hazard names its control and the test or code that implements it, or the issue that plans it; the silent node, duplicate entries across nodes, an unmapped code and a demographic mismatch are among them. The release checklist reviews the file whenever a release changes a component.
- The IHE FHIR services take the OAuth 2.0 client-credentials grant (RFC 6749
§4.4; IHE IUA ITI-71) in
[pixm.manager.credentials.oauth2],[pdqm.credentials.oauth2],[pmir.credentials.oauth2]and[registry.mcsd.credentials.oauth2](#677). The gateway authenticates with a client secret, in the Basic scheme (client_auth = "client_secret_basic", as ITI-71 prescribes) or the request body ("client_secret_post"), read inline or fromclient_secret_file, or with a client assertion the[signing]key signs ("private_key_jwt"), and asks for thescopethe service’s authorization server defines. The token is cached until 30 seconds before it expires and incorporated in each request as a bearer token (ITI-72); a401gets one more request with a fresh token.config checknames every key it refuses: token exchange, aDPoPkey, a certificate binding or TLS client authentication in an identity service’s grant, a secret besideprivate_key_jwt, a scope outside RFC 6749 §3.3, and a token endpoint that is nothttpsoutside the development profile. A node’soauth2grant refuses a client secret. Theihe-itiPIXm, PDQm, mCSD and PMIR clients take anauthorizer::Authorizerfor the headers of each request. crates/eehrxf0.0.1, the European interoperability component of Regulation (EU) 2025/327 as a library (#684). Its dataset model reads the Xt-EHR EHDS Logical Information Models package as data: every logical model and element path, with what a producer and a consumer must do with each element. One feature per Art 14(1) category names the dataset of that category. Behindfhir-r4, it compiles FHIRconnect mappings against an operational template and runs them in process over a canonical-JSON composition, through FerroBRIDGE’sfhirconnect0.1.108, answering a FHIR R4 Bundle with aProvenance. It depends on nothing in FerroFED and on no logging component. An architecture test fails when the two components reach each other or the application, when a crate other than the server links both, or when the engine reaches either.fhir-typesmoves to 0.1.108, the releasefhirconnectbuilds on.cargo denynow checks every feature, so a dependency behind a feature no crate enables yet is checked too.- A member’s own exchange-format endpoint, the
nativemode (#686): a second[[endpoint]]of a node withconnection_type = "hl7-fhir-rest", itscategories,profilesand theehr_id_systemthe member’sehr_idis written in. For a patient summary, the gateway leaves such a member out of its section queries and asks its endpointPatient/$summarybyidentifier=<ehr_id_system>|<ehr_id>andprofile, through the outbound identifier-hygiene gate, with the onward credential and the signed caller identity of the member’s Query API endpoint, so no patient identifier reaches it (N33). The member’s document comes back byte for byte, and every section of the gateway’s document names the member as serving its own summary. A member whose endpoint does not answer makes the summary incomplete,424or504under all-or-nothing, and is named in every section underpartial. The endpoint is absent fromOPTIONS {base}/, and a directive or a targeting header naming it is400.config checkrefuses a native endpoint with noehr_id_system, no category or no profile, a category code that is no Art 14(1) priority category, and a patient summary endpoint that does not declare the EPS composition profile. - The interoperability component carries the terminology rules of the
exchange format (#687): a code in a preferred system passes as recorded,
an openEHR
TERM_MAPPINGof match=adds a coding after the original, a broader, narrower or unknown mapping adds no coding and is written into the narrative, and a code in no preferred system follows Art 10(7) of the eHealth Network Guidelines on Patient Summary. - An optional FHIR terminology server,
[eehrxf.terminology], is asked for$translate(only anequivalentorequalmatch becomes a coding),$lookupand$validate-code. A refusal or a time-out is an error that names the operation and the server’s status. - A received document is held to the
requiredbindings of itsBundleandCompositionprofiles, against the value set expansions its package carries; a binding no expansion and no terminology server decides is listed as not evaluated. A received coding with no display is given one by$lookup, and refused when no terminology server is configured. - The access record of a query answered by several endpoints now carries
the categories of each origin (#694), as Regulation (EU) 2025/327 Annex II
3.2(c) and (e) ask. Each endpoint’s categories are classified from the
rows it answered with, before the merge, under the same rules as the
access’s own, so an id the map does not hold marks that origin
unclassified. The merged answer, its columns andDISTINCTare unchanged. An origin’s set can name a category of a row the merge then cut byLIMITorDISTINCT, and never misses one it delivered. ferrofed reportwrites one tar archive a deployment attaches to a complaint or a serious-incident report (Regulation (EU) 2025/327 Art 44): the version, the commit and target the release build recorded, the bindings, the pins, the release attestations to verify, the effective configuration redacted fail-closed (every key and value not known to be safe, in any casing or nesting, and every[dev]value), and, from the gateway on the same host, readiness, the dependency states, the integrity incidents without theirehr_ids (with--operator-token-file) and the metrics with only the gateway’s own labels. A part it cannot read is listed in the manifest with the reason. The book’s Complaints and incidents page documents the format.scripts/release/withdraw.shwithdraws a non-conforming release without editing anything published. It refuses a version with no finding row in the register of non-conforming versions. It moves the<major>.<minor>andlatestimage tags to the correcting release and keeps the version’s own tag and digest. It publishes the advisory, writes the upgrade note, lists the version under “Withdrawn versions” inSECURITY.mdand records the withdrawal in the register. It runs dry by default.docs/release.mddescribes the route.- The access record names the client’s address behind a reverse proxy (#722).
server.trusted_proxieslists the proxies, each an IP address or a CIDR block, whose forwarded client address the gateway takes, andserver.forwarded_headernames the header they write:forwarded(RFC 7239), the default, orx-forwarded-for. A request from a listed proxy is named by the rightmost address in that header that is no listed proxy; a request from any other peer is named by that peer, whatever headers it sends. The list is empty by default, so every request is named by its peer as before. The per-caller rate limit keeps keying on the verified caller. The shipped nginx file setsX-Forwarded-Forto the address it received the request from and dropsForwarded, and the production guide trusts it on loopback. server.public_urlnames the gateway’s public base URL once (#723). Set, an unsetauth.audienceis that URL, an unsetsigning.jwks_uriis the JWK Set route under it, and an unsetpmir.callback_urlis the feed route under it. Its path must beserver.base_path, and asigning.jwks_uriorpmir.callback_urlthat is still written must name the route the gateway serves under it, orconfig checkrefuses it, naming the key and the URL it expects. Unset, every key is read as written, as before. The production guide shows the shorter form.- The end-to-end lane holds the production guide’s Keycloak recipe (#724): it
reads the
kcadm.shcommands, the protocol mapper files and the[auth]table from the book page, applies them to Keycloak 26.8.0 pinned by digest, and gets a federated answer over two FerroEHR nodes with a signed-in user’s token and a client-credentials token, and a401namingat+jwtwith the token-type setting off. The weekly pin-freshness read reports a newer Keycloak, and it now follows a registry that pages its tag list. - Every release attaches the nginx reverse proxy configuration of the
production guide as
ferrofed.conf, with its SHA-256 asferrofed.conf.sha256sum, and the release lane fails when it is missing (#725). The container page and the production guide download it from the release with the compose files. ferrofed admission check --read-onlychecks a member whose governance forbids test data in its production CDR (#726, §12b.1, §12b.2, N42a): it creates nothing, reads theehr_idandsystem_idof up to--countexisting EHRs through one AQL query, judgesehr_idgeneration andsystem_iduniqueness on them, and ends its report with every condition a run without writes leaves unproven, theehr_idexchange of §5.5 (N34) among them. The admission page says when to run it and when to run the full check against a staging copy instead.- An issuer can be declared a national contact point,
[auth.issuer.national_contact_point](Implementing Regulation (EU) 2026/2099 Art 6 and 7; #734). A patient-data request from it needs every attribute of the 2026/2099 Annex Tables 1 and 2, read from the IHE IUA claims where they coincide and from the claims the table names otherwise, and a purpose of use whateverauth.purpose_of_use.requiredsays; one that lacks either is403 contact-point-attributes-requiredor403 purpose-of-use-required, with nothing sent. Its requests are served with consent exclusions withheld whatever[federation.consent] disclosesays (Regulation (EU) 2025/327 Art 8, Art 11(5)). The professional and the provider reach each node in thenational_contact_pointclaim of the conveyance and the access record in anehds-relayedentity, both marked as asserted by the contact point, and the outbound gate reads them like every other caller claim; the IUAperson_idis never read. A correlation identifier the connector sends in the declaredcorrelation_headeris recorded with the request, and one out of form is400 correlation-invalid.OPTIONS {base}/declares the setting underfederation.national_contact_point, and the book’s authentication and §13.4 pages name who authenticates the foreign professional (CP-39). crates/ehds-logging0.0.6:Accessorcarries what a contact point relays (Relayed),Requesta correlation identifier, and the BALP record writes both (#734).- The access record names the person behind each access as client
authentication verified them (Regulation (EU) 2025/327 Annex II 3.1 and
3.2(b); #742). The BALP
agent:usercarries the professional’s IHE IUAsubject_nameaswho.displayandnational_provider_identifieras anihe-otherIdextension typedNPI, the assurance level of the authentication as anihe-assuranceLevelextension, and who acts,personorclient, as itsrole. The level is recorded only when the issuer’s[auth.issuer.assurance]table maps the value the token states, never inferred. No other part of the record, and no log line, carries them. crates/ehds-logging0.0.4:Accessorcarries the acting mode, the assurance level and the professional’s name and identifier, and the requester’s professional number moves toalt_id.crates/ihe-iti0.0.30: a BALPUsertakes a name, a provider identifier, roles and an assurance level, written to theagent:user(#742).- The reporting procedure of the Cyber Resilience Act, Regulation (EU)
2024/2847 Art 14, which applies to every FerroFED release from 11
September 2026 (Art 69(3), Art 71(2)).
SECURITY.md, the book’s Complaints and incidents page and the maintainers’ checklist indocs/post-market.mddescribe the early warning within 24 hours, the notification within 72 hours and the final report, sent through the single reporting platform to the CSIRT designated as coordinator in the Netherlands and to ENISA; how users are told through the repository’s GitHub security advisories (Art 14(8)); and how these reports sit beside the serious-incident report of Regulation (EU) 2025/327 Art 44(7). - A support period for every release: five years from its release date, the
minimum of Regulation (EU) 2024/2847 Art 13(8).
SECURITY.mdlists each release with its end date (Art 13(19), Annex II point 7) and says how fixes ship in the latest release (Art 13(10)).scripts/release/changelog.sh --assemblenow opens a release’s notes with its end date, adds its row toSECURITY.mdand prints the date; a pre-release gets no support period. - The Regulatory status page states FerroFED’s position under the Cyber
Resilience Act, Regulation (EU) 2024/2847 (#764): every tagged release is
one product Cadasto B.V. places on the market as its manufacturer, Art 14
reporting applies to the v0.0.x releases now, and from 11 December 2027
each release has one technical documentation set, one EU declaration of
conformity and one CE marking for the CRA and the EHDS Regulation. The
page and the information sheet state the support period of at least five
years, with its end date published, and link to
SECURITY.md. A deployment’s counsel point on placing on the market now asks whether the deployment is an economic operator Cadasto B.V. supplied. - Every write action an operator runs on the admin listener is recorded
under
ferrofed::security(#767):admin-write-admittedbefore the action has any effect, counted under the same name, andadmin-write-finishedwith its outcome,abandonedwhen it never answered. Each names the operator’s issuer and subject, the method and route template, and the time, and nothing else: never the token, a path value, a body or a stored query, and the issuer and subject are never a metric label. - Every release attaches its own copy of the information sheet and the
instructions for use, which Regulation (EU) 2025/327 Art 30(1)(d) has an
EHR system accompanied by, as
ferrofed-vX.Y.Z-information-sheet.mdandferrofed-vX.Y.Z-instructions-for-use.md: the Markdown source of the two book pages at the tag, plain text a screen reader reads (#769). The release lane refuses an information sheet that does not describe the version being released, and publishes no release without both files. The gateway and console images name the download address of each copy in the labelseu.ferrofed.information-sheetandeu.ferrofed.instructions-for-use. - The patient summary’s stored section queries (#776): wherever the
stored-query registry is offered, it holds eleven read-only queries of the
gateway’s own under the reserved namespace
eu.ferrofed.eehrxf, at the immutable version1.0.0, one per patient summary section openEHR content feeds (eu.ferrofed.eehrxf::patient-summary-problemsand so on). Each selects, from every member, the whole compositions that contain one of the archetypes the openEHR International Patient Summary template names for its section, once each, with the composition’s uid and template id. The patient is bound through$patientand$namespace, so no node receives the identifier. They are listed, read and run by name as any stored query. APUTinto the namespace answers409stored-query-reserved, and a store that holds a definition there refuses the start. The selection, and the four sections no query feeds, are in the book’s clinical safety risk file, pending the clinical review. app/ferrofed-eehrxf0.0.1, the interoperability component’s federation half, which builds the section queries as AQL syntax trees withopenehr-query;eehrxfstill links nothing of FerroFED (#776).- The openEHR International Patient Summary template of the openEHR
international Clinical Knowledge Manager, vendored with its source under
CC BY-SA by
scripts/vendor/openehr-ckm.sh(#776). eehrxf0.0.4: the patient summary crosswalk (#777). Under thepatient-summaryfeature, each row ofcrosswalk::patient_summarykeys on an Xt-EHREHDSPatientSummaryelement path and names the eHealth Network element ids of the patient summary guideline Release 3.4, the producer obligation, the element or slice of the HL7 Europe Patient Summary composition that carries it, and the profiles a FHIRconnect context may map to in order to feed it.Crosswalk::checkfails when an element a producerSHALL:able-to-populatehas no row, when a row names a slice the pinned profile lacks, or when a required EPS section slice is uncovered. The medical alert (A.2.1.2) and the functional status (A.2.3.4) are listed as gaps for the clinical safety review.dataset::ResourceProfilereads a FHIR resource profile and its slices from a package, and each element now carries its target profiles.- The gateway knows the end of its release’s support period from its build,
never from the network (Regulation (EU) 2024/2847 Art 13(19)). The startup
banner names it on a
Supportline, andOPTIONS {base}/declares it as the top-levelsupported_until, ornullfor a build from source or a pre-release. Once the date has passed, the banner shows anUNSUPPORTEDnotice,servelogs it atWARNandferrofed config checkprints it as a warning.scripts/checks/versions.shfails when a release inCHANGELOG.mdhas no row in theSECURITY.mdsupport table or a row whose end date is not five years after its release date. - Each access record states how long it is kept (Regulation (EU) 2025/327
Art 9(2), Annex II 3.4):
[access_log.retention]declares whole years for every record, per category and per registry endpoint, each at least three, and a record is kept for the longest its categories and origins call for, an unclassified one for the longest declared anywhere. The BALP record carriesehds-retention-years,ehds-retention-endsandehds-retention-ground, so the Audit Record Repository can apply it. A period under three years, an unknown category and an endpoint the registry does not hold are refused when the configuration loads. The book’s audit page names the ITI-81 searches the log is read with at the repository (#795, #660). crates/ehds-logging0.0.7: theretentionmodule, the period a record is kept by category and origin with the three-year floor of Art 9(2), the record’sretention, its three BALPdetailentries, andCategoryMap::category(#795).- The access record of a request addressed by
ehr_id, such as a routed read or write or a query scoped to oneehr_id, names the patient the identity binding holds under it, in each namespace the new[access_log] patient_namespacesnames, so a search of the log by the person’s identifier (ITI-81patient.identifier) finds it (Regulation (EU) 2025/327 Art 9(1)). Under PIXm the gateway asks the member’s PIX Manager with one ITI-83, on behalf of the caller, within the per-node budget. Eachehrentity says how its patient was looked up in apatient-lookupdetail (request-named,found,not-found,unavailable,not-configured,unsupported) and keeps theehr_id; the access is recorded and answered whatever the lookup says, and no identifier reaches a node or a log line (#796). crates/ehds-logging0.0.8:PatientLookupand thepatientof eachEhrAt, every patient found written as a BALPentity:patient, and thepatient-lookupdetail (#796).eehrxf0.0.6: the interoperability component’s receive side (#801, part of #665). Underfhir-r4,receive::ReceivedDocument::readreads a document received in the exchange format once: it refuses an object that repeats a name, decodes the text against FHIR R4, refuses a resource of a type R4 does not define and a document that does not encode back to what was read, holds it to the R4 document rules (bdl-7tobdl-11, andfullUrls that agree with their resource), and resolves its subject to its onePatiententry. Every reference, found by its R4 type at any depth and resolved by one resolver (receive::reference::resolve, which reads only canonical spellings and answers exactly one entry or a typed refusal), is held to that patient: asubject,patient,beneficiaryorformust name it, and a reference that could name another patient, atypethat disagrees with its target, a second or containedPatientand an element the R4 table does not describe are refused. The text is kept byte for byte.ReceivedDocument::checkholds the decoded document to aBundleand aCompositionprofile read from the vendored package: cardinalities,fixed[x]andpattern[x]values, andvalue,pattern,existsandtypeslices, an entry of a type no slice names, and the invariants that require a reference on listed entry types, with every constraint it cannot evaluate listed. Underopenehr,Mapping::to_openehrmaps the document into one canonical composition through FHIRconnect and keeps the document in the composition’sFEEDER_AUDIT.original_contentas aDV_PARSABLE.dataset::Elementnow carries itsfixed[x]orpattern[x]value and its slicing (dataset::constraint). The gateway does not serve the receive path yet; writing a received document to a member is #802.- Receiving a document in the exchange format (Regulation (EU) 2025/327
Annex II 2.2 and 2.3, #802):
POST {fhir-base}/Bundletakes a documentBundleand writes it as one openEHR composition to the member[[fhir.receive]]declares for its Art 14(1) category, by ITS-RESTcomposition_createat that member’s ownehr_id. The document is held to the category’s HL7 Europe profiles from the package the deployment supplies, mapped by its FHIRconnect mapping, and kept whole in the composition’sFEEDER_AUDIT.original_content. Its patient is resolved from everyPatient.identifierin a namespacefhir.receive_namespacesnames, at the receiving member alone, and every identifier must name the sameehr_id; no patient identifier is sent in the path, the query string or the headers. The caller needsuser/composition-*.c(a read scope is refused), and apatient/grant writes only into its own patient’s EHR. A category with no member, a document that breaks its profiles, a patient the member holds no EHR for, identifiers that name two patients, and a member’s refusal or silence are each anOperationOutcomeerror, never a success; every receipt that reached the member writes one access record. A member the registry does not hold, or receiving with no resolver, refuses the start andconfig check. eehrxf0.0.8:ReceivedDocument::categorynames a received document’s priority category from itsComposition.type,Category::profilesandCategory::document_typename the profiles and the document type of each category,Category::codeandCategory::of_codename each category by its HL7 Europe priority category code, such asPatient-Summaries, andReceivedComposition::modelis the mapped composition as the RM model holds it (#802).- The FHIR face’s capability statement,
GET {fhir-base}/metadata, declares thecreateonBundlethat receives a document in the European exchange format. It names the Art 14(1) categories the deployment receives and those it has no receiving member for, and lists theBundleprofile of each category received (#803). - The information sheet lists the categories a release can receive in the exchange format, patient summaries alone in this one, and says where a deployment states the ones it receives and the ones it has no receiving member for (#803).
- The FHIR R4 face of the European exchange format (Regulation (EU)
2025/327 Annex II 2.1, #809): with
[fhir]set,GETandPOST {fhir-base}/Patient/$summaryanswer the patient summary as an HL7 Europe Patient Summary documentBundle, andGET {fhir-base}/metadatathe face’sCapabilityStatement. The face sits on its own base beside{base}, so no ITS-REST path changes. A summary runs the gateway’s own eleven section queries over every member that holds the patient, with or without[stored_queries], resolving the patient once and sending no patient identifier to a node, and maps each composition through the FHIRconnect mappings[[fhir.mapping]]names. Each member whose data a section holds is named as its author with oneProvenanceper mapped composition; a silent member fails the summary under all-or-nothing and is named in every section under partial; what no mapping covers is counted, never dropped. Every request passes client authentication, a summary takes theaql-search scope of each section query, every summary that reached a member writes one access record with thepatient-summarycategory, and every error is anOperationOutcome. The book’s new page “The patient summary over FHIR” describes the request, the document and the configuration. eehrxf0.0.7: thedocumentmodule writes the EPS documentBundlefrom the resources and sections a caller gives, naming every entry under a base and keeping two runs over one composition apart;Mappingnames its compiled contexts and runs under a FHIRconnect call context (#809).ehds-logging0.0.9:Evidence::constructedclassifies an access that serves one category by construction, such as a patient summary, beside what its data show (#809).- The document list on the Annex II 2.1 FHIR face (#810):
GET {fhir-base}/DocumentReference?patient.identifier=system|value, with the optionaltypetoken of the patient summary, LOINC60591-5, answers asearchsetofDocumentReferences that meet the HL7 Europe EU Health Data APIEehrxfMhdDocumentReferenceprofile: the summary the gateway writes on request, retrievable throughPatient/$summary, and eachnativemember’s own document, named byattachment.urlBinary/{token}with its media type, SHA-1 hash and size. The gateway asks each such member’s own endpoint by itsehr_idalone, through the outbound identifier-hygiene gate, with the same budget, onward credential and signed caller identity as for a summary, and runs no section query. A member that does not answer fails the list,424or504, under all-or-nothing, and is named in anOperationOutcomeentry withsearch.modeoutcomeunderpartial.GET {fhir-base}/Binary/{token}reads a listed document through the gateway: the token is a JWS the gateway signs with its[signing]key, of its own JOSE type, valid forfhir.document_url_lifetime_secs(300 unless set) and for the caller it was listed for, and carries no patient identifier; the member is asked again and its bytes are answered unchanged, with nothing stored. Both take the scopes of a summary and write an access record naming every member endpoint asked, andmetadatadeclares the search and the read. eehrxf::receive::conform::check_resourceholds any one resource to a profile by the rules the document check applies, and the component’s own tests hold the patient summary it writes tobundle-eu-eps,composition-eu-eps, the profile each entry claims and the EU core profile its mapped entries are written to (#815).[federation.consent] emergencychooses what the consent pre-filter does for a request whose verified token declares an[[access_log.emergency_purpose]](#833). Under"apply", the default, the pre-filter applies as to any request. Under"pass-to-node", the pre-filter is still asked, and every member it denies is asked all the same, so its node decides (Regulation (EU) 2025/327 Art 11(5); N26, N27). The access record names each such member in anehds-consent-set-asidedetail of itsehds-emergency-accessentity, and the answer never shows it."pass-to-node"needs a declared emergency purpose to load,config checknotes it beside[nl_gf.mitz], andOPTIONS {base}/declares the setting asfederation.consent.emergency.crates/ehds-logging0.0.13 carriesEmergency::with_consent_set_aside. The book states that the pre-filter is asked with the configuredTREATorCOC, never the caller’s purpose.- A directed query can order on an ENDPOINT attribute through the
directive’s variable,
ORDER BY p/organisation DESCalone or among other keys (#874). The gateway takes the key out of every node query, orders the merged rows on the value it holds per endpoint in the place the client wrote it, and breaks a tie on the endpoint id and then the uid; each node keeps the other keys and theLIMIT(§8.1, §9.3, §11.6.1). UnderDISTINCTthe attribute must also be selected, and under version-identity dedup with aLIMITthe key is refused400with the new codeendpoint-order-dedup(§10.2). The variable anywhere else is still refused withendpoint-variable. - A stored-query
PUT {base}/v1/definition/query/{name}with no version is stored as a new version the registry assigns:1.0.0for a name it holds no version of, else the next patch of the newest version by number (after1.9.0and1.10.0,1.10.1), answered200withLocationnaming it as./{name}/{version}(§12.7stored-query-versioning; ITS-RESTdefinition_query_store). A held version is never replaced: the embedded store reads and writes in one transaction, and over PostgreSQL a replica that finds the version taken assigns the next, up to eight times, then answers409 stored-query-heldand stores nothing. The literal-patient refusal applies before any version is assigned, the reserved namespace stays closed, a read-only registry still answers405, and a distribution sends the assigned version. The codequery-version-required, the400thisPUTanswered before, is retired, and the error page lists it as retired (#875). - The operator can clear a held
ehr_idcollision once the node at fault has fixed it, withPOST /admin/ehr-ids/{ehr_id}/clearon the admin listener (#876). It needs a token with the issuer’soperator_scope, is recorded with the operator who ran it, takes the node-localehr_idalone, and forgets it from this replica’sehr_idindex and from every caller’s resolution bindings. The next read asks every member again and is refused409again while the collision stands; a write is never probed for. The answer says what the replica held, and the incident report lists each cleared collision undercleared. - A registry in FHIR form or read from an mCSD directory declares a member’s
own exchange-format endpoint, as the TOML form does (#883). An
EndpointwithconnectionTypehl7-fhir-restin HL7’sendpoint-connection-typenames its categories aspayloadTypecodings in HL7 Europe’s priority category code system, its profiles inpayload-profileextensions and the identifier system of the member’sehr_idin oneehr-id-systemextension. Without them it is refused as before, under the rules the TOML form’s native endpoint meets; the registry page of the book has the mapping.ihe-iti0.0.34 readspayloadTypecodings andvalueCanonicalandvalueUriextensions of a directoryEndpoint.
Changed
- FerroFED implements the 1.0.0 release of the Federation Tier with AQL
specification, approved by the openEHR SEC on 2026-10-12, in place of the
0.9.0 release candidate (#17).
OPTIONS {base}/now reportsfederation.spec_version"1.0"(§7a.2), and the startup banner names Federation Tier 1.0.0. A client that matches"0.9"must accept"1.0"; no other wire structure changes, and no requirement or conformance point moved.openehr-federation0.0.45 carries the newFEDERATION_SPEC. The book links the specification site at its1.0path. nl-generic-functions0.0.19 cites the did:web Method Specification by its section titles, Method name and Method-specific identifier, where it cited a section number the specification does not use (#620). No behaviour changes.oauth-server-metadata0.0.5 says in its manifest that its name is not yet reserved on crates.io and must be claimed before publishing is turned on. No behaviour changes.- Outside the development profile, a gateway with a registry needs an
[audit] destination, because every access to patient data is now recorded (#623).config check,serveand a reload refuse the configuration without one, namingaudit.destination. Outside development a gateway with a registry also refusesdestination = "log", namingaudit.destination: the log target names no caller and no patient, so an access record written there names no one (Regulation (EU) 2025/327 Annex II 3.2).logstays accepted under development, and for a gateway with no registry. The example Kubernetes ConfigMap and the release compose example send the records to an Audit Record Repository, from the spool on their durable volume. Thelogdestination never names the client of a request the gateway received. ihe-iti0.0.27 writes a BALP entity withdetailentries, a user’s organisation as an agent of its own and their alternative identity asaltId, and a record that claims no pattern.openehr-federation0.0.43 reads the archetype and template ids a bound query constrains its data to, and whether they bind every class it reads (#623).- The Kubernetes example runs the gateway as a StatefulSet,
deploy/kubernetes/statefulset.yamlin place ofdeployment.yaml, so each replica keeps its audit spool on a persistent volume claim of its own instead of anemptyDira reschedule deletes (#628). The claim names anencryptedstorage class to replace with your own, and is kept when the StatefulSet is deleted or scaled down. The container page says what a lost spool means for the audit trail. To move an existing Deployment, wait until the dependency report readsupfor the audit repository, then delete the Deployment and apply the StatefulSet. - The front-door documents match the code on
main(#639). The README’s status names what v0.0.10 has built so far and what it plans, each with its issue. The README, the container page and the configuration page say that a secret written inline is accepted under every profile, and that a_filekey keeps it out of the configuration, asconfig checkdoes; a test holdsconfig checkto it. The claims page no longer lists the professional’s identification, the assurance level and TLS on the listener as missing. A test holds the registry page’s reload table to the sections a reload applies. - A request that reaches patient data needs a natural person behind its
token (#661, Regulation (EU) 2025/327 Annex II 3.1). A token whose
subis itsclient_id(IHE IUA ITI TF-2 3.71.4.2.2.1), or one onlysystem/scopes cover (SMART on openEHR master08), is answered401 natural-person-required, unless its issuer declaresclient_tokens_act_for_professional = trueand the token names the professional, in the IUAnational_provider_identifieror the[auth.issuer.requester]professional claim. Definitions,OPTIONSand the operator surface are unchanged. A refused request asks no node and writes no access record. [fhir]needs[pdqm]: configuration load refuses the FHIR face without a demographics binding, because the HL7 Europe Patient SummaryPatientrequires a name (ips-pat-1) and the members federate no demographics (#663).- The public texts are reviewed against Regulation (EU) 2025/327 Art 28 at
every release cut, on the book’s new “Claims review” page, which also
assesses the completeness default, the budgets and the consent pre-filter
against Annex II, point 2.5 (#674). The first review corrects the claim
that a client “never learns it was federated” (
meta.federationnames every member), says that the identifier rule covers every request the gateway composes while a written body travels as sent, credits mutual TLS to a node to v0.0.9, fixes the landing page’s quickstart, which sent no access token, and the README’s, which sent the wrong body, and brings the stated status up to v0.0.9. The claims page gains a list of the limitations to know before deploying FerroFED. - The access record needs no regional binding:
[audit], its spool and the BALP access record are part of every build offerrofed-server, so a build without thebinding-ihefeature records every access to patient data and runs a registry outside development.binding-ihekeeps only the IHE transactions’ own records, and the audit spool metrics are on the metrics surface in every build (#697). - The book’s data-protection and threat-model pages describe the access log
as built (#711): the access records are a row of the processing inventory,
the
logaudit destination row says it names no client of a request the gateway received and is refused for a registry outside development, and boundary B6 and the deployment’s risks name what the access log covers and what stays planned. The hardening guide asks for[audit] destination = "repository"once a registry is configured. The authentication page no longer says the open health routes name no member:GET {base}/health/dependenciesnames every member endpoint id and its state. The operator console page says to setStrict-Transport-Securityat the proxy. The README,llms.txt, the claims page’s limitations and the regulatory status page no longer say the European logging component is not built or that access to patient data is not audited; the limitations now say the access records are kept by your Audit Record Repository and are recorded unclassified until you declare a category map. - The engine compiles no FHIR model (#730).
nl-generic-functions0.0.21 moves the request authorizer a Generic Functions client takes out of the FHIR-bearingnvifeature into a feature of its own,authorizer, atnl_generic_functions::authorizer;nviturns it on, and the engine’s Nuts grant implements it withnuts-authandauthorizeralone. The architecture test now fails when the engine compilesfhir-typesthrough any crate. eehrxf0.0.2: itsfhir-r4feature compiles no openEHR crate (#730). The FHIRconnect mapping from openEHR moves behind a newopenehrfeature, which turns onfhir-r4, and the architecture test fails whenfhir-r4alone reaches an openEHR crate or the mapping engine.- A national contact point’s
country_codemust be the ISO 3166-1 alpha-2 code of an EU Member State (Implementing Regulation (EU) 2026/2099 Annex Table 1), or the request is refused403 contact-point-attributes-required.[auth.issuer.national_contact_point] additional_countriesadmits a further country per contact point, such as a third country whose contact point the Commission connected to MyHealth@EU (Regulation (EU) 2025/327 Art 24(3)) (#734). - The node profile checks of
conformance run --node-profilesend eachehr_id-scoped query as a fixed AQL template with theehr_idas the value of its$ehr_idparameter inquery_parameters, so a node must bind ITS-REST query parameters, in aWHEREclause and in anEHRpredicate, to pass them (#739). The scenario queries ofconformance runare built and printed throughopenehr-query, so a value always reaches the gateway as an escaped AQL literal. A new CI guard,scripts/checks/aql-splice.sh, refuses AQL built by string formatting or concatenation outside the tests. [telemetry] filterno longer quiets the security log (#767): the gateway addsferrofed::security=infoafter the configured filter, so every security line atinfoor above is written whatever it says.- The access record writes each category as a coded value (#824): the six
Art 14(1) categories carry the codes of HL7 Europe’s
EEHRxFDocumentPriorityCategoryCS(hl7.fhir.eu.health-data-api1.0.0-ballot), written<system>|<code>in theehds-category,ehds-category-basisandehds-retention-grounddetails, with a newehds-category-versiondetail naming the code system’s version. A national category is its Member State’s code in that state’s system. No category and unclassified stay states of the record, and the reasons an access is unclassified are a closed set.crates/ehds-logging0.0.12 carries the change:Category::system,versionandtoken,NationalCategoryin place ofNationalCode, andclassify::Unreadablein place of a free reason string. The four packages the codes are read against are vendored with their provenance. - An audit record names the caller’s purposes of use at
purposeOfEventtoo, each once, beside theagent:userpurposeOfUsethat BALP 1.1.4 places them at, so an Audit Record Repository that keeps the DICOM audit message form keeps them, an emergency purpose such asBTGamong them (RESTful ATNA Tables 3.20.4.2.2.1-1 and 3.81.4.2.2.1-1, ITI TF-2 §3.20.7.1.2). The access log’s records carry them there as well (ihe-iti0.0.32,ehds-logging0.0.14; #830). - The harness and the quickstart run FerroEHR 4.3.6, node and database image alike, pinned by digest, in place of 4.3.3. The conformance statement names 4.3.6 as the FerroEHR product the tests run against.
GET {base}/v1/ehr?subject_id=…whose subject resolves at more than one member now answers400(subject-several) instead of409, as N41 has it when no one node is found, with the endpoints a client may name inopenEHR-federation-endpointlisted in the message (#869). Anehr_idtwo members claim keeps N42’s409(ehr-id-collision). A client that branched on the409reads the400and thesubject-severalcode.- The
409(controlling-system-unreachable) for a versioned write whose controlling system is no member now names that system by the version’screating_system_id, read as an RMsystem_id, and says it is outside the federation, as §12.2 and N36 ask (#872). The message still points at where the request names the version and quotes no other part of the request. - A DEMOGRAPHIC request now goes to the endpoint
federation.demographic_endpointdeclares without anopenEHR-federation-endpointheader (#873). One destination the operator fixes andOPTIONS {base}/declares is the explicit choice §12.6 asks for, so the request no longer answers400target-required. A header naming another endpoint is still400targeting-conflict, the query string and headers are held as before, andits_rest.demographicsays the header is optional.
Fixed
- A rolling restart no longer cuts requests in flight (#625). The drain,
server.shutdown_timeout_ms, now defaults toserver.request_timeout_ms, andconfig check,serveand a reload refuse a drain shorter than the request timeout, naming both keys. The newserver.drain_delay_mskeeps the listener accepting for that long afterSIGTERM, with readiness already503, so a load balancer stops routing to the gateway before it stops taking requests; it is0by default. The example configurations drain for 30 seconds, the Kubernetes example waits 5 seconds first, and the grace periods outlast both:terminationGracePeriodSeconds: 45andstop_grace_period: 40s. A configuration that set a drain shorter than its request timeout, such as the earlier examples’ 10 seconds beside 30, is refused until the drain is raised or removed. - The PMIR identity feed across several replicas (#626). A draining gateway
no longer deletes the ITI-94 subscription that the other replicas behind
the same
[pmir] callback_urlrely on: the newon_drainkey keeps it by default, for the other replicas and the next start, andon_drain = "unsubscribe"deletes it, for the last gateway or a replica with acallback_urlof its own. Replicas that find several subscriptions for theircallback_urladopt the same one and delete the others, and a delete the Registry answers404or410counts as done. The book’s identity page describes the two ways to run the feed across replicas and the window during which a replica the change did not reach keeps a stale binding. - Each resolution binding now expires
federation.binding_ttl_msafter the last resolution that returned it. A caller that kept resolving used to keep every binding it had ever recorded, so a binding made stale by an identity merge or split could route a follow-up to a node the patient no longer maps to for as long as the caller stayed active (#650). - On a stop, the bindings’ drain, such as deleting the PMIR subscription,
runs under its own budget,
server.bindings_drain_timeout_ms(5 seconds by default), after the requests in flight have drained; a binding still stopping when it elapses is abandoned with a warning (#676). The shipped grace periods are derived from the drain delay, the drain and this budget, and the health page shows the arithmetic. scripts/checks/versions.shfails when a file the release ships or builds its images from is missing, where it skipped the check, andscripts/checks/kubernetes-example.shandscripts/checks/release-compose.shread every timeout of their grace period check from the example’s configuration, where they assumed the gateway’s default request timeout (#676).- The PIXm resolver reads an ITI-83 answer that repeats one identical
(system, value)targetIdentifierin a member’s domain as that one identifier, so a PIX Manager that returns the same identifier from a master and a local record no longer fails the query with424(PIXm 3.1.0 §2:3.83.4.2.2.1, §5.2). Two different values in one domain stay ambiguous (#679). - The
requestspan recordshttp.request.methodas_OTHERfor a method no RFC the gateway knows defines, and names that spanHTTP {route}, as the OpenTelemetry HTTP conventions ask, so a client can no longer mint a span value with its method token (#692). An ask-all probe still waiting for a slot of a member’s in-flight cap when its deadline passes is reported as capped with nothing sent, never as a member abandoned without an answer, so it no longer counts as a sent time-out in the member metrics (§11.1, §11.5, N38). - The registry page’s reload table lists every section a
SIGHUPreload applies,[pdqm],[nl_gf]and each endpoint’s onward TLS included, and every one that takes a restart,[registry.mcsd]and[pmir]included. The SanteMPI setup on the identity page says why the client-credentials policies are granted by hand (#695). - A federated query still waiting for a slot of a member’s in-flight cap
when the overall budget runs out is reported as capped with nothing sent,
never as a member abandoned without an answer, so it no longer counts as a
sent time-out in the member metrics (§11.1, §11.5, N38). The request log
writes the closed method label in its
methodfield,_OTHERfor a method no RFC the gateway knows defines, so a client’s method token no longer reaches the log (#708). - The two federated-query examples on the book’s container page post the
query from the here-document with
-d @-; they posted a number, which the gateway refused. A guard,scripts/checks/doc-curl.sh, refuses acurlexample that posts a bare number, or opens a here-document and passes-danything but@-or@<file>(#721). config checkwrites nothing to disk (#727). It created the[audit.repository]and[xcpd.audit_repository]spool directories, so it failed on a read-only root filesystem and left a directory behind on a host. It now checks each spool directory where it stands: one that exists must give its owner write access and no one else any, and hold no file the gateway did not write; for one that does not exist, the nearest directory above it must admit a writer. Only modes are read, andservestill creates the directory. A spoolservewould refuse is refused by the key that names it, andservenames that key too. The production guide’s check runs on a read-only root with no durable volume.- The access log no longer names an endpoint as an origin of data it never
sent (#731). An endpoint whose request waited out its deadline for a slot
of
federation.max_in_flight_per_node, or never left the gateway for another reason, is reportedtime-outin the answer and is no origin in the record, and a query that left the gateway for no endpoint writes no record. - The book’s data-protection page lists the professional’s name and identifier, the assurance level of the authentication and whether a person or a client acted among the personal data the access record carries (#773).
- Configuration load refuses a
[fhir]base and a PMIR feed route that overlap, ASCII case ignored, namingfhir.baseandpmir.path, so the FHIR face and the identity feed never share a path (#822). - The receive path admits the patient summary the gateway writes: an entry
of a type no slice names is admitted where the profile slices
Bundle.entryopen, asbundle-eu-epsdoes, and refused only where the slicing is closed, or open at the end and the entry comes before a sliced one; an untyped reference by identifier alone is admitted atProvenance.entity.what, where the summary names each source composition, and refused everywhere else as before. Such an entry stays in the kept original and becomes no openEHR content (#842). - The component maps the patient summary it writes back to openEHR (#862).
The summary names its patient in one form, the relative reference
Patient/{id}, inComposition.subjectand in each mapped resource, where the absolutefullUrlinComposition.subjectread as a second subject. Each mapped resource now claims inmeta.profilethe profile its FHIRconnect context maps to, once, so a receiver selects the mapping by it. - Every query the gateway sends a node now carries an explicit
fetch, so no node’s own default page can cut its share unseen: theLIMITthe query carries, or for a query with noLIMITthe newfederation.max_node_rowsplus one (9999 rows by default, so a node is asked for 10000, the page FerroEHR serves by default; declared inOPTIONS {base}/aspaging.max_node_rows). A node that answers more rows than that isnode-errorand its rows are never merged: the query fails424under all-or-nothing, and apartialanswer is incomplete and names the node (§11.4, §11.5). The ITS-RESToffsetandfetchmembers now select a window over the AQL result (the skip is the AQLOFFSETplusoffset, the count the smaller offetchand the rows the AQLLIMITleaves), paged under the declaredOFFSETstrategy. The codepaging-conflict, a400for a member that differed from its clause, is retired: no request is refused with it any more, and the error page lists it as retired (#868). OPTIONS {base}/now declares that a stored query invoked by name,GETorPOST {base}/v1/query/{name}[/{version}], answers501 Not Implementedon a gateway without the stored-query registry, as N32 requires of an unsupported area (#871). With the registry,its_rest.querysays those paths are served from it. The behaviour is unchanged.- Every stored-query answer that carries
Locationnames the stored query’s own URL,{base}/v1/definition/query/{name}/{version}(ITS-RESTLocation_Query). Withserver.public_urlset it is that absolute URL on every route. Without it, thePUTanswers keep a reference relative to the request URL, and the operator’s redistribution on the admin listener, whose bare version resolved to…/{version}/{version}, carries noLocation, because no reference from the admin listener reaches the client listener (#899). - A
nativemember’s answer on the FHIR face counts as its document only when it is a FHIR documentBundlewhoseidentifierhas a system and a value (FHIR R4bdl-9), which theEehrxfMhdDocumentReferenceprofile needs as the list entry’smasterIdentifier(#901). Any other answer, a document with no identifier included, makes the membernode-error. Under all-or-nothing,Patient/$summaryand the document list fail with424and name the member. Underpartial, the summary names it as a member that did not answer, never as one covered by its own document, and the list leaves its answer out and names it in anoutcomeentry.GET {fhir-base}/Binary/{token}refuses such an answer with424and returns none of its bytes. The access record counts no document the answer did not deliver. - A stored query named
.or..with no namespace is refused with400 query-name-invalid, onPUT,GETand a run by name, spelled plainly or percent-encoded. Such a name is an RFC 3986 dot-segment, so its URL resolved to another resource and the gateway could not name it in aLocationor reach it at a node. A name with a namespace, or of three or more dots, is no dot-segment and is still accepted (#907).
Security
- The admin listener authenticates its callers (#629, #635), where it served
its write actions to every peer that reached it. A write action, such
as the stored-query distribution, needs an access token that client
authentication verifies and that carries its issuer’s
operator_scope, from any peer, loopback included; it answers401 unauthenticatedor403 scope-insufficientotherwise. Onlyprofile = "development"still admits a loopback peer without a credential.GET /metricstakes an optional bearer token,[metrics] scrape_token_file, compared in constant time, and outside the development profile a listener off loopback is refused unless that token or[metrics.tls] client_ca_fileauthenticates the scrape. Each refusal is counted asadmin-write-refusedorscrape-refused. The Kubernetes example sets a scrape token, and the diagnostic report readsGET /metricswith it. The Kubernetes example’s network policy admits the scraper alone to the admin port, as defence in depth. - The access log no longer classifies a query by fewer categories than the
data it delivers (#694). A value a query selects through an archetype
predicate in its path, such as
c/content[openEHR-EHR-OBSERVATION.….v1]/…, is classified by that archetype too, and an archetype id compared asarchetype_details/archetype_id/valuebinds its class asarchetype_node_iddoes. A selected path that names its archetype by a pattern, a parameter or any comparison other than=marks the recordunclassified. An archetype id written in another case, with another version or a namespace is held to the map’s exact keys and markedunclassified, never matched to a near key.crates/openehr-federation0.0.44 carries the reader. - The gateway reads at most
federation.max_node_answer_bytes(16 MiB by default) of one answer from a member or its token endpoint (#710). A longer answer, or one that never ends, is dropped unread, so a faulty or hostile member can no longer make the gateway buffer an unbounded body on every request. The member isnode-errorwith anerrornaming the bound, which fails the query424under all-or-nothing and clearsmeta.federation.complete(§11.1, §11.4); a routed request answers424.config checkrefuses a zero, and a reload that changes the bound logs it as needing a restart. ferrofed admission check --read-onlyno longer prints or keeps theehr_ids of the existing EHRs it reads. Those are real patients’ pseudonymous identifiers at a production node, so the report now names each EHR by its row and keeps only how many it read; a run with writes still names the synthetic EHRs it created.- The dependency report, which names every member endpoint and which are
down, moved to the read-only operator surface,
GET {base}/operator/dependencies, behind the issuer’soperator_scope, so a reader without a token no longer learns the federation’s membership (#751). The operator console andferrofed reportread it there with the operator’s token. It is no probe, so the overload limit now holds it. - The access record names only whose data the access reached (#756). A
federated query’s record names an
ehr_idonly at a member the query was sent to, at the endpoint it was sent through. A routed request’s record reads the pathehr_idand the subject ofGET {base}/v1/ehrwith the parsers the route is served by, so asubject_idholding a+is recorded as the patient the gateway resolved, where it was recorded with a space. A routed answer whoseehr_idor subject the record cannot read is withheld with503 access-unrecorded. rustlsmoves to 0.23.46, which fixes RUSTSEC-2026-0346, RUSTSEC-2026-0347 and RUSTSEC-2026-0348, and the workspace requires that version or later, so no build resolves one open to them;ihe-itimoves to 0.0.33 andehds-loggingto 0.0.15.
0.0.9 - 2026-10-05
Added
-
The operator views of the operator console (#276). After sign-in, which now exchanges the code at the provider’s token endpoint with the PKCE verifier and checks the ID Token’s signature, issuer, single audience, expiry and
noncebefore a session begins (the new[oidc]keystoken_endpointandjwks_uri), the console shows the member endpoints with their membership standing and last observed health (/members), the integrity incidents and thecreating_system_idrouting table (/integrity), the stored queries with their AQL (/stored-queries), and the gateway’s self-description (/federation), each rendered on the server from the gateway’s surface with the operator’s own token. A view without a signed-in session sends the browser to sign-in, and a refusal shows the gateway’s status and stable error code, a401with a link back to sign-in. The routing table and the stored queries are paged, 100 rows a page. Withsecure_cookieon, the console’s cookies carry the__Host-prefix, and a new sign-in ends the session the browser held. -
A read-only operator surface on the gateway:
GET {base}/operator/incidents(each incident kind’s count and the last 25 incidents of each kind),/operator/creating-systems(the routing table) and/operator/stored-queries(every held version, as the ITS-RESTStoredQuery), admitted only for a token carrying theoperator_scopeits[[auth.issuer]]entry names as one whole scope token. The last two answer one page,{"items", "offset", "total"}, under the optionaloffsetandlimitparameters, withlimitat most 100. It answers routing ids, counts and stored definitions only, and an incident names anehr_idonly when it is a bare UUID (§5.4.1, N33). -
ferrofed conformance run --node-profilereports CP-18, CP-19 and CP-27 against every active member (#573; §16.2; N26, N27, N34). The Federation-Node profile checks are the gateway’s own: each member is read through its node client, with its onward credentials and the outbound gate, for the patient’s EHR by itsehr_idalone, an EHR created with no subject, and its error statuses for an unknown EHR and an unparsable query, and the admission check runs as before. The access and consent checks need a refusal arranged with the node’s own policy, so a run records them not observable with that reason. The harness runs the same checks against FerroEHR and EHRbase. The run also scores a new track 3 scenario: theENDPOINTattributes a client selects are each row’s endpoint id andsystem_id(§9.4, N12, CP-37). -
Every release attaches the conformance seed data,
ferrofed-conformance-seed-data.json, with its SHA-256 inferrofed-conformance-seed-data.json.sha256sum(#573). The file holds the vendored template and compositionsconformance runwrites, with their Apache-2.0 licence and notice, and--seed-datatakes it in place of the vendored directory, checking each file it holds by SHA-256. -
A mutual-TLS token endpoint alias on a host you name (#560; RFC 8705 §5). A node’s
[credentials."<id>"]section takesmtls_alias_hosts, each a host name with an optional port, and afapi2grant that uses mutual TLS then takes atoken_endpointofmtls_endpoint_aliaseson one of those hosts, overhttpsalone, as well as on the issuer’s origin. An alias on any other host is still refused before a token request is sent. An entry that is no host, or the key in a section with nofapi2grant over mutual TLS, refuses the configuration, naming the key.oauth-server-metadata0.0.2 addsAliasHostandIssuer::mtls_alias. -
Mutual TLS toward a node and its authorization server, with RFC 8705 client authentication and certificate-bound tokens (#492; §13.1, §13.4, N25, CP-17; FAPI 2.0 Security Profile §5.3.2.1). A node’s
[credentials."<id>"]section takesclient_identity_fileandtrust_roots_file, the keys and the TLS type every identity service takes, and the node and its token endpoint are reached over one transport that presents that certificate,httpsunder every profile; a section may name that material alone. Anoauth2grant, and afapi2grant, may authenticate withclient_auth = "tls_client_auth"or"self_signed_tls_client_auth", sendingclient_idand no assertion (RFC 8705 §2), and withtls_client_certificate_bound_access_tokens = truetake only tokens bound to the certificate (§3): a token whosecnfnames another certificate, in the token response or as a JWT claim, is refused before the node is sent anything (§3.1, §3.2). It composes with token exchange per caller, whose actor token the gateway still signs. Afapi2grant over mutual TLS uses themtls_endpoint_aliasestoken endpoint (§5), needs the metadata to listtls_client_authand statetls_client_certificate_bound_access_tokensas the grant uses them, and needs a client key only for a token exchange. A grant that sets bothdpop_key_fileand certificate binding, either without a client identity, an identity with no certificate, anhttptoken endpoint or node, and TLS material in a service’s owncredentialssection refuse the configuration. RFC 8705 is vendored underdocs/specs/ietf-oauth/. -
The operator console’s skeleton,
app/ferrofed-viewer(#275): a Leptos app rendered on the server and hydrated in the browser, with a backend-for-frontend, as its own binary,ferrofed-viewer, and its own image,ghcr.io/ferrohealth/ferrofed-viewer. It reaches the gateway over HTTP alone, as any client does, and holds no clinical data. Built so far: a landing page,GET /healthand thehealthcheckcommand, operator sign-in at an OpenID Provider with the authorization code grant, anonceand PKCE, itsstate,nonceand verifier held on the server behind an opaqueHttpOnlycookie in a bounded, short-lived pool of pending sign-ins that a flood of login starts cannot grow and that never touches the separate, bounded pool of signed-in sessions with their idle and absolute timeouts, configuration refusals that name a key and a line and never a value, and a typed client of the gateway’sOPTIONS {base}/and ITS-REST surface called with the operator’s own token. Every answer carries a per-response script nonce in its Content-Security-Policy. The console reads a TOML file withFERROFED_VIEWER__overrides and aclient_secret_file. The code exchange, the operator views (#276) and the query console (#277) are planned. The release lane builds, attests and pushes the image with the same SLSA provenance and SBOM attestations as the gateway image, CI lints the browser half forwasm32and builds the site bundle, and the book’s “The operator console (planned screens)” page says what is built and what is planned. -
The NVI localizer authenticates with the Nuts grant (#539; Annex B §B.1, §B.4; the IG’s Localization page, GF-Authentication, GFI-004 and GFI-005).
[nl_gf.nvi.credentials.nuts]takes the table a node’s onward credentials take and runs the same grant: a Verifiable Presentation of the gateway’s credentials for a token bound to itsDPoPkey, cached until 30 seconds before it expires and dropped on the NVI’s401. Every search carries the token with a proof of the key over the search URL without its query, and a nonce the NVI demands is answered once (RFC 9449 §7.1, §9). The token goes to the NVI alone. A refused grant leaves the localization unavailable, so the query fails closed (§14.1). Anoauth2orfapi2table under[nl_gf.nvi.credentials]is refused, naming its key, since the IG defines no such grant for the Localization Service. Thenl-generic-functionsNVI client takes an authorizer that makes each request’s headers and is handed the request URL without the query that holds the pseudonym (0.0.12). -
ferrofed conformance run --config <file>scores a configured deployment against the Connectathon tracks 1 to 7, 9 and 11 over ITS-REST (#546; §16.3, §16.4). It starts the gateway in-process from the configuration, or drives one already serving at--gateway <url>, as a client holding a bearer token from--token-file. It seeds a synthetic patient in theurn:oid:2.999arc at every member the deployment’s own cross-reference names, through each node’s ITS-REST writes: the patient’s EHR, the vendored template and one vendored composition, three EHRs with no subject, and the writes the scenarios make. It reads no EHR it did not create and removes nothing. It refuses to start without--allow-writes, against a profile other thandevelopmentwithout--i-understand-this-writes-synthetic-data-to-the-nodes, for a patient outside the example arc, and for seed files other than the vendored ones, which it checks by SHA-256. The--gatewayURL and every member endpoint are held tohttps, orhttpto a loopback host under the development profile alone, which the report names; the gateway client follows no redirect, and the caller’s token is held as a secret that no report, log line or error carries. It writes the per-track and per-point report the harness run writes, with the same columns and results, and reports every scenario that needs fault injection, node-side capture or a gateway configured for itnot-runwith the reason, neverpass;--node-profileadds the admission check of every member as the node profile findings. The end-to-end tracks call the same scenario checks, and a run against the harness reads what the harness scores and nothing more. -
An end-to-end check that the
AVGthe gateway declares decomposable inOPTIONS {base}/is the mean weighted by each node’s count (§11.6.3, N39, CP-10, CP-32), behindFERROFED_E2E. With two values at one FerroEHR node and one at the other, each node is asked itsSUMandCOUNT, and the answer equals the mean weighted by the counts each node returns for the query it was sent, which differs from a mean of the node means. -
The gateway serves the
did:webDID document of each Nuts grant’s holder (#503; Annex B §B.4, Nuts RFC021 §4.2). It is served at the path the DID resolves to (the did:web Method Specification, Read (Resolve)), such as/.well-known/did.json, under any base URL, asapplication/did+ld+jsonand with no client authentication, like the JWK Set. The document is built from the holder keys thenutssections name and nothing else, oneJsonWebKey2020verification method perkid, so a key change shows up on the next reload with no file to edit. A DID whose document would sit under{base}/v1/, two DIDs that share one document path, or two keys under onekidrefuse the configuration.nl-generic-functions0.0.13 adds thenuts_auth::did_documentmodule,HolderKey::publicandDid::document_path. -
A differential run against the Federation Tier reference implementation, behind
FERROFED_E2E(#94; §16.3 tracks 1 to 7 and 9). The testkit buildssyntaric/openehr-federation-refat its pinned commit from the vendored source and the manifest held to its recorded sha256, on digest-pinned Maven and Java images, and runs it beside FerroFED over the same two FerroEHR nodes, each gateway behind capturing proxies of its own. Some thirty requests go to both, and the run compares the status, the federation headers, the envelope against the vendored schema,meta.federation, the rows and what reached each node, after normalising only what the specification leaves free. Every difference is held in a register with its verdict and where it is recorded: a new or vanished difference fails the run. Thee2e (containers)job uploads the Markdown and TSV report as thedifferential-reportartifact. No difference is a FerroFED defect; three reference divergences and one specification question are new upstream reports (#212 T190 to T193). -
A
fapi2grant’s client key rotates with an overlap (#514): the newprevious_client_key_filenames the previous P-256 client key, which the JWK Set at{base}/.well-known/jwks.jsonpublishes after the current key until it is removed from the configuration. Only the current key signs. An authorization server that fetches the set during the rotation verifies both the new assertions and one the previous key signed before it (FAPI 2.0 Security Profile §5.4.2). A previous key that is no P-256 key, or is the current key, refuses the configuration, naming the key. -
[signing]holds a P-256 key as well as a P-384 key, and reads the algorithm from the key: a P-256 key signs ES256 and a P-384 key ES384 (RFC 7518 §3.4) (#513). The current key signs theopenEHR-federation-clientconveyance and everyoauth2client assertion with its own algorithm, and the JWK Set publishes each key with itsalg, through the same previous-key rotation window, which may cross curves. A federation whose nodes hold to the FAPI 2.0 Security Profile, which admits ES256 and not ES384 (§5.4.1), signs with a P-256 key. A key on any other curve refuses the configuration, naming the key. -
The Federation-Node profile runs against a second CDR product, EHRbase, behind
FERROFED_E2E(#549; §16.2, N26, N27, N34, CP-18, CP-19, CP-27, CP-33a). The testkit pinsehrbase/ehrbase2.36.0 and theehrbase/ehrbase-v2-postgres16.2 database image by digest, each adocs/VERSIONS.mdrow the versions guard compares, and every node profile check runs against it in thee2e (containers)job and writes its findings beside FerroEHR’s. For the access check (CP-18) an EHRbase started with Basic authentication withholds the request path of one EHR from its user role, the narrowest refusal itssecurity.additionalAuthorizationsexpresses; it records no consent decision, so the consent check (CP-19) reports not observable with that reason. The conformance report shows the findings of each product side by side under the node class, then each product’s evidence. EHRbase passes the error pass-through and subjectless EHR checks. It refuses anEHR_STATUSsubject in a namespace such asurn:oid:2.999.1.1, which the BASEOBJECT_REF.namespacepattern admits. The admission check therefore creates no test EHR there, and its integrity conditions fail on that cause. Its EHR read omitsehr_access, which the RMEHRclass makes mandatory. As at FerroEHR, bothehr_id-scoped query forms release the EHR whose read the node refuses. The invocation finding names why an EHR body does not decode. No specification governs which products the harness runs: our own design. -
Each IHE audit record the gateway makes for a client’s request names the verified caller (#500; PIXm §2:3.83.5.2.1, BALP 1.1.4 §3:5.7.5.4, ITI TF-2 §3.55.5.1.1, IUA ITI TF-2 §3.72.5.1). The ITI-83, ITI-78 and ITI-119
AuditEventcarries the BALPagent:userwith the token’sissandsub,requestortrue and its purposes of use, and an Application agent with itsclient_id; the ITI-55 DICOM message carries the Human Requestor with thesubasUserIDandaud<sub@iss>asUserName, as its one requestor. A record the gateway makes on its own behalf (the admission check, the mCSD directory reads, the PMIR subscription and feed) names no caller. The identity reaches the Audit Record Repository alone: the log destinations record only whether a record was made for a caller or by the gateway, and no metric carries it.ihe-iti0.0.23 takes whom each PIXm, PDQm and XCPD exchange is made for (ihe_iti::user::OnBehalfOf), and the resolver, localizer and demographics roles take it too. An ITI-83 answer a localization read is reused only by the resolution of the same caller, so another caller’s access sends, and records, an ITI-83 of its own. The security eventedge-identity-assertednames the caller bysubject_refandclient_ref, keyed HMAC-SHA256 references stable for the process, in place of the subject and the client, and a verified caller’sDebugshows neither them nor the issuer. -
Track 11, the integrity suite, against the two FerroEHR nodes of the harness behind
FERROFED_E2E(#91; §16.3 track 11, §12.5.1, §12.5.2, §12b, N41, N42, N42a, CP-33, CP-33a). The harness seeds oneehr_idat both nodes for two different synthetic patients. A read and a query scoped to it are refused409ehr-id-collisionlisting both claimants, with one integrity incident, and the proxy journals show the ask-all probe as the only request either node received. Once two targeted reads teach theehr_idindex both claimants, which raises the index-insert alarm once, a read and two writes are refused the same way and reach neither node. A versioned write no earlier step of §12.5.1 routes is refused400target-requiredand probes nobody, although itsIf-Matchnames node A’ssystem_id. As the federation operator, the harness then applies the admission check to a FerroEHR candidate deployed with member A’ssystem_id: recorded under thatsystem_id, the registry load refuses it as a duplicate, and recorded under one of its own, the check fails thesystem_idcondition naming member A. The conformance matrix records track 11 as covered. -
The Connectathon tracks 1 to 7 and 9 of §16.3 run as scenarios in the end-to-end harness over two FerroEHR nodes: transparency, resolution through the PIX Manager, the directed query and the endpoint pin, partial results under node faults, de-duplication and cross-node shaping, follow-up routing, onward authentication with consent left to the node, and the REST surface with its self-description. Track 8 stays deferred, as the specification marks it provisional (#92).
-
scripts/conformance/report.shwrites the report of §16.4 from a test run: every track and every conformance point as pass, fail, not run, deferred, not applicable or open, with the issue and reason of each deferral. CI writes it from the offline and the container runs and uploads it as theconformance-reportartifact (#92). -
The Federation-Node profile of §16.2 as operator and harness tooling (#93; N26, N27, N34, CP-18, CP-19, CP-27). The testkit’s
node_profilechecks what a member CDR’s ITS-REST interface shows of each node obligation. They check that an EHR, itsEHR_STATUSand its compositions answer to theehr_idalone, that an EHR created with no subject is read and queried by itsehr_id, and that an unknownehr_idand an unparsable query answer the ITS-REST error status. They also check that an EHR the node’s own policy withholds is refused on the read and on bothehr_id-scoped query forms, and that a consent refusal an operator arranges is held the same way. Each check records pass, fail or not-observable with its evidence. The end-to-end run executes every check against FerroEHR, including a FerroEHR started with Basic authentication and restricted per-EHR access. It also runs the admission check of §12b.2 against a FerroEHR member. The conformance report shows the Node and Operator points as their own class, scored from those findings and never as a gateway pass, and lists every finding per product. -
A PIX Manager can be asked by
POST(#494).[[pixm.manager]]takesmethod = "post"beside"get", the default: the gateway then posts the ITI-83sourceIdentifierandtargetSystemparameters in aParametersbody to[base]/Patient/$ihe-pix(FHIR R4 Operations §3.2.0.1), so the patient identifier is in no request URL a proxy or an access log records.getstays the default because PIXm 3.1.0 §2:3.83.4.1.2 prescribes theGETand requires no Manager to accept aPOST. Any other value refuses the configuration naming the key. The ITI-83 audit record of aPOSTholds its request line, media type and body.ihe-iti0.0.22 addspixm::InvocationandPixmClient::invoked_by, and the harness PIX Manager answers a posted query. -
Each regional binding is one Cargo feature of
ferrofed-server:binding-ihecarries the IHE binding of Annex A ([pixm],[pdqm],[xcpd],[registry.mcsd],[pmir]and[audit]) andbinding-nlthe Dutch Generic Functions of Annex B ([nl_gf]and the Nuts grant of[credentials."<id>".nuts]);[dev]is always built. Both features are on by default, so the released binary and the container image behave as before. A build without one refuses its sections as unknown keys (#489). -
The book has a page on adding a country: a specification crate, a binding module and a feature line (#489).
-
The query console of the operator console (#277), at
/query. An AQL query, or a stored query by name and optional version, runs through the gateway’sPOST {base}/v1/query/aqlor/v1/query/{name}as the signed-in operator, with the targeting, dedup and best-effort headers the gateway’s self-description offers. The answer shows the rows, every endpoint’s status, latency and error frommeta.federation, and says in words whencompleteis false; a504or424all-or-nothing failure shows its diagnostic envelope, and a refusal its status and stable code. A patient is named through a parameter in the request body, and nothing entered in the console reaches a URL, the browser history or a log. A query runs once the page has loaded; its answer is rendered on the server with every value a node sent escaped, and is never cached or compressed. -
Operator sign-out in the operator console (#584). The navigation bar’s “Sign out” posts to
POST /logout, which ends the server-side session, removes the session cookie and, where the new[oidc]keyend_session_endpointnames the provider’s end-session endpoint, sends the browser there with the ID Token hint, the client id and the optionalpost_logout_redirect_uri(OpenID Connect RP-Initiated Logout 1.0). The sign-out and every server function the views and the query console call are taken only from the console’s own pages (Sec-Fetch-Site,OriginorReferer); any other request is a403before the session is read. -
Browser journeys for the operator console (#608): headless Chrome, driven over WebDriver from Rust with
thirtyfour, signs in at a test OpenID Provider, opens each operator view and follows its pagers, runs a query that shows every node’s status and whether the answer is complete, sends a query the gateway refuses, and signs out, against a running gateway over stub nodes and the console serving its release site bundle. Each journey fails on any error the browser logs. They run behindFERROFED_JOURNEYS=1in the newjourneys (browser)CI job, at the Chrome for Testing releasedocs/VERSIONS.mdpins. -
The conformance statement (#95, #89): a book page under Evaluate that claims the Federation-Gateway profile of the Federation Tier with AQL 0.9.0 at commit
7162d0c, with every §17 point and §16.3 track and how each is scored, every deferral with its actor and reason, and the FerroEHR and EHRbase images the tests ran against. Its generated part and the new page of marked tests come fromscripts/conformance/matrix.sh --statement-write, and the docs build and the conformance-matrix guard fail when either disagrees with the matrix. The guard now refuses aplannedpoint or track, so a re-pin scores or defers every point it adds. Six rows of the obligations checklist that still readplannedafter their work shipped now readtestedand name their tests.
Changed
-
The Licensor and copyright holder of FerroFED’s own work is Cadasto B.V., replacing Vernum Projecten B.V. (#602). The licence, its Additional Use Grant and its Change Date do not change. A new guard,
scripts/checks/copyright-holder.sh, fails any first-party header that names another holder. -
The
openehr-*family moves from 0.0.83 to 0.0.84 (FerroEHR #3565, #574). A re-send answering a node’sDPoPnonce challenge that no proof could be made for is the node’snode-error, with the node counted as asked, read from the client’sDpopProof { sent }. The node clients no longer keep a record of each call’s sends.openehr-federation0.0.40 takes the new family. -
The
openehr-*family moves from 0.0.82 to 0.0.83 (FerroEHR #3560, #566). A deadline that passes before the re-send answering a node’sDPoPnonce challenge is the node’stime-out, with the node counted as asked, read from the client’sDeadlineElapsed { sent }in place of the call’s prover record (#470). The record still reads a re-send that noDPoPproof could be made for, which the client reports with no such field.openehr-federation0.0.39 takes the new family. -
The harness and the quickstart run FerroEHR 4.3.3, pinned by digest at its new home under
ghcr.io/ferrohealth, node and database image alike (#566). That release makes AQL honourEHR_ACCESSon every query form (FerroEHR #3562), which the node profile’s “access decided at the node” check (CP-18) reads. -
A consent pre-filter that did not ask its service says so, with a closed reason, in place of answering as one that was asked and denied nothing (#496). Mitz is not asked about a patient named outside the BSN, such as by a pseudonymised BSN (reason
namespace), or for a caller whose token does not state the requester claims (reasoncaller-claims). Such a call is counted inferrofed_consent_prefilter_requests_totalwithoutcome="not-asked"and itsreason, no longer asno-signal, and it leaves the pre-filter’s state onGET /health/dependenciesas it was. The client’s answer is unchanged: every candidate is asked, nothing appears inmeta.federation.consent, and under[federation.consent] disclose = falsethe answer is the one a pre-filter that found nothing gives. -
Two sections that fill a role exactly one may, two resolvers, two consent pre-filters or two localizers of a binding’s own, are refused with one error that names every section, such as
[nl_gf.nvi] and [xcpd] are both localizers; set one, in place of one error per pair. A localizer section undernode_selection = "ask-all"is refused naming its section, as before (#489). -
The
configuration resolvedlog line names the compiled bindings, and each binding logs what it is configured to reach on abinding configuredline of its own; the registry directory, PIX Manager, PMIR and PDQm fields move from the first line to the IHE binding’s (#489). -
A build without a binding feature compiles none of that binding’s crate:
binding-iheandbinding-nlturn on newiheandnlfeatures offerrofed-identityand annlfeature offerrofed-engine, socargo build -p ferrofed-server --no-default-featuresbuilds neitherihe-itinornl-generic-functions. The registry document in FHIR form (registry.format = "fhir"), read with the mCSD reader, is part ofbinding-ihe(#551). -
The authorization server metadata checks of RFC 8414 move out of
nl-generic-functionsinto a crate of their own,oauth-server-metadata0.0.1, which the OAuth 2.0 issuer audience, the FAPI 2.0 grant and the Nuts grant share.nl-generic-functions0.0.11 drops itsoauth-metadatafeature (#551). -
An error that needs a cross-reference resolver names the resolvers the build carries,
[dev] or [pixm]by default and[dev]withoutbinding-ihe(#551). -
[federation.consent] disclose = falsekeeps a consent exclusion out of what a client sees, for Regulation (EU) 2025/327 Art 8, under which the fact that a patient restricted access “shall not be visible to healthcare providers”. A member the consent pre-filter excludes is still never sent a request, and a federated query reports itnot-resolved, with the sameerroras a member the cross-reference does not know the patient at, someta.federation.completestaysfalseand nothing fails. A read of an EHR by subject that only an excluded member could serve answers404 subject-unavailable, the same answer as for a subject no member knows, a new code in the error vocabulary.OPTIONS {base}/declares the choice asfederation.consent.disclose, the pre-filter metrics still count every exclusion for the operator, and the default stays the specification’sconsent-denied(N27a) (#493). The setting covers a node’s own consent refusal too, a403with a code the registry lists inconsent_refusal_codes: a federated query reports the nodenot-resolvedwith nolatency_ms, and a read by subject, a routed request and an ask-all probe answer404 subject-unavailable. On a request under{base}/v1/ehr/, the creation of an EHR and a DEMOGRAPHIC request, a node’s own404gets that same answer in this mode, so it never stands for a refusal alone; everysubject-unavailablecarries one fixed message. The node request metrics now count such a refusal asconsent-deniedon every path, whatever the setting. -
nl-generic-functions0.0.16 addsBSN_SYSTEMSandis_bsn_systemtoidentification, besidePSEUDO_BSN_SYSTEMandURA_SYSTEM: the naming systems of the BSN itself, the IG’s$bsnsystem and its OID as a URN and dotted (#587). -
The changelog is written as fragments (#598). A pull request with a user-visible effect adds one file under
changelog.d/, named<issue>-<kebab-slug>.<section>.mdand holding its entry, instead of editingCHANGELOG.md, so two pull requests no longer conflict on one file. The release cut runsscripts/release/changelog.sh --assemble <version> <date>, which writes the fragments and the entries already under[Unreleased]into the new version’s section and removes the fragments. The newchangelogjob of CI runschangelog.sh --checkover every fragment, and thechangelog-guardjob fails a pull request that adds no fragment and leavesCHANGELOG.mduntouched, unless it carries theno-changeloglabel. -
The operator console serves its site bundle brotli- or gzip-compressed, as the browser’s
Accept-Encodingchooses, withVary: Accept-Encoding, so a browser downloads about 220 KB of WebAssembly where it downloaded 770 KB; pages and server function answers stay uncompressed (#600). The release bundle names no directory of the host that built it: the build remaps the cargo home, the toolchain and the checkout, and CI fails a bundle that names a home, runner, registry or toolchain path. -
ferrofed-identitynames the PMIR subscriber’s errorihe::pmir::PmirConfigError, after its module, where it wasLifecycleConfigError, and the log target both audit log recorders write to isihe::audit::AUDIT_TARGET, where it wasihe::xcpd::AUDIT_TARGET; its value,ferrofed::audit, is unchanged. Innl-generic-functions0.0.18,mitz::BSN_ROOTtakes its value from the BSN OID ofidentification::BSN_SYSTEMS, which is unchanged (#606). -
The operator console refuses to start with
[session] secure_cookie = falseunless its[oidc] redirect_uriis anhttpURL on a loopback host (localhost,127.0.0.0/8or::1), so a deployed console never sends its session cookie over plain HTTP; the refusal namessession.secure_cookie(#615). -
A commercial licence, and any other business or licensing question, goes to Cadasto B.V., the Licensor, at info@cadasto.com or https://www.cadasto.com/contact/ (#616). Every
LICENSEcopy names that contact where it named the maintainer, soopenehr-federation0.0.42,ihe-iti0.0.25,nl-generic-functions0.0.17 andoauth-server-metadata0.0.4 carry it. The maintainer stays the contact for technical questions. The licence terms do not change.
Fixed
- A caller whose token states the requester claims in a form the Mitz
question does not take, or a patient value Mitz does not take as a BSN,
is no longer reported as a Mitz outage, although Mitz was never asked
(#568). The pre-filter answers that it did not ask, with the reason
caller-claims-invalidorpatient-value, so the call is counted asnot-asked,GET /health/dependencieskeeps the pre-filter’s state, and the client’s answer carries nometa.federation.consent.error. No claim value reaches a label, a log line or an error. - A stored-query definition that a PostgreSQL or
redbstore holds is now checked on every read, as the files backend checks each file (#586). A row written past the gateway, by a restore, a manual insert or an older version, that names its patient by a literal or does not admit as a definition is never served or run: the gateway refuses to start over a store holding one, and over a shared PostgreSQL store a read, a listing or an invocation that reaches such a row answers500. The security log records aheld-definition-refusedevent naming the definition’s name and version, never its text. - The operator console’s sign-out reaches the provider’s end-session
endpoint in the browser: the Content-Security-Policy’s
form-actionnow names that endpoint’s origin beside the console, where before the browser blocked the redirect the sign-out form is answered with (#608). The console also serves the brand favicon at/favicon.ico, which every browser asks for on its first page and which answered404before. - Every operator console page the gateway fills is whole in the HTML the server sends, so it reads with no script: the pages render once every answer is in, where before a page could arrive with its loading notice and the content in a template that only a script moved into place (#608). A query or a view the gateway refuses, and a query form the console cannot send, are shown with the gateway’s status and code or the field at fault as before, and the browser no longer logs them as a failed request.
0.0.8 - 2026-10-04
Security
-
The trace export records nothing of a client’s
traceparentortracestate. The request span recorded the client’s trace id and span id as a span link, and a client chooses its trace id freely, so a patient identifier hex-encoded in it reached the operator’s trace collector. The gateway now reads neither header: it starts its own trace for every request, as before, and every trace id and span id an exported span carries is one it generated (#446). -
A node is never sent a request without its configured onward credential, and never the caller’s own
Authorizationheader. -
The
errora client sees for a node no token could be obtained for is a fixed sentence and, when the token endpoint refused with one, its registered RFC 6749 §5.2 code. The token endpoint’s description, its address and the network error go to the log alone, and the assertion and the token go nowhere. -
A token endpoint URL with a user name, a password, a query or a fragment is refused at load, so no secret can ride in it into a log.
-
No request reaches a node, a cross-reference service or a store before its caller is verified. A token signed with
noneor an HMAC, of anothertypthanat+jwt, from an untrusted issuer, for another audience, or outside its validity window is refused, and an issuer that cannot be asked fails closed with503. Apatient/scope admits nothing, because the gateway cannot bind it to the token’s patient context, and asystem/aql-*scope counts only for a listed backend client. The DEMOGRAPHIC API admits only the clients an issuer lists indemographic_clients. The client’s token is never forwarded to a node. -
Outside
profile = "development",serve,config check,admission checkand every reload refuse a credential sent to a URL that is nothttps, naming the key of the URL and of the credential: a registry endpoint with a[credentials]section, anoauth2token endpoint, a PIX Manager with credentials, and ametrics.otlp_endpointcarrying a user name or a password. A reload keeps the profile the process started with. Under the development profile the same configuration starts, and the startup banner, aWARNlog line andconfig checkname each credential that travels unencrypted, by key, an XCPD responding gateway’s XUA assertion included (#402). -
A PIX Manager and an XCPD responding gateway are sent patient identifiers, so outside the development profile each must be
https, with or without a credential configured. Under the development profile they are named in the banner, the log andconfig checklike a cleartext credential (#402). -
A reload whose file changes
profileis refused with classprofile, and the running configuration stays, so nothing the development profile admits can enter a process that started under another profile. A reload under another profile could admit anhttpXCPD gateway before (#402). -
One module holds every transport rule: a URL a credential or a patient identifier is sent to must be
httpsoutside the development profile, and a key set or introspection endpoint the gateway verifies callers against must behttps, orhttpto loopback, under every profile (#402). -
Outside the development profile, the stored-query store’s PostgreSQL connection string must set
sslmode=requirewhen it carries a password to a networked host:disableandprefer, the driver’s default, which falls back to no TLS, are refused namingstored_queries.urlbyconfig check, the start and a reload, even though the store itself changes only on a restart. Under the development profile it starts and is named in the banner, the log andconfig check(#416). -
No request reaches a node without the caller’s identity: one that reaches the dispatcher with no verified caller is answered
500and nothing is sent. The conveyed token never carries a patient identifier (§5.4.1, N33): it holds no IUAperson_id, and the outbound gate reads every caller claim, refusing with nothing sent a request whose claims would carry the identifier its query was resolved on. A client’s ownopenEHR-federation-clientheader never reaches a node.
Added
-
Mutual TLS to the PIX Manager, the PDQm Supplier, the PMIR Patient Identity Registry and the mCSD directory:
[[pixm.manager]],[pdqm],[pmir]and[registry.mcsd]takeclient_identity(orclient_identity_file) andtrust_roots_file, as[xcpd]does, the client identity held as a secret. A file that cannot be read is refused naming its key, and material that is no PEM refuses the federation without quoting it. The testkit gains a mutual-TLS front that puts any harness server behindhttpswith client authentication (#507). -
A FAPI 2.0 grant as an onward credential, for a node whose authorization server follows the FAPI 2.0 Security Profile, as the BgZ/eOverdracht track of the Dutch binding does (Annex B §B.4a): a
[credentials."<id>".fapi2]section names the server’s issuer, and the gateway reads its RFC 8414 metadata once, holds it to the issuer and to everything the grant sends, authenticates with an ES256private_key_jwtassertion whoseaudis the issuer, binds every token with DPoP, and sends the configured RFC 9396authorization_details, taking only a token response that states the details granted. Token exchange per verified caller works as foroauth2. The client key’s public half is published in the gateway’s JWK Set. The authorization code grant, which needs a user agent, refuses the configuration. The testkit has a harness FAPI 2.0 authorization server, and the book has an Onward credentials page (#497, #88). -
assertion_audience = "issuer"in anoauth2section makes every client assertion name the authorization server’s issuer, set inissuer, as itsaudin place of the token endpoint (RFC 7523 §3; FAPI 2.0 §5.3.2.1). The token endpoint stays the default (#497). -
nl-generic-functions0.0.10 adds theoauth-metadatafeature: the RFC 8414 issuer identifier and metadata checks the Nuts grant built, now shared by both Annex B authentication tracks (#497). -
The PDQm demographics step of Annex A §A.2, ahead of localization and resolution. With
[pdqm], a patient named in a namespace the table lists is taken to a PDQm Patient Demographics Supplier first, by ITI-78 (anidentifiersearch that asks for the master domain only) or, where the deployment declarestransaction = "iti-119", by the ITI-119$matchwithonlyCertainMatches. The identifier the one matched Patient carries in the master domain is localized and resolved in place of the client’s, and the outbound gate withholds both from every node request. No match leaves every membernot-resolvedand fails nothing (N6); several matches, two master identifiers or an uncertain match refuse the resolution and fail the query424, and the gateway never picks one. A Supplier outage follows the localization failure policy (§14.1). The Supplier is reached overhttpsoutside development, with no redirect, a bounded answer and its own timeout, whichOPTIONS {base}/declares astimeout.demographics_ms; each exchange is audited through[audit]as the PDQm Query or Match Consumer record, required outside development; the step shows asdemographicsonGET /health/dependenciesand is counted inferrofed_demographics_requests_total. The read of an EHR by subject takes the same step, and a reload applies a change to[pdqm]as it does to[pixm](#487). -
The ITI-119 Patient Demographics Match in
ihe-iti0.0.19:PdqmClient::match_patientposts aMatchInputto[base]/Patient/$matchand reads the Match Output Bundle, every matched Patient with its score andmatch-grade, and its PDQm Match Consumer audit record (#487). The testkit gains a harness PDQm Supplier that answers ITI-78 and ITI-119 from synthetic Patients in theurn:oid:2.999example arc. -
The Nuts grant of the Dutch Generic Functions (Annex B §B.4) as an onward credential: a
[credentials."<id>".nuts]section makes the gateway obtain that node’s token from its authorization server with a Verifiable Presentation of the gateway’s own credentials, signed with itsdid:webkey, by the VP Token Grant Type of Nuts RFC021. The token is always bound to the section’sdpop_key_filewith DPoP, and every request to the node carries a proof of that key. The gateway checks the authorization server’s metadata and Presentation Definition before any credential leaves, sends no expired credential, answers a demanded DPoP nonce once with a new presentation, caches the token until 30 seconds before it expires, and never logs or reports a credential, the presentation or the token. The client isnl-generic-functionsfeaturenuts-auth, and the testkit has a harness Nuts node (#88). -
The specifications the Annex B authentication tracks cite are vendored: the GF-Authentication pages of the NL-GF IG, Nuts RFC003, RFC021 and RFC022, nine OAuth RFCs, VC Data Model 1.1, DID 1.0, DID Resolution, Bitstring Status List and the did:web method, and DIF Presentation Exchange 2.0.0. The OpenID FAPI 2.0, OpenID4VCI and OpenID4VP documents are pinned and fetched, never committed (#88).
-
Mitz as the consent pre-filter of the Dutch binding (Annex B §B.6, N27a), configured under
[nl_gf.mitz]. Before resolution the gateway asks Mitz’s closed authorization question once for each candidate’s care provider: may this data holder make the patient’s data of the configured categories available to the verified caller, forTREATorCOC? The caller’s professional (UZI number and role) and organisation (URA and type) come from the token claims each trusted issuer maps under[auth.issuer.requester]; a caller whose token lacks them is not asked about, and nothing is filtered for it. A member whose holder Mitz denies for every category isconsent-denied, never asked, and clearscompletewhile the query succeeds; every other member is asked and its node still checks consent (N27, §14.3).Indeterminate, a fault, a timeout or a malformed answer is no decision: those members are asked and the failure is carried inmeta.federation.consent.error, and a pre-filter that denied some members and failed for others is counted aspartialinferrofed_consent_prefilter_requests_total. Mitz is asked by BSN, so a patient named by the pseudonymised BSN gets no signal.OPTIONS {base}/declares the pre-filter asnl-gf-mitz. The client is the newmitzfeature ofnl-generic-functions0.0.8, which follows the VZVZ Implementatiehandleiding Open en gesloten autorisatievraag 3.8.2: SOAP 1.2 with an XACML 3.0 decision query over mutual TLS. VZVZ states no licence for the document, soscripts/vendor/mitz.shpins it by sha256 and fetches it into an ignored directory (#475). -
Every IHE transaction the gateway makes or receives is audited as its profile requires. The PIXm ITI-83 query, the mCSD ITI-90 search and ITI-91 history, the PMIR ITI-93 message received and each ITI-94 subscription create, read and delete are recorded as their profile’s BALP
AuditEventand, under the new[audit]table, posted to an ATNA Audit Record Repository over the FHIR Feed of ITI-20 (destination = "repository") or written to theferrofed::auditlog target without a patient identifier ("log"). Each record is stored in a spool before it is delivered: a repository that is down delays the records, one that refuses a record has it quarantined, and a record the spool cannot store fails its transaction closed, as the ITI-55 audit does: an ITI-83 query fails424, a directory read is refused, an ITI-93 message is answered503unapplied. The repository shows onGET /health/dependenciesasaudit_feed, and the identity Registry’s fault can beaudit-failed.ihe-iti0.0.18 adds thebalpfeature, with an audited mode for its PIXm, PDQm, mCSD and PMIR clients. BALP 1.1.4, the RESTful ATNA supplement, the ITI-20 page and each profile’s audit profiles are vendored (#486, #469). -
The registry keeps the identifiers a care services directory publishes for each organisation (mCSD
Organization.identifier), and the NVI localizer reads its custodian map from them: each member organisation’s URA, by the LRZa rules of Annex B §B.2, maps to the members it operates, so a Dutch directory needs no[nl_gf.nvi.custodians]table. A table given beside it must agree, a member no URA maps to is refused, and a reload or directory refresh rebuilds the map.nl-generic-functionsaddslrza::ura_in, andihe-itireads every identifier of a directoryOrganization(#87). -
[nl_gf.nvi]localizes undirected patient queries through the NVI, the national localization index of the Dutch Generic Functions (Annex B §B.1). The client names the patient by a pseudonymised BSN; the NVI’s care providers, by URA, map to the members that hold their data, and every other member isnot-localized. A patient named in any other namespace, an NVI that refuses, answers outside the IG or stays silent fails the query closed. The URL ishttpsoutside development, the section is refused beside[xcpd]and underask-all, andOPTIONS {base}/declareslocalization.mode = "nl-gf-nvi". The pseudonymised walkthrough of Annex B §B.7 runs end to end in the test harness, and no node receives the pseudonym (#87). -
The Dutch Generic Functions binding of Annex B starts in
nl-generic-functions: featurenviasks the national localization index which care providers, by URA, hold data for a patient named by a pseudonymised BSN, and refuses every answer that is not asearchsetof localization records about that patient; featurelrzareads the URA of an NL-GFOrganization. The source of the IG (fhir.nl.gf0.3.0, EUPL-1.2) is vendored underdocs/specs/nl-gf/by a new fetch script, pinned by tag and commit because the IG has no registry package (#87). -
The PMIR identity feed,
[pmir]: the gateway subscribes at an IHE PMIR Patient Identity Registry with ITI-94 and servesPOST {base}{path}for the ITI-93 messages it sends. A message is applied only with the bearer token agreed with the Registry out of band, read fromfeed_token_file, and only when it holds to the PMIR profiles; otherwise it is answered401,400or415and changes nothing. An applied merge or delete drops the resolution bindings of theehr_ids it carries in a member’sehr_iddomain, and an update, or a change that names no memberehr_id, drops every binding. The log and the newferrofed_identity_feed_messages_totalcounter carry the change kinds, counts and results, never an identifier.GET /health/dependenciesreports the Registry asidentity_registry, and the subscription is found by search before it is created, so a create answered late is adopted and never made twice; one inerrororoff, or one the Registry lost, is replaced; failed checks back off up to 32 timescheck_interval_s; a create answered with no usableLocationstops further creates and shows asidentity_registry_fault = "unmanageable"; and the drain stops the loop before it deletes the subscription. The Registry URL and the callback URL arehttpsoutside the development profile. Theihe-iticrate gains thepmirfeature: the ITI-94 subscriber and the ITI-93 feed reader and response, held to the PMIR 1.6.0 package, now vendored underdocs/specs/ihe-pmir/; the testkit gains a harness Patient Identity Registry. Track 8 stays deferred (#147). -
[auth.issuer.patient]honours one issuer’spatient/grants, off by default. It binds the issuer to the one member whose platform issues its patient tokens and names the identifier system under which the cross-reference knows that member’sehr_ids;config checkrefuses a binding whose endpoint the registry lacks, or that has no registry or no cross-reference. A bound issuer’spatient/scope counts on an EHR’s data alone. The gateway reads the token’sehrIdclaim (SMART on openEHR), resolves it at every member (§5.2), and sends a request only to the patient’s own{node, ehr_id}pairs. Another patient, a query that names no patient, anehr_idthe pairs do not place, EHR creation and definitions are403(patient-confinement) with nothing sent, nothing probed and, for another patient’s read by subject, no resolution binding or index entry recorded. A query whoseFROMreads a class beside its one scopedEHR(underANDorOR, a secondEHR, anEHRunderNOT CONTAINS) is refused the same way, and a named patient is checked at the bound member alone before any localizer, consent pre-filter or other member hears of it.openehr-federation0.0.38 addsAnalysis::within_one_ehr, which says whether every class a query reads is contained under that oneEHR. A token whose resource scopes are allpatient/is refused the DEMOGRAPHIC API the same way, bound or not and even for a listed demographic client. A token without the claim is403(patient-context-missing), and a cross-reference that cannot resolve the patient is424(patient-context-unavailable). The bareehrIdis never compared across members (§12.5). Each node’sopenEHR-federation-clienttoken carries the patient’sehr_idat that node asehrId, and only the coveringpatient/scopes inscope(N26); the outbound gate refuses, with nothing sent, a request whose token would tell its node anotherehr_idthan the one the request is composed for. Without the section apatient/grant still admits nothing (#443). -
The texts client authentication is held to are vendored under
docs/specs/. The ITS-REST Release-1.1.0 fetch now takes the SMART on openEHR source (docs/smart_app_launch/), recording that the release declares it DEVELOPMENT status. A new fetch script takes the IHE IUA supplement, Revision 2.5 Trial Implementation (ITI-71, ITI-72), which IHE publishes under CC-BY-4.0. The citations in the authentication code and the security handoff of the architecture point at the vendored text and name the DEVELOPMENT status, and a test holds every cited section, quotation and claim name to the pinned text (#414). -
[telemetry] trace_sample_ratiosets the share of client requests whose spans the trace export sends, from0.0to1.0,1.0unless set, and refused at load outside that range. The decision is made once at the request span, from the gateway’s own trace id, and the whole span tree follows it; a node request of a trace not kept still carries atraceparent, with its sampled flag off. The export over the OTLP wire is tested end to end against an in-process OTLP collector in the testkit: aserveprocess’s spans arrive under the gateway’s resource, are flushed on the drain, and carry no synthetic patient identifier (#437). -
Onward token exchange per verified caller (RFC 8693, #439): an
oauth2section withgrant = "token_exchange"and a requiredresource(RFC 8707) exchanges the caller’s verified access token at the node’s token endpoint, with an assertion of the gateway as the actor and only the caller’s granted scopes that cover the operation (N26). Every exchange carries its scope and resource; an operation no scope covers, or a caller the edge mode asserted, fails that nodenode-errorwith nothing sent. Exchanged tokens are cached per endpoint, caller token and scope (the caller token hashed), at most 1024, and dropped on a401. The gateway’s own requests use the client-credentials grant at the same token endpoint. The gate keeps the caller’s token only while some node exchanges it, and no log, span, metric oropenEHR-federation-clientclaim carries it. -
DPoP-bound onward tokens per endpoint (RFC 9449, #439): anoauth2section’sdpop_key_file, a P-256 or P-384 key, binds that node’s tokens to the key. Every request to the node and its token endpoint carries a proof binding its method and URL, and the token’s hash on the node request; the token is sent under theDPoPscheme, a bearer token answered to such a grant fails the node, and a nonce either server demands is answered once. -
The gateway exports its own spans as OpenTelemetry traces over OTLP when
[telemetry] otlp_endpointnames anhttp://collector; off by default. A federated query is one trace: the request, the resolution, the fan-out, the merge, and onenode_requestspan per member asked, with the probe, the template, stored-query and drift fan-outs, the localizer and the consent pre-filter as spans of their own. Every span attribute is a route template, a registry id, an ITS-RESToperationId, a status or a count, never a patient identifier, query text, header value or body, and no log event is exported. Every client request starts a trace of the gateway’s own with a random trace id, and each node request carries a W3Ctraceparentfrom its own span in that trace. A client’straceparentandtracestateare never read, so no trace id a client chose reaches a node or an exported span (#353, #446). -
The resolution bindings of §12.5.1 step 2 are kept per verified caller, the token’s issuer, subject and client together. A federated query and a read of an EHR by subject record where each
ehr_idresolved, and a routed request by the same caller is routed by them before theehr_idindex; another caller never sees them. A consent denial drops the caller’s bindings naming the denied member (N27a). They live in memory forfederation.binding_ttl_msand number at mostfederation.binding_capacity(100000 by default), andOPTIONS {base}/names the step inits_rest.ehr(#412). -
The registry can be read from an IHE mCSD 4.0.0 care services directory and kept in step with it (#86; §15.1, N21, Annex A.5).
[registry.mcsd]names the directory’s FHIR base, optional bearer or basic credentials, a refresh interval, a deadline for one whole read or refresh, and caps on its pages, bytes and entries (by default 30 seconds, 200 pages, 64 MiB and 50,000 entries), in place ofregistry.document. At start the gateway reads theOrganizations andEndpoints that carry the registry’s FHIR-form identifiers with ITI-90, and every interval it asks for the changes since the last read with ITI-91_history?_since=, from the directory’s own clock. The content passes the checks of the FHIR form and of a reload: an endpoint relying onhl7-fhir-rest(§15.2, N19), a missing managing organisation (N20) or a duplicate id orsystem_idstops the start, and a refresh that breaks one is refused with the running registry kept, logged and counted as a refused reload. A refresh that runs past its deadline or a cap is refused and counted the same way (registry-budget), so a faulty or hostile directory can neither hold the gateway nor fill its memory, and a partial answer never becomes the registry. A directory that cannot be reached keeps the running registry, and every outcome shows onGET /health/dependenciesasdirectory.config checkreads the directory too. Credentials for the directory travel overhttpsonly, outside the development profile (#402). A member organisation of a shared directory may list endpoints that are no member: another service’s, one that lost the federation’s identifier, or one the directory deleted. Such a listing is ignored and logged by its reference, once per read of the content, at a start and on a refresh alike, where the document refuses it, and a deleted endpoint leaves the registry (#423, #433). -
ihe-iti0.0.13: themcsdfeature carries the ITI-90 Query Client and the ITI-91 Update Client (McsdClient), every walk bounded by oneBudget(a deadline and caps on pages, bytes and entries), and a replica of a directory’sOrganizationandEndpointresources kept in step with ITI-91 (Replica). IHE mCSD 4.0.0 is vendored underdocs/specs/ihe-mcsd/byscripts/vendor/ihe-mcsd.sh, and the testkit carries a harness directory built from the IG’s examples. -
The ITI-55 audit messages sent to an ATNA Audit Record Repository (#418; ITI TF-2 §3.20, §3.55.5.1.1):
[xcpd] audit = "repository"with[xcpd.audit_repository]sends each exchange’s DICOM PS3.15 audit message as ITI-20 Record Audit Event, RFC 5424 syslog with the PRI<85>and the MSGIDIHE+RFC-3881over TLS (RFC 5425,tls://;tcp://underprofile = "development"only). Every message is fsynced to a bounded spool before it counts as recorded and delivered from there in order, so a repository outage delays delivery and fails no discovery (§3.20.4.1.1), and a restart keeps the spool. A full or unwritable spool fails the discovery closed. The spool directory is created0700with0600files, and a directory open to other users refuses to start;spool_diris required outside development, and[xcpd] home_communityis required withaudit = "repository", since the audit message names the gateway’shomeCommunityID(§3.55.5.1.1). Recording only writes to the spool. Delivery is bounded at every step (connect_timeout_msfor the connection,send_timeout_msfor the TLS handshake and each write and flush), and a failure drops the connection and retries with a jittered exponential backoff up toretry_max_ms. A spooled message that cannot be read or is no whole frame is moved to the spool’squarantine/, logged by its sequence number, and the drain goes on; a file the gateway did not write refuses the start, naming the file to move.GET /health/dependenciesreportsaudit_repository(degradedwhile retrying, while messages wait and while any sits in quarantine), and the metricsferrofed_audit_spool_events,ferrofed_audit_spool_bytes,ferrofed_audit_quarantined,ferrofed_audit_delivered_totalandferrofed_audit_retries_totalcarry no label. Where the audit messages go takes a restart. -
ihe-iti0.0.14: theatnafeature, the ITI-20 sender (the DICOM audit message, the RFC 5424 syslog frame, the RFC 5425 TLS connection, the spool and the forwarder), andAuditEvent::messagefor the ITI-55 Initiating Gateway table.AuditRecorder::recordis async, awaited before the exchange’s answer is returned. -
The consent pre-filter applies to the read of an EHR by subject as it does to a federated query (#399; N27a, §13.2.1). A member it denies is never resolved and never sent a request. When it denies every member that might hold the subject and no other member holds one, the answer is
403 consent-deniednaming the denied endpoints, never the404of a subject with no EHR. A pre-filter that cannot answer leaves every member to its own consent check, as on a query. -
A consent pre-filter outage is visible (#400). The answer to a query asked while the pre-filter could not answer carries
meta.federation.consent.error, beside thelocalization.errorof §14.1, withcompleteand the status unchanged.GET /health/dependenciesreports the pre-filter asconsentunder the members’ rule (a decision or an answer below500isup, a5xxisfailing, no answer isdown), absent when none is configured. Each call is counted inferrofed_consent_prefilter_requests_totalbyoutcome(denied,no-signal,unavailable). The carriers are FerroFED’s own design; no specification names one. -
federation.node_selection = "localized": an undirected patient query gets its node set from a localizer (N4, N10, §14.1). A member the localizer does not name is reportednot-localizedand never asked. A localizer that fails or stays silent pastfederation.localization.timeout_msfails closed: no member is asked, every member isnot-localizedwith the localizer’s error, andmeta.federation.localization.errorcarries it too.federation.localization.on_failure = "ask-all"widens to every member instead, andOPTIONS {base}/declares the policy, the binding aslocalization.modeand the budget astimeout.localization_ms. A directed query is never localized (§8). The development cross-reference serves as the localizer underprofile = "development"; the IHE XCPD binding follows (#85). -
Consent stays with the node, and a node’s consent refusal can be reported as one (#83; §13.2, §13.2.1, §11.1, §11.3, N26, N27, N27a, N40, CP-36). ITS-REST defines no consent signal, so a node’s
403isconsent-deniedonly when its ITS-RESTErrorcarries acodethe registry lists for that endpoint in the newconsent_refusal_codeskey (in the FHIR form, onehttps://ferrofed.eu/fhir/StructureDefinition/consent-refusal-codeextension per code). The list is empty by default, so every refusal staysnode-erroruntil you name your nodes’ codes; the key is FerroFED’s own design. A consent-denied node contributes no rows, carries the latency of the request it refused, clearsmeta.federation.complete, and fails the query in neither completeness mode. The optional Step-1 consent pre-filter runs after localization and before resolution: each member it denies is reportedconsent-deniedwith nolatency_ms, is never resolved or contacted, and loses anyehr_idthe session cached for it. A member it does not deny is asked, and its node decides. When the pre-filter cannot answer, every candidate is asked (pass-to-node), because the node checks consent in any case.OPTIONS {base}/declares a configured pre-filter underfederation.consent. For development,[[dev.consent_denied]]rows beside the cross-reference are a static pre-filter, accepted only underprofile = "development"and declared asdevelopment-static. -
A “How it works” part of the book, right after the introduction, explains the gateway in Mermaid diagrams (#395; no specification governs the documentation: our own design). Its pages cover a federated query end to end with each member’s status and the answer’s status (§4, §11, N37), where the patient identifier stops (§5.4, N33), follow-ups and writes (§12, §12a, N41, N42), definitions and stored queries (§12.6, §12.7, N43, N44), trust and keys (§13.1, N25), and the quickstart and production layouts. Each diagram cites the sections it shows, and a planned part is drawn dashed with its issue and milestone. The pages of the other parts link to the diagram that belongs to them. The diagrams follow the book’s theme when you choose “Auto” too:
website/book/js/mermaid-theme-sync.jsredraws them when the theme crosses between light and dark, which the vendored mdbook-mermaid script does only on a click of a named theme. -
The gateway authenticates to a node as itself with OAuth 2.0 client credentials and a signed JWT client assertion, the default onward mechanism of §13.1 (N25, CP-17 onward half, #81). A
[credentials."<endpoint id>".oauth2]section names the node’s token endpoint, theclient_idand the scope, written in the SMART on openEHR grammar and held to thesystemcompartment, with optionalresourceandaudience. Each assertion is ES384, names the client asissandsuband the token endpoint asaud, lives at most 300 seconds and carries a freshjti(RFC 7523 §3). A token is cached until 30 seconds before it expires, with one token request per endpoint at a time, and a node’s401drops it. The admission check authenticates the same way. -
[signing]holds the gateway’s ES384 signing key, read from a file, and during a rotation the previous key, published beside it for a configurable overlap window of at least the assertion lifetime plus the nodes’ JWK Set cache time. The gateway serves its public keys as a JWK Set (RFC 7517) atGET {base}/.well-known/jwks.json, each with its RFC 7638 thumbprint askid, and declares the configured location asfederation.auth.jwks_uriinOPTIONS {base}/(§13.1, N30). -
XCPD localization (#85, Annex A.3):
[xcpd]makes the gateway an IHE XCPD Initiating Gateway (ITI-55, ITI TF-2 Revision 20.1). An undirected patient query asks every configured Responding Gateway by the patient identifier alone, and only the members whose communities a gateway names are asked. One gateway that faults, answers an error, asks for demographics or stays silent fails the discovery closed. Gateway URLs arehttpsoutsideprofile = "development"; a client certificate, extra trust roots and a SAML XUA assertion your identity provider signed are configured with_filekeys, and the assertion is sent unchanged.OPTIONS {base}/declareslocalization.mode = "xcpd", and[xcpd]takes effect on a reload. -
ihe-iti0.0.11: thexcpdfeature, the ITI-55 Initiating Gateway client. It is the only feature that compilesuuidandjiff, and it adds no crate to the graph beyond them. -
The read of an EHR by subject is localized (#409; N4, §5.2, §14.1): under
node_selection = "localized"the subject is resolved only at the members the localizer names, and no other member learns of the request. A localizer that fails closed answers424 localization-unavailablewith no member asked; a targeted read is never localized (§8). -
The localizer on
GET /health/dependenciesaslocalizer, under the members’ rule, and its calls inferrofed_localizer_requests_totalbyoutcome(#410). -
The ITI-55 Initiating Gateway audit message (#410, ITI TF-2 §3.55.5.1.1):
ihe-iti0.0.12 hands every exchange’s message to anAuditRecorder, and[xcpd] audit = "log"writes it at theferrofed::auditlog target without the query parameters, which name the patient. A message the recorder cannot accept fails the discovery closed under everyon_failurepolicy,ask-allincluded, and shows the localizerfailing. -
The PIXm resolver as the localizer (#408; §14.2, N4, §14.1): under
node_selection = "localized"with[pixm]and no[xcpd], the members whose domain holds an identifier for the patient at the PIX Manager are the candidates, and the resolution reuses the same ITI-83 answer, so each query asks each Manager once. A Manager that fails or stays silent fails the localization closed.OPTIONS {base}/declareslocalization.mode = "pixm". -
Client authentication at the gateway (§13.1, N25, CP-17 inbound half, #80). A request to the ITS-REST surface and
OPTIONS {base}/carries an RFC 9068 access token from an issuer on the[[auth.issuer]]trust list, verified against the issuer’s JWK Set (fetched fromjwks_uri, read fromjwks_file, or given inline asjwks) or by RFC 7662 introspection. The key set is cached, refetched once for a key it does not hold and at most once perauth.key_set_refetch_s. Scopes are read in the SMART on openEHR grammar withopenehr-sdt, and one table maps every ITS-REST operation to what it requires. A purpose of use, in the IHE IUA extension or RFC 9396authorization_details, is required unlessauth.purpose_of_use.required = false(§13.4). The verified caller rides in the request’s extensions. An explicit edge mode verifies a proxy’s signed assertion in a configured header and logs the identity it asserted. New codes:unauthenticated(401),scope-insufficient,purpose-of-use-requiredandoperation-refused(403), andauthentication-unavailable(503). The book has a new page, Client authentication. -
scripts/quickstart/token.shmints the token the quickstart gateway accepts, from a development issuer whose key pair it generates withopensslon its first run; the quickstart and the release example configuration carry an[auth]section. -
The testkit’s test issuer: key pairs generated per run, its JWK Set served from a mock server, and tokens minted with chosen claims.
-
The caller’s identity on every request to a node (#82; §13.1, N24, N25, §12.4, CP-16). The federated query, every routed read and write, a definition request, the template and stored-query fan-outs, the ask-all probe and the read of an EHR by subject each carry
openEHR-federation-client: an ES384 JWS, typedopenehr-federation-client+jwt, signed for that one node with the gateway’s[signing]key, the key its JWK Set publishes. Itsaudis the node’s endpoint id,expis 60 seconds afteriat,jtiis fresh, andissis theclient_idof the endpoint’s OAuth 2.0 grant or else the federation id. It names the verified caller (sub,iss_upstream), how the gateway verified it (verified_by:signature,introspectionoredge), the caller’s organisation (subject_organization_id), each purpose of use and the scopes as granted, so a node can apply them (N26). The admission check and the admin listener’s redistribution name the gateway itself. The book’s Client authentication page gives the claims and the steps a node verifies the token with. The header and its claims are FerroFED’s own; §13.1 leaves end-user conveyance open. -
scripts/quickstart/signing-key.shwrites the compose quickstart’s development signing key before the firstdocker compose up, and the quickstart and the release example configuration carry[signing]. -
The book’s §13.4 deployment decisions page (#84; §13.4, N25, CP-39): FerroFED’s own answer to each of the five obligations §13.4 puts on a deployment (the identity verified at each boundary, who authenticates the end user, how purpose of use travels, what the tokens are bound to, and which risks are carried technically and which by agreement), the configuration that changes each answer, and a template an operator fills in for what only a deployment can answer. A test holds the page to one section per obligation.
Changed
- Every outbound client of
ferrofed-identity(PIXm, PDQm, PMIR, mCSD, XCPD, NVI, Mitz and the FHIR Feed audit repository) is built through one client build and one TLS type, and the server maps a credentials section to a credential once, refusing an OAuth 2.0, Nuts or FAPI 2.0 grant wherever one is not allowed; the PMIR mapping no longer reads such a grant as no credential (#507). - The container image names FerroHEALTH, the organisation that distributes
it, as its OCI
vendor, and FerroHEALTH as itsauthors, in the image labels and in the release index annotations (#490). - A production gateway with
[pixm],[registry.mcsd]or[pmir]set needs[audit] destination:config check,serveand a reload refuse the configuration without it, namingaudit.destination, and refuse"off"outside development. Setdestination = "log"to keep a deployment’s behaviour while it adds an Audit Record Repository (#486). - The quickstart’s node database roles have new development passwords (#452):
each role’s password is its name followed by
_example(ferroehr_a_example), set bydocker/postgres/20-ferrofed-node-databases.shandcompose.yamltogether. A quickstart volume created before this change keeps the old passwords, so the nodes are refused by their databases: rundocker compose down -vonce before the nextdocker compose up --wait, which removes the quickstart’s data and its synthetic patients, then runscripts/quickstart/seed.shagain. - The
openehr-*family moves from 0.0.81 to 0.0.82 (FerroEHR #3557, #3558). An onward OAuth 2.0 grant now requests its configuredscopein the canonical form of the SMART on openEHR grammar, asopenehr-sdtprints it (#456): the permissions inc,r,u,d,sorder and one space between scopes, sosystem/aql-*.sris requested assystem/aql-*.rs. A token exchange asks for the caller’s covering scopes in the same form. An authorization server that compares scope strings sees the canonical form, not the text in the configuration or the caller’s token. - A
DPoP-bound onward token is sent by theopenehr-itsclient itself (#448): the client writes theDPoPscheme and asks the endpoint’s key for each request’s proof, and answers a node’suse_dpop_noncechallenge with one re-send. The token request proves itself and answers a token endpoint’suse_dpop_noncethe same way, with a newly signed client assertion, and under token exchange a new actor token, so nojtirepeats (RFC 7523 §3). The token endpoint’s nonce and the node’s are kept apart even on one origin (RFC 9449 §9). - A node that demanded a
DPoPnonce and was not sent the request again is counted as asked (#470). A deadline that passes before the re-send is the node’stime-out, “no answer before the deadline, which passed before the request could be sent again with the DPoP nonce the node demanded”, and a re-send no proof could be made for isnode-error, “the node demanded a DPoP nonce, and no proof could be made to send the request again”. The health and metrics surfaces read both as a node that gave no answer, never as a request never sent. A request no proof could be made for at all isnode-error, “no DPoP proof could be made for the request, so nothing was sent”. - The Kubernetes example bounds the gateway’s ephemeral storage (#452): the
container requests
128Miand is limited to1Gi, and the audit-spoolemptyDirhas asizeLimitof512Mi, which holds the default spool bounds (spool_max_bytes64 MiB,spool_max_events100000). The kubelet evicts a pod past either limit, so raise both with the spool bounds. - The image ships
/var/lib/ferrofed/audit-spool, owned by the gateway user65532:65532with mode0700(#442), so the ITI-20 audit spool needs no step on the host. The releasecompose.yamlmounts the named volumeaudit-spoolat/var/lib/ferrofed, and the Kubernetes example anemptyDirthere, writable throughfsGroup65532. Setxcpd.audit_repository.spool_dir = "/var/lib/ferrofed/audit-spool"; the spool holds audit records that name patients, so keep the volume on an encrypted disk. - A
patient/scope grant admits nothing at the gateway, now as a decision (#413): anehrIdnames no namespace and anehr_idmeans nothing outside its CDR (§12.5), so a bare match could admit another patient’s EHR at another node. The authentication, How it works and deployment-decision pages say so, and an issuer-bound opt-in is planned (#443). [signing]is required whenever a registry is configured, byregistry.documentor by[registry.mcsd]: a federating gateway without a signing key refuses to start, andconfig checkrefuses its configuration with exit code 78, naming the key.- The ADMIN API under
{base}/v1/admin/answers403(operation-refused) to every caller, where it answered501. - A gateway that federates and trusts no issuer refuses to start, and
config checkrefuses its configuration, with exit code 78. [xcpd]requiresaudit(#410):"log", or"off", which onlyprofile = "development"accepts andOPTIONS {base}/declares aslocalization.audit. A configuration without it refuses to boot.ihe-iti0.0.12:XcpdError::Faultcarries the HTTP status it came with,XcpdError::status()names the status the responding gateway answered with, an unreadable answer with a status other than200isRejected, andXcpdError::Auditreports an audit message the recorder refused.- An onward credential that cannot be obtained fails that node as
node-errorwith nothing sent to it, on the federated query, the definition fan-out and a routed request (424 node-error), where it was a gateway error before (§13.1, §11.1). - The localizer error a member and
meta.federation.localization.errorcarry names the status the localization service answered once, with the binding’s own reason, for PIXm and XCPD alike (#420). The log keeps the whole cause chain. - The boot refusals that ask for a registry (
[dev],[pixm]andfederation.demographic_endpointwithout one,federation.node_selectionandfederation.idmissing beside one) name both of its sources,registry.documentand[registry.mcsd](#425). So does the refusal of the admission check without a registry, and a registry in FHIR form that lists an endpoint it does not hold says the reference names no resource, where it said it named noOrganization(#434). - The book draws the v0.0.8 work on
mainas built (#406). The How it works pages show the gate, the onward OAuth 2.0 token with its ES384 assertion and cache, the signed caller on every node request, the XCPD and PIXm localizers, the mCSD directory and the resolution bindings per verified caller; only the ATNA audit repository (#418), PMIR (#147) and the Dutch Generic Functions (#87, #88) stay dashed as planned. The claims page lists the merged v0.0.8 items as onmain, in the next release, and v0.0.7 as released. The README,llms.txtand the introduction say client authentication and onward authentication are built.
Fixed
- The Mitz consent pre-filter’s budget,
nl_gf.mitz.timeout_ms, is now a part of the overall budget, as the localizer’s and the PDQm step’s are:config check,serveand a reload refuse a configuration whose pre-filter, PDQm and localizer budgets together reachfederation.overall_timeout_ms, naming the keys, andOPTIONS {base}/declares the budget astimeout.consent_ms(§11.5, #538). Before, a long Mitz timeout could take the time left to resolve and ask the members with no refusal at startup. The PDQm check now also counts the localizer’s default budget undernode_selection = "localized"when[federation.localization]is not written, where it counted none. - An XCPD discovery whose audit message is still being stored when the
localization budget runs out now fails closed under
on_failure = "ask-all"too, as every exchange the gateway could not audit does (#529). The gateway stopped waiting at the very instant the discovery’s own bound fired, so it read the discovery as a localizer that did not answer, and ask-all widened the query to every member. The localizer and the[pdqm]step are now given a deadline a tenth of their time short of the gateway’s, so the audit failure reaches the gateway first and leaves every membernot-localized. - An audit record still queued behind another spool write when its
exchange’s time runs out is now written once that write ends, and
delivered once, in the order it was queued (#532). Before, the record of
a failed exchange was dropped before its write started, and nothing
logged it. A failed exchange is still audited, and ITI-20 has a stored
record sent when the sender is able (ITI TF-2 §3.20.4.1.1). A queued
record holds its place under
spool_max_eventsandspool_max_bytesas a stored one does, so a stalled disk holds no more records in memory than the spool could hold on disk: a record past either bound is refused at once as a full spool, its exchange fails as an audit failure, and the refusal is logged with its count, never the record, and counted in the newferrofed_audit_refused_total. - A slow or stalled disk under the audit spool no longer holds a patient
query past its budget (#512). Storing one record is bounded by the new
spool_write_timeout_ms(2000 by default) in[audit.repository]and[xcpd.audit_repository], and the record of an ITI-83, ITI-78, ITI-119 or ITI-55 exchange also by the time left to that exchange. A record not stored in time is an audit failure under the policy already in place (424for the query,503for an ITI-93 message), and is not counted in the spool’s depth. Its write goes on: a record it stores is delivered like any other, and a write that fails is logged, with the sequence number and never the record. One write runs at a time, so a stalled disk holds one thread, and the audit metrics andGET /health/dependenciesread the spool’s depth without waiting on the disk. - The ITI-55 audit trail starts its delivery again when the runtime it was started on has ended, so audit messages recorded after that are delivered rather than left in the spool (#486).
- A
[[pixm.manager]]URL that is nothttpsoutsideprofile = "development"is refused while the configuration loads, as an XCPD gateway URL already was, naming its key; before, onlyserve,config checkand a reload caught it (#464). - The example Kubernetes manifests start a gateway again (#428): the
ConfigMap trusts an example issuer in
[auth]and names the gateway’s signing key in[signing], read from theferrofed-secretsSecret the Deployment now mounts at/run/secrets/ferrofed. CI runsferrofed config checkover the ConfigMap’s configuration with synthetic secrets (scripts/checks/kubernetes-example.sh), so the example cannot drift from what the gateway accepts. - A refresh of the mCSD directory whose change the gateway refuses no longer
reports the directory
uponGET /health/dependencieswhile the gateway serves the registry it last accepted (#481). The directory showsdegraded, withdirectory_faultnaming the class of the refusal (registry-invalidorconfiguration-mismatch) and never a member or a URL, until a later refresh is accepted. A directory that did not answer still showsdown, and one whose answer is brokenfailing. A directory that answers a refresh with a4xxshowsfailingwhere it showedup, and a401or403namesdirectory_fault = "refused-credentials".
0.0.7 - 2026-10-03
The v0.0.7 milestone, definitions and membership (§12.6, §12.7, §12b). A versioned write goes only to the CDR that created the version. A template goes to the node you name, or to several on request, with each member’s outcome reported. Stored queries live at the gateway as immutable versions, on an embedded store, PostgreSQL or a read-only directory, distributed to the members with drift reported and repaired from the admin listener. A check you run before admitting a node tests the admission conditions. The gateway exports metrics through OpenTelemetry, as Prometheus and over OTLP, records each member’s state from the calls it makes, holds every credential in a type that never renders it, and prints a startup banner. Every release now carries a compose file that starts the gateway alone in front of the CDRs you already run. The gateway still authenticates no client; that is v0.0.8 (#80).
Added
-
Every release carries
compose.yaml,ferrofed.tomlandregistry.toml, which start the gateway alone in front of the CDRs you already run, with no checkout of the repository (#385; no specification governs packaging: our own design). You download the three, edit the two TOML files (the gateway configuration and the registry document, in their documented formats, every value to change markedEDIT), put each credential in a file undersecrets/, and rundocker compose up --wait. The compose file runs the publishedghcr.io/ferrohealth/ferrofedimage at the release’s version and mounts the two files andsecrets/read-only; its only variables are about the container (image tag, host address and port, CPU and memory). The gateway runs read-only, as uid 65532, with every capability dropped,no-new-privileges, CPU and memory limits, a 20-second stop grace period, theferrofed healthcheckprobe and theunless-stoppedrestart policy, so a configuration it refuses (exit 78, naming the key) is retried at Docker’s doubling delay. The release lane attaches the three files to the draft and refuses to publish a draft missing any of them,scripts/checks/versions.shand the release plan hold the image tag to the product version, and the CI jobrelease composerenders the compose file and runsferrofed config checkover the two examples as attached. The repository’scompose.yamlstays the four-node quickstart. The container page of the book has a section “The gateway from a release”. -
The operator can send a stored-query version the registry holds to the members that missed it (#342; §12.7 stored-query-drift, N44, CP-40; no specification gives drift repair a request, so this is our own design).
POST /admin/stored-queries/{name}/{version}/distributeon the admin listener ([metrics] listen), with no body and the members in the targeting headers, sends the registry’s held copy to them and changes nothing at the registry. The answer has the shape and statuses of a first distribution, and its newmeta.registrymember saysheld, where a first distribution’s saysstored. It is404for a version the registry does not hold,400for a request naming no member, a body, a deployment without distribution or an endpoint-targeted definition, and405with an emptyAllowat a read-only registry. Without the admin listener the action does not exist. Every secondPUTof a held version stays409(stored-query-held), the same query naming members included. The book’s stored-query page has a “Repairing drift” section. -
A metrics surface (#281; no specification governs metrics). One OpenTelemetry meter provider counts the integrity incidents by
kind(ferrofed_integrity_incidents_total), the requests sent to each member endpoint by the §11.1 outcome the per-endpoint report gives them (ferrofed_node_requests_total{endpoint, outcome}), how long each member took to answer (ferrofed_node_request_duration_seconds{endpoint}), and the registry reloads byresult,appliedorrefused(ferrofed_registry_reloads_total).[metrics] listenserves the Prometheus text exposition atGET /metricson an admin listener of its own, never the gateway’s listener, and[metrics] otlp_endpointpushes the same metrics to an OTLP gRPC collector; both are off by default.serveandconfig checkrefuse a listener on a non-loopback address unless[metrics] allow_remote = trueis set, a listener onserver.listen, and a collector that is nohttp://URL. Every label value is drawn from a closed set or the registry document, never from a request (§5.4.1, N33). The book’s operate section has a Metrics page listing every metric and its labels. The integrity incident webhook the architecture named is removed from the design: alert on the counter. -
The stored-query registry runs on one of three backends, chosen with
[stored_queries] backend(#268; §12.7, N44, CP-40).redb, the default, is the embedded file for one gateway, as before.postgres, behind theferrofed-servercargo feature of the same name and off by default, keeps the definitions in one PostgreSQL database every replica shares: the primary key on the name and version withINSERT … ON CONFLICT DO NOTHINGstores exactly one of two racingPUTs of a new version, the other is a409(stored-query-held), and a read or an invocation reads the store again first, so every replica serves what any of them stored. Its connection string is a secret,urlorurl_file, TLS is rustls with the platform’s roots, and the schemaferrofedand its table are created when absent.filesis read-only: one file per{qualified_query_name}/{version}.aql, loaded and admitted at start; aPUTis a405with the new codestored-query-read-onlyandAllow: GET, OPTIONS,OPTIONSlists noPUT, and a malformed file refuses the start andconfig check, naming the file and never its content. The startup banner and log line name the backend kind and never its path or connection string. -
PUT {base}/v1/ehr/{ehr_id}is checked against what the gateway already knows of theehr_idbefore anything is sent (#289; §12.4, §12.5.2, N23, N42, CP-15; ITS-REST 1.1.0ehr_create_with_id). When a resolution binding of the session or theehr_idindex places theehr_idat a member other than the one the targeting headers name, the create is a409with the new codeehr-id-held, no node is sent it, and the gateway logs a warning naming endpoint ids only; the message names the holding endpoints and the targeted one, never theehr_id. A create at the member that holds theehr_idis forwarded, so that node answers its own409, and anehr_idnothing places is forwarded as before and, once created, indexed at the targeted member, where a racing create at another member raises the index-insert alarm of §12b.2. -
Stored-query definition fan-out, opt-in with
[federation] fan_out_stored_queriesbeside the registry (#78; §12.7, N44, N43, CP-40). Off by default, refused byconfig checkand at start without[stored_queries], and declared inOPTIONS {base}/asdefinition.stored_query_fan_out,trueonly beside the registry. Where it is on, a stored-queryPUTwhoseopenEHR-federation-endpointis*or whose targeting headers name members is stored at the registry first, then sent to each member as the registry’s canonical AQL throughopenehr-its’s generated client, and answered with the registry’sStoredQueryandmeta.federationon the template fan-out’s statuses:200,207withcompletefalse, or424and504, the registry holding the definition whatever the members answered and nothing rolled back. A definition carrying aFROM ENDPOINTorORGANISATIONdirective is refused for distribution with400 definition-endpoint-targetedbefore anything is stored, and stays storable and executable federated. AGETof a version naming members reports drift per member:activewhere its copy is the same query, andnode-errorwitherror.codedefinition-differsordefinition-missingotherwise,207unless every named member matches. An invocation always runs the registry’s AQL, never a member’s copy. With the setting off, a registryPUTor versionGETcarryingopenEHR-federation-endpointoropenEHR-federation-organisationis refused with400 stored-query-fan-out-unsupported, storing and reading nothing, where the header was ignored before. -
The federated AQL answer carries the provenance headers (#288; §7a.3, N31, CP-24).
openEHR-federation-endpointlists the endpoints that contributed rows andopenEHR-federation-system-idtheir nodes’system_ids, position for position, in registry order and in the comma-separated form of the §8.4 request header. An endpoint contributed when it answeredactivewith arow_countabove 0; one that answered no rows, failed or was never asked is not listed, an answer that fails lists none, andmeta.federation.endpoints[]still names every endpoint (§11.1). The headers carry registry ids andsystem_ids, never a request value (§5.4.1). -
Fan-out template upload, opt-in with
[federation] fan_out_template_upload(#76; §12.6, N43, CP-34). Off by default, andOPTIONS {base}/declares the configured value asdefinition.fan_out_template_upload. Where it is on, an ADL 1.4 or ADL 2 template upload whoseopenEHR-federation-endpointis*(every active member) or whose targeting headers select several endpoints is sent to each member independently, byte-identical through the routed forwarding path, and nothing is rolled back. The answer ismeta.federationwith oneendpoints[]entry per registry member and no node’s body:200when every named member accepted,207withcomplete: falsewhen some failed, and424or504when none accepted. The provenance headers list the members that accepted. A plain upload still names its one node, and*staysendpoint-unknownon every other definition request and whenever the setting is off. -
ferrofed serveopens with a startup banner when its log renders for a person (#330): the FerroFED wordmark, the version, the maintainer and the repository, the Federation Tier, ITS-REST, AQL andopenehr-*releases it serves, then the base path, the listen address, the member and endpoint counts of the registry document, and whether the stored-query registry is offered. The document is read once, before the banner, and the gateway is built over that same read, so the banner counts the registry it serves. Underprofile = "development"a red notice, in plain words where there is no colour, says the deployment must not hold or reach real patient data. The banner prints only before theprettyrendering, so a log collector reading JSON never receives it, andconfig check,healthcheckandadmission checkprint none. It never shows a credential, a URL, a header value or request data. Each pin comes from the crate constantscripts/checks/versions.shholds todocs/VERSIONS.md, and theopenehr-*family version is a new constant the guard checks. -
The console honours
NO_COLOR(#330, https://no-color.org): set and not empty, it switches colour off in theprettylog and the startup banner, whatever the format and the terminal. -
The ITS-REST
GETforms of query execution are served (#287; N1, CP-1).GET {base}/v1/query/aqland, where the stored-query registry is offered,GET {base}/v1/query/{name}[/{version}]carry the request in the query string:q(ad hoc only),offsetandfetchby name, and every other pair aquery_parametersmember, so a storedGETbindsq=…to$q. The generatedopenehr-itsparameters decode it (FerroEHR #3540), a+is a literal plus, and the request runs the pipeline of thePOSTform unchanged: the rewrite, the outbound gate, the fan-out, the merge and the completeness rules, with the same node requests and the same answer. Anehr_idis dropped, as from aPOSTbody. A query string the decoder refuses is400 body-invalidand asks nobody; anullparameter is400 parameter-invalid, as in a body.OPTIONSon both paths listsGET, and the request log records neitherqnor a parameter value. -
The DEMOGRAPHIC area is never federated, and may be routed to one declared endpoint that the request names (#68, #311; §7a.1, §12.4, §12.6, N23, N32, N31, CP-25). By default every request under
{base}/v1/demographic/answers501and no node is asked. The new[federation] demographic_endpointsetting declares the one registry endpoint that serves the area. A DEMOGRAPHIC request names that endpoint inopenEHR-federation-endpointand then goes to it alone, through the single-node path definition requests use: the body byte-identical, only what the ITS-REST operation declares, and the node’s answer as the node sent it withopenEHR-federation-endpointandopenEHR-federation-system-id. The setting is never applied as a default: a request naming no endpoint is400 target-requiredand no node is asked, one naming another endpoint is400 targeting-conflict, and several endpoints,*or an unknown id are refused as on any routed request.config checkrefuses an id the registry does not hold, and the setting withoutregistry.document.OPTIONS {base}/declaresits_rest.demographicasunsupported: 501, or asrouted-single-nodenaming the endpoint a request names, andOPTIONSon a DEMOGRAPHIC path names its ITS-REST methods only when the area is routed. -
Definition requests are routed to one explicitly chosen node (#75; §7a.1, §12.6, §12.7, N43, N31, N33, CP-34). Every request under
{base}/v1/definition/, an ADL 1.4 or ADL 2 template upload, list, read or example, and stored-query management where the stored-query registry is not offered, goes only to the one endpoint the targeting headers name. The body reaches that node byte-identical with only what the ITS-REST operation declares, and the node’s answer, its404or validation400included, comes back as the node sent it withopenEHR-federation-endpointandopenEHR-federation-system-id. Without a header the request is400 target-required;*or an unknown id is400 endpoint-unknownand two endpoints are400 endpoint-several, so no node is ever picked implicitly and no two nodes’ templates are combined into one catalogue. Where the registry is offered it keeps answering stored-query definitions itself. Without the registry both stored-queryPUTs are routed, the versionedPUT {base}/v1/definition/query/{name}/{version}included (#298), andOPTIONSon either path listsPUT.OPTIONS {base}/declaresits_rest.definitionasrouted-single-node, andOPTIONSon a definition path names its ITS-REST methods. -
ferrofed admission check --endpoint <id>exercises one configured member against the identifier-integrity conditions of §12b.2 and writes a report to standard output (#79; §12b.1, §12b.2, N42a, CP-33a). It creates test EHRs on the node (3 by default,--countfrom 2 to 50) throughPOST {base}/v1/ehr, each for a fresh synthetic subject inurn:oid:2.999.1.0, reads each back, and reports every condition aspass,failorcannot-checkwith its evidence:ehr_idgeneration (a version-4 UUID, and noehr_idissued twice),system_iduniqueness (thesystem_idthe node reports is the one the registry records, which the registry holds unique), and theehr_idexchange (the configured cross-reference maps each subject to theehr_idthe node created, orcannot-checkwhere the gateway cannot write to it). Reuse across restores and adoption of foreignehr_ids are alwayscannot-check, with the reason. A node the check cannot reach fails every condition it exercises. Every request passes the outbound gate with the run’s subjects withheld (§5.4.1, N33), and the report prints no subject and no node error body. The command exits0when no condition failed,1when one did. The book’s new page “Admitting a node” states what the check proves and what it cannot. -
The registry reloads on
SIGHUP(#282).ferrofed servereads its configuration again from where it started and checks it as at boot; a valid one replaces the registry for every request that starts after it, while a request in flight finishes on the registry it took. The[registry],[credentials],[dev]and[pixm]sections take effect: an added endpoint gets its node client and credentials, and a removed one is never called again. A learnedcreating_system_idroute the new document maps elsewhere is withdrawn with aRegisteredCreatingSystemConflictincident, andehr_idindex entries and resolution bindings naming a member that left are dropped; one a request in flight learns after the swap is dropped at its next lookup, and a read then probes every member while a write needs its target, so an entry is never narrowed to the claimant that remains (§12.5.2, N42). A change to any other setting is logged as needing a restart while the rest applies. A configuration that does not load is refused, the running registry stays, and theERRORline names the failure class and the files, never their content.config checkis unchanged. There is no file watch. -
GET {base}/v1/ehr?subject_id=…&subject_namespace=…, the ITS-REST read of an EHR by subject, is served (#218; §5.2, §5.4.1, §12.5.2, N3, N31, N33, CP-24, CP-26). The two parameters are resolution input: the gateway resolves the subject through the cross-reference service and sendsGET {base}/v1/ehr/{ehr_id}to the one member that holds it, under that member’s ownehr_id, with no query string, and returns the node’s answer unmodified withopenEHR-federation-endpointandopenEHR-federation-system-id. No node request, error body or log line carries the subject. A subject several members hold is refused with the new code409 subject-severallisting the endpoints, unlessopenEHR-federation-endpointnames one of them; a subject no member holds is the operation’s own404 no-destination; a cross-reference that cannot answer is the new424 resolution-unavailable.OPTIONS {base}/v1/ehrnow listsGET. -
The gateway serves at a base path of the deployment’s choosing (#69; §4.1, N28, CP-21).
[server] base_path,/by default, puts every route under it,OPTIONS {base}/, the health family and{base}/v1/…included, and every path outside it answers404. No prefix is reserved, and/rest/openehris served only where a deployment chooses it. A base path that does not start with/, ends with/, carries a query or a fragment, or has an empty,.or..segment refuses to boot, namingserver.base_path. Nodes are still asked at their own base, and a node’sLocationstill passes through unmodified (N31). -
Both AQL forms of N29 scope a query to one
ehr_id(#69; §7.1, N29, CP-22).FROM EHR e[ehr_id/value='…']reaches a node as the canonicalWHERE e/ehr_id/value = '…', so both forms send the same query and answer the same rows. An undirected query scoped to oneehr_idgoes only to the member that owns it, found as a pathehr_idis (§12.5.1, N41): the session’s binding, theehr_idindex, then the ask-all probe. Every other member is reportedexcluded, the answer names the acting endpoint inopenEHR-federation-endpoint(N31), two claimants are409 ehr-id-collisionwith neither queried (N42), anehr_idno member holds is404 no-destination, and one that is not a bare UUID and no earlier step routes is400 probe-requires-uuidwith no member asked (§5.4.1, N33). A query directed byFROM ENDPOINTor a targeting header still goes to the endpoints it names. -
Versioned writes reach only their controlling CDR, and a new EHR only an explicit target (#65; §12.4, §12a.1, §10.3, N23, N41, CP-15). An update of a composition, the
EHR_STATUSor the directory, a directory delete (each naming its preceding version inIf-Match) and a composition delete (naming it in the path) route by the pathehr_idas before, and are sent only when the registry maps the preceding version’screating_system_idto that same node, as the member’s ownsystem_idor a[[creating_system]]mapping; a learned mapping never counts. Otherwise the write is refused409 controlling-system-unreachable, naming the controlling node where the registry knows it, and no node is sent it. A versioned write that names no single preceding version (noIf-Match, several,*, a weak or unquoted tag, or noOBJECT_VERSION_ID) is refused with the new code400 preceding-version-invalid.POST {base}/v1/ehris now served, and it andPUT {base}/v1/ehr/{ehr_id}go only to the one endpoint the targeting headers name: without them the request is400 target-required, and with several endpoints400 endpoint-several; neither a binding nor the index routes a new EHR.OPTIONS {base}/v1/ehranswersPOST, OPTIONS. -
A
CONTRIBUTIONthat amends versions reaches only the CDR that controls every one of them (#286; §12.4, §12a.1, §10.3, N23, CP-15).POST {base}/v1/ehr/{ehr_id}/contributionroutes by its pathehr_idas before, and the gateway now reads the body with the ITS-RESTNewContributiontype: each version’spreceding_version_uidmust be one the registry maps to that node, by the rule above, or the request is refused409 controlling-system-unreachableand no node is sent it. ACONTRIBUTIONof creations alone routes as before. A body that is noCONTRIBUTIONin the representation itsContent-Typeselects, apreceding_version_uidthat is noOBJECT_VERSION_ID, or an XML body, is refused400 preceding-version-invalid. The node receives the body byte for byte. -
A
CONTRIBUTIONwhose versions’datais FLAT or STRUCTURED is routed as a canonical-JSON one is (#297; §12.4, §12a.1, N23, CP-15). UnderContent-Type: application/openehr.wt.flat+jsonorapplication/openehr.wt.structured+jsonthe envelope stays canonical (ITS-REST 1.1.0contribution_create), and the gateway reads it with the genericNewContributionofopenehr-its0.0.80 for each version’spreceding_version_uid, leavingdatato the node. ACONTRIBUTIONof creations alone goes by its pathehr_id, one whose every amended version the path node controls reaches that node, and one with a version another member controls, or no member is known to, is409 controlling-system-unreachableand no node is sent it. The node receives the body byte for byte, under the declared media type. ACONTRIBUTIONin canonical XML is still refused400 preceding-version-invalid(#308), now with a message that names the XML form. -
Integrity incidents for an
ehr_idtwo members claim (#63; §12.5.2, §12b.2, N42, CP-33). A request whoseehr_idthe session’s resolution bindings, theehr_idindex or the ask-all probe finds at two members or more is refused409 ehr-id-collisionlisting the claiming endpoints, and the gateway logs oneEhrIdCollisionincident: anERRORline under the new log targetferrofed::integritywith theehr_id, the routing step that found the claimants (detection) and the claimants’ endpoint ids. When the index learns anehr_idit already holds at another member, it raises the index-insert alarm of §12b.2 once, anIndexInsertCollisionincident naming the members, keeps both and routes neither; a held collision lasts until the entry is forgotten or the gateway restarts. An incident names anehr_idonly when it is a bare UUID, and never a patient identifier. The book’s configuration page says what an operator sees and does, and the metrics surface (#281) counts each incident bykind. -
The obligations checklist (#278):
conformance/obligations.tsvholds one row per normative statement of the pinned Federation Tier specification, 447 across its 26 pages and both JSON schemas, each with the status FerroFED holds for it and the test, code, issue or #212 report behind that status. 273 are tested, 52 planned, 3 built but untested (#290), 3 missing (the GET forms of query execution, #287, and the provenance headers on the federated AQL answer, #288), 5 deferred, and 18 are contradictions or silences of the text recorded on #212; the other 93 fall on a member node or the operator, or are no obligation of the gateway. The book renders the counts and every gap on a new page beside the conformance matrix. A new tier-1 guard,scripts/checks/obligations.sh, fails on an unknown status, a gap that names no issue, a test that does not exist, a point or requirement the matrix does not hold, a duplicated row, or a stale page, and it holds a digest of each vendored page’s keyword lines, so a re-pin fails until the changed pages are reclassified. The comment-style guard now reads theevidencecells of the conformance tables as it reads theirreasoncells. -
Container readiness (#303).
ferrofed healthcheckasks the gateway on this host forGET /health/readinessover loopback, prints one line and exits0only on200,1otherwise; the image carriesHEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3over it, and thecompose.yamlgateway service the same healthcheck. Readiness answers503until boot completes and from the momentSIGTERMorSIGINTarrives, before the drain starts, and its body names the phase and the gateway’s own subsystems (the configuration, the registry, the outbound clients, the stored-query store). No member node and no identity source gates readiness: the newGET /health/dependenciesalways answers200with the state the gateway last observed of each member endpoint and of the resolver (up,failing,down,unknown), from the requests it already makes, never a probe of its own, naming endpoint ids and states only.deploy/kubernetes/holds an example ConfigMap, Deployment, Service and PodDisruptionBudget, validated in CI by a digest-pinnedkubeconformin strict mode. No specification governs health probes: our own design. -
The supported platforms are stated (#307): FerroFED runs on Unix only. The server drains on
SIGTERMand reloads onSIGHUPthrough Unix signals, and every release binary and the container image are Linux. The book says so under Operate, and buildingferrofed-serverfor a non-Unix target, such as Windows, stops at acompile_error!that names the reason. No specification governs this: our own design.
Changed
- CI checks every internal link and anchor of the published site (#377; no
specification governs this: our own design). The new tier-1 job
site-links, in the requiredconclusion, assembles the site as the Docs workflow publishes it and runs lychee 0.24.2, pinned by version and SHA-256, with--offline --include-fragmentsover every page, and overREADME.mdagainst the repository tree, so a link to a page or an anchor that does not exist fails the change and no request leaves the runner.scripts/checks/site-links.shruns the same check locally, with a self-test, andscripts/site/assemble.shtakesSITE_ROADMAP=offto leave the roadmap block unrendered.docs/VERSIONS.mdpins lychee, and the versions and pin-freshness guards hold the pin. - The errors page names the requests that answer
501 not-implemented(#378; §7a.1, N32): every path under{base}/v1/, andOPTIONS {base}/, without a registry; the DEMOGRAPHIC API unless its endpoint is declared; the ADMIN API; stored-query execution without the stored-query registry; a path ITS-REST does not define or a method it does not declare; andOPTIONSon an unserved path. The errors test now sends each of them. - The quickstart’s
compose.yamlruns only the publishedghcr.io/ferrohealth/ferrofedimage: itsbuild:block is gone, sodocker compose up --buildno longer builds the gateway, andscripts/checks/release-compose.shfails when any compose file in the repository carriesbuild:(#385; no specification governs packaging: our own design). The gateway service now has a 20-second stop grace period, longer than its 10-second drain, sodocker compose downno longer cuts a drain off at Docker’s default 10 seconds. - The README, the landing page and the book are rewritten against what the
gateway does on
main(#363; no specification governs the website). The book’s claims page lists what v0.0.3, v0.0.6 and v0.0.7 carry and what v0.0.8 and v0.0.9 plan, by issue; the operate pages state that the gateway authenticates no client yet and which onward credentials it sends, that no session-scoped resolution binding is held until client authentication (#80), and that a node’s consent refusal is reportednode-erroruntil #83. Two pages are new: Identity resolution, covering[pixm],[dev],profileandfederation.default_namespace, and Health probes, moved out of the container page.Prefer: waitis documented beside the budgets. The landing page’s quickstart runs the four-node compose stack with its seed script, and its cards name what each release shipped. - The ask-all probe is timed (#366; no specification governs metrics): each
member’s probe now carries its latency, so
ferrofed_node_request_duration_secondstimes it beside its count inferrofed_node_requests_total, and a probe still waiting when the overall budget runs out is atime-outtimed to that moment. The book’s Metrics page lists which calls eachoutcomeand the histogram cover. - The book’s client contract and configuration pages are split along their own sections (#348): the client contract continues on Follow-ups, Templates, definitions and demographics, and Stored queries, and the configuration on The registry, and Queries and API areas. The file-length guard now holds the book’s Markdown pages to the same limits as the Rust sources.
- A node’s error in
meta.federation.endpoints[]follows one rule on every path that reports a member: a federated query, the fan-out template upload and the stored-query distribution and drift check (#343; §9.5, §11.1, §12.6 item 2, N40). Anode-errorcarries the node’s HTTP status, then an excerpt of the node’s own message: at most 512 characters, control characters and invisible format marks turned into spaces, and the patient identifier the query resolved on replaced by[withheld]wherever the node echoed it, the whole message when the node echoed it percent-encoded (§5.4.1, N33). The HTTP client’s reason anofflineortime-outnode was not reached is held to the same rule. The two fan-outs carried the node’s status alone before, and a query copied the node’s message with no cleaning and no masking. - A recombined
AVGover integers answers an integer (#309; AQL 1.1.0 §3.9.1.5, “it will also determine the return type”). When every node’sSUMis an integer, the gateway returns the integer nearest the exact quotient of the federation’s sum and count, a tie going to the even one, so5 / 2is2and7 / 2is4; it rounds once, after adding every node’s sum and count, never per node. AQL states no rounding rule, so the rounding is FerroFED’s own. When a node’sSUMis a real,AVGis the decimal mean written as the nearest JSON number, as before.openehr-federationis 0.0.33. - The
openehr-*family moves to 0.0.81 (FerroEHR #3548, #3551, #3552), andopenehr-federationto 0.0.35 with it (#329). - A body routed to one node without a
Content-Typetravels with the first media type the operation lists,application/jsonwherever ITS-REST lists several, where it was a415for an operation whose body is declared in more than one (#329). ITS-REST 1.1.0 makesContent-Typeoptional with no default, so the first listed is the gateway’s own choice, as for a missingAccept. TheContent-Typeof a body is held to the media types the operation’s body is declared in, and aCONTRIBUTIONis read for its preceding versions by the request body reader ofopenehr-its. - The compose quickstart runs four FerroEHR nodes,
ferroehr-atoferroehr-don ports 8081 to 8084, each with its ownsystem_id(#322). They share one FerroEHR PostgreSQL container,ferroehr-postgres, with a database per node owned by its own role; the image’s init script runs once per node throughdocker/postgres/20-ferrofed-node-databases.sh, so the per-nodeferroehr-a-postgresandferroehr-b-postgresservices and their volumes are gone (docker compose down -vremoves the old ones). Each node database admits only its own node’s role:CONNECTis revoked fromPUBLIC, so one node’s role is refused on another node’s database. The gateway’s quickstart configuration is a development profile whose static cross-reference maps four synthetic patients inurn:oid:2.999.1.1: one at all four nodes, one at two, one at one and one at none, andscripts/quickstart/seed.shcreates exactly those EHRs and a composition in each over ITS-REST. A patient query now resolves in the quickstart instead of failing closed with424. The book’s container page walks through a full federated query, a patient missing at some nodes and a query directed at one node, and states the measured memory use: 238 MiB afterdocker compose up --wait, 425 MiB after the seed and the queries. - The end-to-end harness starts one FerroEHR PostgreSQL container per topology with a database per node, through the same init script, instead of one container per node (#322, #320). CI keeps two nodes.
scripts/conformance/obligations.shandscripts/checks/obligations.share executable, as every other script is (#320).- The query string of a stored-query
PUT {base}/v1/definition/query/{name}/{version}is decoded by the generatedopenehr-itsparameters (#292, FerroEHR #3540), so a+is a literal plus. Aquery_typegiven twice, or a pair that does not percent-decode to UTF-8 text, is now400 body-invalidand nothing is stored; an undeclared parameter stays400 query-parameter-refused. - The
openehr-*family moves to 0.0.80 (FerroEHR #3539 to #3541, #3543), andopenehr-federationto 0.0.31 with it. The fuzz crate is now held to the family pin byscripts/checks/versions.sh. - An
ehr_idthat the session’s resolution bindings or theehr_idindex hold at two members is now refused409 ehr-id-collisionlisting the claimants, on a write as on a read (#63; §12.5.2, N42). A read no longer goes on to the ask-all probe, and a write is no longer400 target-required. Naming the node inopenEHR-federation-endpointstill routes the request to it (§12.5.1 step 1, N41). The integrity incidents of thecreating_system_idmap are logged underferrofed::integritytoo. - The
409 controlling-system-unreachablemessage now names the controlling system in every case (#66; §10.3copy-write-reject, N36, CP-29). Where the registry routes the version’screating_system_idto another member, it names thatcreating_system_idas the registry spells it, with the member’s node and endpoint. Where no member is known to control the version, it points at the place in the request that names the version (If-Match, the path, or aCONTRIBUTIONversion by position), and still quotes nothing of the request. CP-29 is scored end to end: a write against a row thatversion-identitydedup kept, sent through the copy’sehr_id, is refused409whether the creating node is down or up, the copy’s node is sent nothing and the creating node is never tried, and a write through the creating node’s ownehr_idreaches it alone and its answer names no copy. - A body routed to one node always travels with a
Content-Typeits ITS-REST operation declares (#298; §12.6, N43, CP-34). The gateway reads each operation’s request-body media types fromopenehr-its(request_media): a clientContent-Typefor an operation that declares noContent-Typeparameter, such as the versioned stored-queryPUT, is composed as the listed media type it names, or refused415 media-type-unsupported; a body sent without one travels with the one media type the operation’s body is declared in,text/plainfor a stored query andapplication/jsonfor a commit, and an operation whose body is declared in several is415. A forwarded body no longer reaches a node without aContent-Type. - Each path identifier of a routed request is parsed as the identifier class
openehr-itsstates for it, and the follow-up routing table reads a version from the path parameters of that class, with no name list of the gateway’s own (#291; §5.4.1, §12.2, N33, CP-26). Theuid_based_idof aDELETEis anOBJECT_VERSION_ID, as ITS-REST declares: a DEMOGRAPHIC delete addressing aHIER_OBJECT_IDis400 parameter-value-invalid, and a composition delete stays400 preceding-version-invalid, with nothing sent.
Fixed
-
Every link in
llms.txtis now an absolute URL (#392). The file is served at the site root, where its repository-relative links resolved to nothing. -
A fan-out template upload, a stored-query distribution or repair, and a stored-query drift check fail with a
500when the task for one member panics (#380; no specification governs the metrics or health probes). Such a task was reported as abandoned by the member: counted as atime-outinferrofed_node_requests_total, markeddownonGET /health/dependencies, and listed astime-outinmeta.federation, blaming the node for a defect in the gateway. The probe and the federated query already failed this way, and none of these calls records a member for a request it fails. -
The admission check reports an EHR call whose deadline passed before it left the gateway as a call never sent (#379; §11.5), where it said the node did not answer before the deadline.
-
A request whose deadline passed before it left the gateway is no longer counted as a node
time-out(#374; §11.5; no specification governs the metrics or health probes). A request routed to one node, an ask-all probe, a fan-out template upload, a stored-query call and a federated query member the budget overtook before sending are now neither counted inferrofed_node_requests_totalnor timed inferrofed_node_request_duration_seconds, and leave the member’s state onGET /health/dependenciesas it was, where they counted astime-outand set the memberdown. A fan-out whose overall budget had already run out when it started reported every member as asked and silent; it now reports none as asked. What the client receives is unchanged: the routed request and the probe answer504(node-timeout), and the member record inmeta.federationsaystime-out. -
GET {base}/health/dependenciesrecords each member a fan-out template upload, a stored-query distribution or repair, or a stored-query drift check asked (#366; no specification governs health probes), where a member those calls found down kept the state an earlier request left. One rule now holds for every call: the state is the member’s reachability and health, never whether the request was valid. Any answer below500isup, a5xxisfailing, and no answer isdown, read from the node’s own HTTP status whatever the §11.1 record says. A query member that answered400, a refused store, a node that refused the gateway’s onward credentials, and a drift check whose copy differs or is missing areupwhere some of them werefailing; a request that never left the gateway changes nothing. -
ferrofed_node_requests_totalandferrofed_node_request_duration_secondscount only requests that left the gateway (#366). A member a fan-out template upload or a stored-query call could not send a request to staysofflineinmeta.federation, which §11.1 leaves no other status for, and is no longer counted and timed as anofflinenode request; nor is a federated query member whose deadline passed before its request left. -
A request the gateway routes or answers under
{base}/v1/is logged under the path template of the ITS-REST operation it addresses, such as/v1/ehr/{ehr_id}/composition/{uid_based_id}, where its request line named<unmatched>(#219). The template comes fromopenehr-its’s route table, so the line never carries theehr_idor version uid of the path, and the configured base path appears in front of it once. AnOPTIONSrequest is logged under the template of the resource it describes. A path that names no route, or any other method its operation does not declare, is still logged as<unmatched>. -
POST {base}/v1/query/aqland the stored-queryPOST {base}/v1/query/{name}hold theirContent-Typetoapplication/json, the media type ITS-REST 1.1.0 lists for both (#269). Another media type, or a parameter other thancharset=utf-8, is415 media-type-unsupported, and no node is asked (RFC 9110 §15.5.16); a body sent astext/plainused to be read as JSON. A body sent without aContent-Typeis read as JSON, the first listed media type, as for a missingAccept, since ITS-REST and RFC 9110 §8.3 name no default. The stored-query definitionPUT {base}/v1/definition/query/{name}/{version}the registry answers holds itsContent-Typetotext/plainunder the same rule: another media type is415 media-type-unsupportedand nothing is stored. -
The
query-parameter-refusedsecurity event says “a request carried a query parameter the gateway does not admit for its operation, and was refused”, which is true of a routed request and of the stored-query definitionPUTthat raises it too (#269). -
OPTIONS {base}/declares the EHR area as the gateway routes it (#290; §7a.2, N30, CP-23).its_rest.ehrnow says that a new EHR, byPOST {base}/v1/ehror byPUT {base}/v1/ehr/{ehr_id}, goes only to the endpoint the targeting headers name, and that thePUTis refused when another member holds itsehr_id. It no longer names the session’s resolution binding as a routing step, because without client authentication no request has a session. Each of the fourits_restdeclarations is now tested against the behaviour of its area in every configuration mode. -
A query dispatched to a single node, directed at one endpoint by the
openEHR-federation-endpointheader orFROM ENDPOINT, or undirected with the patient resolving at one member alone, answers withopenEHR-federation-endpointandopenEHR-federation-system-idnaming that endpoint and its node’ssystem_id, as N31 requires of a request dispatched to a single node (#319; §7a.3, CP-24). Zero rows, a424or a504from that node carries them too; a query that asked no node carries neither. -
A request routed by its target alone (
POST {base}/v1/ehrand a definition request) checks its declared header and query values before its target, as the EHR route does: a malformed value is400 parameter-value-invalid, and anAcceptorContent-Typethe operation does not list is406or415, before400 target-required(§5.4.1, N33). -
A
[credentials."<endpoint id>"]key is held to the registry’s endpoint id rule when the configuration resolves, with or without a registry (#272). Configuration used to accept 1 to 128 printable ASCII characters and left the registry’s rule to the federation load, so a key such asnode:a,-nodeor a 65-character id passed when no registry was configured. The one rule is now the registry’s: 1 to 64 ASCII letters, digits,.,-and_, starting with a letter or digit, and the refusal (exit 78) names the key. The CI container job now runs theENDPOINTattribute test against both FerroEHR nodes, and a new tier-1 guard,scripts/checks/e2e-placement.sh, refuses a container test placed where that job never selects it.
Security
- A request routed to one node no longer forwards client text in a path
identifier, a structured query value or a negotiation header (#261;
§5.4.1, N33, CP-26). The routed path resolves no patient, so the outbound
gate had nothing to compare a client’s
version_at_time,Acceptor pathversion_uidagainst, and a patient identifier written there reached the node. The gateway now works from what the ITS-REST operation declares inopenehr-its’s parameter table. It composesAccept,Content-TypeandPreferitself, so the node receives a value the operation lists, in its own spelling:Acceptis negotiated as an RFC 9110 §12.5.1 media-range list,*/*or noAcceptsending the first listed type, and one that admits no listed type is refused with the new codemedia-type-not-acceptable(406);Content-Typemust name a listed type, withcharset=utf-8accepted and dropped, or it is refused with the new codemedia-type-unsupported(415);Preferkeeps only the listed preferences and ignores the rest (RFC 7240 §2). Each path identifier parses as the openEHR identifier it names withopenehr-base(version_uidanOBJECT_VERSION_ID, a textuid_based_idaUID_BASED_ID, auuid-format parameter a canonical UUID), and the path then travels byte for byte (N22). Each query value matches its declared kind: a date-time is an extended ISO 8601 date-time read byopenehr-base’sIso8601_date_time(ITS-REST Overview, “Datetime format”). A malformed value is refused with the new codeparameter-value-invalid(400), naming the header, or the parameter by position and declared name, never the value; no node is asked, the ask-all probe included, and the refusal is logged as the security eventparameter-value-refused. A composition update must address the versioned object’s UUID, as ITS-REST declares. The free text the table states no kind for (If-Match,openehr-audit-details,openehr-item-tag,openehr-template-id,openehr-version,openehr-version-item-tag, the item-tagkey,path,tag_key,tag_value,tag_target_path) still travels as sent; whether N33 covers a forwarded client value is recorded on #212. Track 10 gains the identifier in a date-time parameter, anAcceptparameter and a path uid.ferrofed-engineadds thedeclaredmodule, which takesmimefor the media types, andForwardError::Value. - The
Debugoutput of the configuration no longer prints an inline credential (#364). An inlinebearer_tokenorpasswordof a[credentials]section or of a PIX Manager was a plain string, so any{:?}of the configuration, in a log line, a panic message or a test failure, showed it in clear, and a PIX Manager URL or a registry endpoint URL showed the user name and password of its userinfo. Every credential is now held in one of two types of the newferrofed_registry::secretmodule: aSecret, whichDebug,DisplayandSerializerender as***, or aSecretUrl, which they render with the userinfo and the query replaced by***, and as***whole for a libpq key/value connection string. The stored-query store’s PostgreSQLurlandurl_fileandmetrics.otlp_endpointareSecretUrls too. A_filesibling is read straight into the same type, and the text read is zeroed once it is trimmed, so the resolved settings, the composed PIXm configuration and the registry document carry them as well. What reaches a node, a PIX Manager, the store or the collector is unchanged.ferrofed-identityaddsPixmConfigError::BaseUrlfor a Manager base URL that does not parse. - A PIX Manager
urlthat carries a user name or a password is refused byconfig checkand at start, namingpixm.manager[N].urland never quoting it, as an endpoint URL in the registry document already is (#364). Its credentials go in[pixm.manager.credentials]. A configuration that put them in the URL must move them there. - The
Debugoutput ofihe-iti’s types no longer prints a credential (#370).PixmClientandPdqmClientderivedDebug, so a base URL that carried a user name and password showed both in clear, and the HTTP client they hold showed its default headers, anAuthorizationheader among them. Each client now shows its URLs with the userinfo and the query replaced by***, and leaves the HTTP client out. mCSD’sDirectoryOrganizationandDirectoryEndpointshow theirfullUrland the endpointaddressthe same way, and the endpoint leaves out the rest of its resource, whoseheaderlist may hold a credential. The crate carries this redaction itself and depends on nothing in FerroFED. Every redacted value inihe-itinow shows the family’s***, where the identifiers, the matched Patients and the page links showed[REDACTED], and so doferrofed-identity’sPatientRefand the admission probe’s synthetic subject.ihe-itiis 0.0.9. - A URL whose password holds an unencoded
/,?or#, such ashttps://user:pa/ss@host, no longer shows that password in the rendering of aferrofed_registry::secret::SecretUrlor of anihe-ititype (#370). The userinfo was taken to end at the first/,?or#, so no@was found and the text showed as written. Text that parses as a URL is now read as the URL parser reads it, so an@in a path or a query is not userinfo, and text that does not parse is redacted up to its last@.
0.0.6 - 2026-10-03
The v0.0.4, v0.0.5 and v0.0.6 milestones in one release (no v0.0.4 or v0.0.5
tag was cut). The federated query now shapes its answer across nodes the way
a single CDR would: ORDER BY with LIMIT re-applied at the Tier, bounded
OFFSET pages, DISTINCT, decomposable aggregates and opt-in
version-identity dedup, each refused with a reason where it cannot be exact.
Every failure carries its §11.2 status and a stable error code. A request
under {base}/v1/ehr/{ehr_id} is routed to the one node that holds that EHR,
byte-identical, and every answer teaches the gateway which system created
each version. A client names nodes through the FROM ENDPOINT /
ORGANISATION directive or the targeting headers and selects their ENDPOINT
attributes, the registry also loads as FHIR Endpoint and Organization
resources, stored queries are held at the gateway as immutable versions, and
OPTIONS {base}/ declares all of it. The identifier-hygiene property is now
held by the track 10 leakage suite against both nodes: no client-chosen
request id, targeting header or path segment carries a patient identifier to
a node or into the log.
Added
-
The follow-up routing table learns from every answer (#64; §12.2, N21, CP-13). Each version uid in a federated query’s rows, and each one a routed read names or answers with in its
ETag, teaches a route for acreating_system_idthe registry document does not map; one seen at two nodes raises theLearnedCreatingSystemConflictintegrity incident and is routed on by neither. CP-13 is now scored. A follow-up read of a version under{base}/v1/ehr/{ehr_id}/…routes by the pathehr_idin the order of N41, never by the version’screating_system_id(§12a.1, §12.5.1, N22, N42a), and reaches the node byte-identical. -
The federated stored-query registry (#77; §12.7, N44, N33, CP-40, CP-28). With
[stored_queries] pathset, the gateway holds stored queries itself in an embeddedredbfile that survives a restart, andOPTIONS {base}/declaresdefinition.stored_query_registry: true.PUT {base}/v1/definition/query/{name}/{version}stores the AQL on ITS-REST’s semver segment: the name is[{namespace}::]{query-name}, the versionmajor.minor.patch, and the text is analysed as an inline query would be, with each$parameterstanding in for a bound value, and held as its canonical print. A definition that names the patient by a literal is refused400 subject-literal, so no patient identifier is held at rest; a secondPUTof a held name and version is refused409 stored-query-heldand the held text stands, across a restart too.GETon the same path reads the ITS-RESTStoredQueryback, andGET {base}/v1/definition/query/{pattern}lists the versions of every name the pattern starts.POST {base}/v1/query/{name}[/{version}]runs the stored query over every member exactly as if its text were sent inline, with the client’soffset,fetchandquery_parameters, the targeting, completeness and dedup headers and the budget; without a version the highest runs, and a{major}or{major}.{minor}prefix runs the highest it matches. The answer carries ITS-REST’snamenaming the gateway’s definition. New codes:query-name-invalid,query-version-invalid,query-version-required,query-type-unsupported,subject-literal(each400),stored-query-held(409) andstored-query-unknown(404). Without[stored_queries]the definition routes still answer501.openehr-federationis 0.0.30 and addsaql::definition::Definition, the admission analysis of a stored definition. Definition fan-out to the nodes is #78. -
Routing a path
ehr_idin the order of §12.5.1 (#62; §12.5, N41, CP-33). A request to{base}/v1/ehr/{ehr_id}or below it goes to the node the targeting headers name, then the node a resolution binding of the client session names, then the node the newehr_idindex names, and for a read only, the one member an ask-all probe finds. The probe sendsGET {base}/v1/ehr/{ehr_id}to every member at once within the per-node timeout and the overall budget (§11.5); a read of the EHR itself is answered from the owner’s probe answer. A read no member holds is404 no-destination, one two members hold is409 ehr-id-collisionlisting the claimants, and one a member did not answer for is504(node-timeout,node-unreachable) or424(node-refused, and the new codenode-error), naming the member: a member that gave no answer may hold theehr_id, so the owner is unknown. A read that names no node no longer answers501. A write none of the first three steps routes stays400 target-required, and nothing is probed. A step that names two members names none, and no later step picks one of them. The pathehr_idmust be an openEHRHIER_OBJECT_ID, and any other value is400with the new codeehr-id-invalidbefore any routing. The index learns from resolutions and from members’ successful answers, holdsehr_ids and member ids only, in memory, and forgets the least recently usedehr_idpastfederation.ehr_index_capacity(default 100000; 0 is refused). Session bindings answer once client authentication lands (#80); the integrity incident of a collision is #63. -
OPTIONS {base}/, the gateway’s self-description (#73; §7a.2, N30, CP-23). The body validates against the vendoredoptions-root.schema.jsonand is built from the running configuration: the federation id, themajor.minorof the pinned specification,aql.fan_outfrom the node selection, the dedup default, modes and request header, the per-node and overall budgets, all-or-nothing with the best-effort opt-in when it is offered, theOFFSETstrategy (boundedwith itsmax_window, orreject, never a cursor), the decomposable aggregates (an empty list when none), nothing offered in the definition area,localization.on_failure: "closed", the ITS-REST areas, and every registry endpoint with its organisation, membership status, node,system_id, and product and version where the registry holds them. It declares no targeting mechanism, no resolution carrier, no asynchronous queries and no JWKS location, and it needs no patient identifier.OPTIONSon a path under{base}/v1/answers204with the methods served there inAllow, without asking a node, and501where the gateway serves nothing. -
ENDPOINT attributes in rows (#72; §9.2, §9.3, §9.4, N12, N17, N18, CP-35, CP-37). A directed query that selects
p/idorp/endpoint_id,p/organisationorp/organization_id,p/system_idorp/urlthrough theFROM ENDPOINTvariable gets each value in every row, as a string, from the registry entry of the endpoint the row came from: the valuesmeta.federation.endpoints[]reports for it. No node is asked for an attribute, and a query that selects none keeps the row shape of a single CDR. The §9.4 example answers the §9.4 columns and rows, withcolumns[]paths in the ITS-REST form (/id,/system_id,/uid/value). An alias on an attribute keeps it apart from an EHR-derived column of the same name; giving both the same name is refused400with the new refusalendpoint-name-collision, and a path through the variable that is no §9.3 attribute withendpoint-attribute-unknown. UnderDISTINCTthe attributes take part in which rows are equal. An attribute beside an aggregate recombined across endpoints is refused400indecomposable-aggregate, and ordering on an attribute stays refused400endpoint-variable. The501 not-implementedanswer for these queries is gone.openehr-federation0.0.27 adds theattributemodule withEndpointAttribute,ColumnSource::Endpoint(EndpointAttribute),Analysis::attributes,NodeAnswer::with_attributesandMerged::attributes; the engine addsPlan::annotatingandFederatedAnswer::attributes. CP-37 is covered. -
Track 10, the adversarial identifier-leakage suite, judged on node-side wire capture (#90; §16.3 track 10, §5.4, N33, N34, N5, CP-26). One synthetic patient identifier is supplied in each of the four positions the track names: an
EHR_STATUS.subject.external_refpredicate, aPARTY_IDENTIFIED/DV_IDENTIFIERpredicate, aSELECTprojection, and the client’s query string or headers (X-Request-Id,Authorization,Prefer, the targeting and completeness headers, and a free-form header). Each runs on the fan-out, on a query directed at one node, and on the single-node route, and the journal of the capturing proxy in front of each node must hold no occurrence of the identifier, its namespace or any of its fragments in the path, the query string, a header or the dispatched AQL, raw or percent-decoded; a refusal must be the400the specification names, with no node asked. Every dispatched request must locate its node by the node’s ownehr_idalone, and a projected subject column must be the re-injected identifier. The converse check commits aCOMPOSITIONwith the identifier in aDV_IDENTIFIERand finds it byte-identical at the node, compared by bytes and by digest. The gateway’s own log (the request lines, the security events and the panic line) is searched too and must hold none of it. The suite runs against two mock nodes in the normal suite and against the two FerroEHR nodes of the harness behindFERROFED_E2E, and the conformance matrix records track 10 as covered for the gateway’s half; CP-27, the node’s own obligation, stays with the node profile (#93).ferrofed-testkitadds theleakmodule, the search over the proxy journal. -
The
openEHR-federation-endpointandopenEHR-federation-organisationrequest headers, the targeting mechanism beside the AQL (#71; §8.4, §8.4.1, N35, CP-28). Each carries a comma-separated list of registry ids, over one field line or several, and selects nodes exactly as theFROM ENDPOINTandORGANISATIONdirective does: a listed endpoint where the patient is not known isnot-resolved, every other endpoint isexcluded, an identifier the registry does not know, or a header with no identifier in it, is a400(endpoint-unknown,organisation-unknown), and a selection of no endpoint is404 no-destination. A query directed at one endpoint by the header may be an aggregate, as one directed by the AQL may. The headers apply to the federated query and to a request routed to one node, where together they select exactly one endpoint. When the directive and a header, or the two headers, appear in one request, the same node set proceeds and different sets are refused400with the new codetargeting-conflict, whose message names both sets by their registry endpoint ids; the gateway never merges them and never picks one. No query parameter targets anything:?endpoint=and?organisation=have no effect on the federated query, and on a routed request they are refused400 query-parameter-refusedlike any parameter the operation does not declare. Neither header reaches a node: both joinAuthorizationandX-Request-Idin the set a routed request always withholds. -
The registry document in FHIR form (#74; N19, N20, N21, §15.2, CP-13, CP-20). With
registry.format = "fhir",registry.documentnames a FHIR R4 JSONBundleofOrganizationandEndpointresources, the shape an mCSD directory delivers, which loads into the same members and routes identically to the native TOML form. The registry’s ids travel as identifiers in FerroFED’s systems underhttps://ferrofed.eu/fhir/sid/, and an endpoint’sconnectionTypeisopenehr-rest-queryinhttps://ferrofed.eu/fhir/CodeSystem/connection-type.config checkrefuses, naming the resource, an endpoint whoseconnectionTypeishl7-fhir-rest, an informal string or any other code, an endpoint whose managing organisation is missing or not in the Bundle, an endpoint no organisation or two organisations operate, an id that is missing or repeated, and a node its endpoints disagree on.ihe-iti0.0.8 reads the directory content under itsmcsdfeature, resolving references inside the Bundle as FHIR R4 §2.36.4.1 does. -
The
FROM ENDPOINTandORGANISATIONdirective in AQL (#70; §8.1, §8.4.1, N11, N19, N20, CP-6).FROM ENDPOINT p ["node-a-pub", …]asks exactly the listed endpoints, andFROM ORGANISATION ["org-a"]asks every endpoint the registry lists as managed by each organisation. The identifiers are registry ids, never URLs. The directive does not replace resolution: each listed endpoint is still asked about its ownehr_id, a listed one where the patient is not known isnot-resolvedand fails nothing, and every endpoint the directive does not list isexcluded. The directive is parsed byopenehr-query’sfederationfeature and never reaches a node: each node receives the same standard AQL as for the undirected query. An identifier the registry does not know is refused400with the new codesendpoint-unknownandorganisation-unknown, which locate it by its place in the list and never quote it, and an organisation that manages no endpoint answers404 no-destination. A query directed at one endpoint dispatches an aggregate or a function AQL does not define unchanged (N14, §11.6.3). The directive’s variable may only be selected, as an ENDPOINT attribute, which #72 adds to the rows, and any other use is refused400with the new refusalendpoint-variable.openehr-federationaddsaql::directive::FacadeQuery,Context::with_targeting,Analysis::sourcesandColumnSource::Endpoint. Golden case 02 now passes, and CP-6 is covered. The endpoint and organisation headers of §8.4 are #71. -
The registry maps every observed
creating_system_id(#67; §12.2, N21, the mapping half of CP-13): a[[creating_system]]entry in the registry document maps acreating_system_idthat is no member’s ownsystem_idto an endpoint, and a member’s ownsystem_idmaps to that member without one. The document is refused, byconfig checktoo and naming thecreating_system_id, when a mapping names an undeclared endpoint, maps one id twice (ASCII case aside), or maps a member’s ownsystem_id.ferrofed-registryadds the learned map: the first sighting of an id the document does not route learns a read route to the endpoint it was seen at, and a learned mapping never overrides the document. A sighting at a second node, or a learned mapping the document contradicts, withdraws it and raises an integrity incident, logged atERRORwith a stable kind and routing ids only. An id nothing routes is a typed miss, never a default endpoint. The follow-up read routing that consumes the table is #64. -
Opt-in version-identity dedup (#56; §10, N15, N36, CP-9, CP-29): a request that sends
openEHR-federation-dedup: version-identitygets one copy of a version held at several endpoints, keyed on the fullOBJECT_VERSION_IDof the row’sCOMPOSITION, elseVERSION, uid asopenehr-basereads it. The copy kept is the one from the endpoint whose registrysystem_idis the version’screating_system_id, else the one from the lowest endpoint id. Two versions of one object are two rows, and a row with no version uid is never suppressed.meta.federation.deduprecords the mode on every answer,noneand failing424and504envelopes included, and beside the rowssuppressed_rowsandsuppressed_endpoints[], counted beforeDISTINCT,OFFSETandLIMIT. Every node is asked the version uid, as a hidden column when the client does not select it (underDISTINCTonly a selected uid is the key), and a query with aLIMITand noORDER BYis ordered on it. Under the mode a tie on theORDER BYkeys breaks on the uid beforeendpoint_id, which keeps a per-nodeLIMIT n(ork + nfor a page) exact after suppression (§11.6.1). The default staysnone(§10.1), whichnonestates explicitly; any other value, or a repeated header, is400dedup-invalid. A node whose version uid is not anOBJECT_VERSION_IDisnode-error, and the query fails424under all-or-nothing. A recombined aggregate under the mode is400indecomposable-aggregate(§11.6.3).openehr-federation0.0.20 addsdedup::DedupMode,aql::Context::with_dedup,order::ResultOrder::with_version_key,merge::NodeAnswer::with_system_id,merge::Suppressed,merge::Disagreement::VersionIdandaql::refusal::Indecomposable::Dedup. -
SELECT DISTINCTat the Tier (#55; N13, CP-8, CP-32): a row two nodes return is answered once, compared on the columns the client selected under the Tier comparator (2and2.0are one value, two spellings of one instant are two), and the duplicates are removed before theLIMITand theOFFSET(AQL 1.1.0 §LIMIT), so a duplicate no longer takes two slots of aLIMIT nanswer or of a boundedOFFSETpage. The copy kept is the first underORDER BY, thenendpoint_id. A re-injected subject column and a column the gateway adds never make two rows distinct. Each endpoint’srow_countstays what it contributed (§9.5). A node that returned its fullLIMITwith two rows the Tier holds equal isnode-error, because a distinct row can lie past its cut, and the query fails424under all-or-nothing.openehr-federation0.0.18 addsorder::ResultOrder::with_distinctanddistinct, andmerge::Disagreement::Distinct. -
Single-node routing for the EHR area (#61; §7a.1, §7a.3, §9.6, §11.2, N22, N31, N33, CP-24): every ITS-REST operation under
{base}/v1/ehr/{ehr_id}, named byopenehr-its’sroutes::lookup, is forwarded once to the node theopenEHR-federation-endpointheader names, throughClient::forward. The body reaches the node byte for byte, aDV_IDENTIFIERin a committedCOMPOSITIONincluded, and the node’s status, body,LocationandETagcome back unmodified, a404or500included. Every routed answer names the acting endpoint and its node’ssystem_idinopenEHR-federation-endpointandopenEHR-federation-system-id. Only the request headers and query parameters the matched ITS-REST operation declares travel, read fromopenehr-its’s per-operation parameter table (FerroEHR #3530):If-Matchreaches the node on aPUTand never on aGET. The client’sAuthorizationandX-Request-Idnever travel, and the node receives the request’s mintedX-Request-Id. An undeclared query parameter, andsubject_idorsubject_namespaceanywhere, is refused before dispatch. New codes:target-required(a write that names no node,400),endpoint-unknownandendpoint-several(400),query-parameter-refused(400),node-timeoutandnode-unreachable(504) andnode-refused(424, a node that refused the gateway’s onward credentials). A read that names no node still answers501until #62 routes it. A node’s3xxis its answer, passed on with itsLocationunmodified, and no request is re-sent to a host outside the registry. -
Decomposable aggregates (#54; §11.6.3, N14, N39, CP-10, CP-32): an undirected
COUNT,SUM,MIN,MAXorAVGis sent to every node and answered with one recombined row in the client’s columns, never one row per node. Counts and sums add exactly, reals in decimal arithmetic;MINandMAXare re-applied over numbers and complete date-times;AVGis asked of each node as itsSUMandCOUNT. A node value the recombination cannot use isnode-error, so the query fails424with no value.DISTINCT,COUNT(DISTINCT …)and a plain column beside an aggregate are refused400(indecomposable-aggregate), and apartialrequest for a recombined aggregate is refused400(partial-aggregate). The new[federation] decomposable_aggregateslist (all five by default,[]for none) declares the functions, and an undeclared one is stillundirected-aggregate. A directed single-node aggregate is sent unchanged.openehr-federation0.0.13 adds theaggregatemodule,aql::Context::with_decomposable_aggregatesandmerge::combine, and takesrust_decimal1.43.0, already in the tree throughopenehr-rm. -
The full per-endpoint report (#49; §9.5, §11.1, N16, N40, CP-11, CP-31): every registry member appears in
meta.federation.endpoints[], an endpoint a directed request did not name asexcludedwith the reason, which stays out of scope, so it neither clearscompletenor fails the query. A[[node]]of the registry document may record its CDRproductandversion, which the report carries only from there and omits when the registry does not say.latency_msappears exactly for the endpoints the gateway dispatched to, androw_countcounts what each node contributed before any federation-levelDISTINCT, dedup orLIMIT. -
Completeness (#50; §11.2 to §11.4, N6, N37, CP-30): all-or-nothing stays the default, and a request opts into best-effort with
openEHR-federation-completeness: partial. Under best-effort the gateway answers200with the rows of the nodes that answered, names every other node with its status, and setscomplete: false. A cross-reference that cannot answer is reported there and is not a424.allis accepted explicitly. Any other value, a repeated header, orpartialwherefederation.best_effort = falsewithdraws the mode is a400that asks no node and never quotes the value. Thenot-resolvedandconsent-deniedcarve-outs and the scope rule hold in both modes.completeis always derived from the statuses. -
Timeouts (#51; §11.5, N38, CP-31; RFC 7240): a client shortens the budget with
Prefer: wait=<seconds>, and a longer wait leaves the configured budget in force. The effective budget is the one reported inmeta.federation.timeout, and a wait that set it is echoed inPreference-Applied. Only the firstwaitcounts; a malformed one is ignored and never refused.wait=0asks no node and reports each onetime-out. The overall budget runs from the request’s arrival, so the patient resolution and the fan-out share it and the gateway answers inside it, and abandoning one node never aborts a request in flight to another. -
ORDER BYwithLIMITre-applied at the Tier, with a deterministic tie-break (#52; §11.6.1, N9, N13, N39, CP-8, CP-32). Every node is sent the client’sLIMIT nunchanged, with the row’s uid appended as the lastORDER BYkey; anORDER BYpath the query does not select travels as a hidden column the client never sees. The gateway merges the node answers under one total order (null greatest, numbers exactly, complete date-times by instant, strings by code point,DV_ORDEREDvalues through the openEHR RM’s own comparison), breaks ties on the endpoint id and then the uid, and cuts the result atn. A node that returnednrows out of that order, or more thann, is reportednode-error, so the query fails424under all-or-nothing.TOP nis read asLIMIT n;TOP n BACKWARD, aTOPbeside aLIMITclause, and aDISTINCTquery ordered on a path it does not select are refused400. A query withLIMITand noORDER BYnow returns at mostnrows across all nodes. -
OFFSETpaging across a fan-out (#53; §11.6.2, N9, N39, CP-32).OFFSETnever reaches a node. Under the defaultfederation.offset_strategy = "bounded",ORDER BY … LIMIT n OFFSET kasks each node forLIMIT k + n, checks each node’s visible order as forLIMIT n, merges in the federation order and returns rowsktok + n. A page whosek + nis pastfederation.max_offset_window(1000 rows per node by default; 0 refuses to boot), anOFFSETwith noLIMIT, and anOFFSETwith noORDER BYare refused400, the first naming the bound.offset_strategy = "reject"refuses everyOFFSETpast zero400. The ITS-RESToffsetandfetchmembers follow the same strategy. -
The
comment-styleguard checks citations (#173). It fails on a Rust comment, doc comment or lintreasonthat citesdocs/architecture.md, a path under.claude/or a rule file by name, or that names a decision-register entry such asdecision A17. It applies the same check to the full-line#comments of the shell scripts underscripts/and of everyCargo.toml.comment-style.sh --self-testproves each refused form and its near misses, and CI runs it before the full-tree pass. Every comment that cited one of these now cites the specification section it rests on, or says that no specification governs it. -
The error vocabulary (#57; §11.2, N32, N36, N37, N42, CP-12, CP-30): every failure the gateway reports on its own behalf answers the ITS-REST
Errorbody with a stablecodeand therequest_id, and its status follows one table. A refused query is a400named by its refusal (not-aql,unreducible,offset-unsupported, and the rest), an unknown path a404not-found, an unexposed ITS-REST area a501not-implemented, and the gateway’s own fault a500internal, and a request with no member in scope a404no-destination;ehr-id-collisionandcontrolling-system-unreachable(409) are fixed for follow-up routing. The codes are API and are only ever added. The book’s “Errors and status codes” page lists them all, and a test holds the page to the gateway’s table. -
The
versionsguard holds two hand-typed version facts to their sources (#181). The landing page’s release note and status panel must name the newest## [x.y.z]release ofCHANGELOG.md, so a release cut that forgets the page fails. Each specification row ofdocs/VERSIONS.mdmust name a crate constant (FEDERATION_SPEC,ITS_REST,AQL) that carries the version the row pins.versions.sh --self-testproves both checks, and CI runs it before the full pass. -
Generated conformance badges on the README (#175), as shields.io endpoint files under
conformance/badges/: the Gateway points of §17 covered out of the Gateway total, the Node and Operator point counts, each labelled with the pinned specification version and linked to the book’s conformance page, and the AQL golden cases passed out of the vendored corpus, linked to its pass list.scripts/conformance/matrix.sh --badges-writewrites the files and the README block betweenconformance:beginandconformance:end, and theconformance-matrixguard fails when either drifts from the matrix or the pass list. The golden test fails when a case inconformance/aql-golden/pass-list.txtstops passing or an unlisted case passes, and rewrites the list whenFERROFED_CONFORMANCE_UPDATEis1. Case 02 (FROM ENDPOINT) is refused until #70 and is not counted as passing. The static badge row gains the image-pulls badge forghcr.io/ferrohealth/ferrofed. -
The
versionsguard holds the book’s “Pinned versions” page todocs/VERSIONS.md(#198). Each row of its pin table names its matrix rows by their exact names, and every pin it states, a version, a package, anedition 2024or an abbreviatedcommit, must be the one those rows pin; a pin the guard cannot read fails rather than passing unread. The page now lists all fiveopenehr-*crates, the vendored PIXm and PDQm packages and the specification’s source commit as rows of their own.versions.sh --self-testproves each drift. -
Tests that pin the two optional facilities FerroFED does not offer (#59, #60; §11.6.4, §11.7, CP-31, CP-32). A query sent with
Prefer: respond-async, alone or besidePrefer: wait, gets the ordinary synchronous answer under the same budget: never a202or aContent-Location, andPreference-Appliednever namesrespond-async(RFC 7240 §2, §3). A boundedOFFSETpage carries no cursor handle or expiry inmeta.federationand runs the fan-out on every request.
Changed
-
A gateway with
registry.documentset now refuses to boot withoutfederation.id, the federation’s own identifier thatOPTIONS {base}/names (#73; §7a.2, N30). Addid = "<your federation>"to[federation]; an empty id is refused. -
The
openehr-*family moves from 0.0.78 to 0.0.79 (#234). Theopenehr-rmattribute model now holds the BASE primitives, theOrderedmarker and the target class of a reference-typed attribute (FerroEHR #3537), which the AQL rewrite reads to decide which selected paths a node can order underDISTINCT.openehr-federationis 0.0.28. -
The
openehr-*family moves from 0.0.77 to 0.0.78 (#237), and an onward credential is checked at configuration load by the node client’s ownAuthorizationcomposition (FerroEHR #3535), for each endpoint and each PIX Manager alike. A bearer token must now be theb64tokenof RFC 6750 §2.1 (letters, digits,-,.,_,~,+and/, then any=padding), so a token holding a space, a quote or any other character outside that set is refused byconfig checkand at boot with exit code 78, naming its key and never its value; it used to pass and then fail at the node. The basic rules of RFC 7617 §2 are unchanged.openehr-federationis 0.0.26. -
The
openehr-*family moves from 0.0.76 to 0.0.77 (#195).openehr-querynow classifies every AQL function call (FerroEHR #3529): a string, numeric, or date and time function of AQL 1.1.0 is a built-in, and any other name is not. The rewrite reads that classification where it kept a list of the 16 function names of its own, so an undirected query that calls a function AQL does not define is refused exactly as before (undefined-function; §11.6.3, N14), and theCONCAT,CONCAT_WSandSUBSTRINGfolding and theDISTINCTcut check read the crate’s classification too. Everyopenehr-itsclient is built with redirects off (FerroEHR #3531), so a node’s3xxanswer is never followed: the node isnode-error. -
Incompleteness is carried by
meta.federation.completealone, and the gateway emits no FHIROperationOutcome(#58; §9.1, §11.4, N17, CP-12). §11.4, CP-12 and track 4 ask for the resource only for a FHIR-facing consumer, and the answer is an ITS-RESTRESULT_SETwhose federation additions live undermeta.federation. The conformance matrix gives CP-12 that reason beside its scored status codes, a test asserts that a best-effort200and an all-or-nothing424each carrycomplete: false, validate againstfederated-result-set.schema.jsonand hold noOperationOutcome, and the client contract in the book says so. Where the resource would travel for a gateway with FHIR-facing consumers is recorded as an upstream report (#204). -
The conformance record’s loose ends (#196). The
comment-stylecitation checks read the conformance tables underconformance/: their#comment lines and theirreasoncolumn, which the book renders. The tables now cite §16.2, §16.3, §16.4 and §17 where they cited an internal file or a decision-register entry, or say that no specification governs the choice. The CP-10 and CP-32 rows name #187. The book page links the specification site at the versiondocs/VERSIONS.mdpins, with no second copy of it inscripts/conformance/matrix.sh.openehr-federation0.0.16 marks its local list of AQL function names for removal (#195). -
The
comment-stylecitation checks cover the rest of the tree (#178): the full-line comments of the workflow and composite-action YAML under.github/, theechoandprintftext of a workflowrun:block,clippy.tomlwith itsreasonstrings, and the quickstart TOML underdocker/. The per-edit hook runs the guard on every file kind CI checks. Every comment, lint reason and printed line there that cited an internal file or a decision-register entry now cites the specification section or official documentation it rests on, or says that no specification governs it. The six vendor scripts no longer write an internal path into theirPROVENANCE.md. -
The
comment-stylecitation checks refuse a citation of any markdown file underdocs/outside the vendoreddocs/specs/tree (#184): a path that opens a parenthetical or is followed bysectionor§. Namingdocs/VERSIONS.mdas the file a script or test reads still passes. The checks also read every YAMLdescription:scalar and the trailing#comment after a YAML or TOML value, outside quoted strings and block scalars, and the guard runs the same under mawk. The citations this catches cite the GitHub documentation or say that no specification governs them, among themfuzz.yml, thesetup-rustaction description, the version and corpus scripts and the CI rule. The crate manifests’ comment on the 0.0.0 name reservation changes with them, soopenehr-federationmoves to 0.0.15,ihe-itito 0.0.7 andnl-generic-functionsto 0.0.4 with no change to their code. -
The
comment-stylecitation checks also refuse a citation of aREADME.mdof the tree, at the root or in a member, outside the vendoreddocs/specs/andvendor/trees (#198), in the same citation form. Naming a README as a file a script reads still passes.fuzz.ymlcites the libFuzzer documentation for how a crashing input is kept, and the scripts that cited a README no longer do. -
The release lane fails a tag whose tree has no root
Cargo.toml(#198), with a message saying the tag cannot be checked against the workspace version. It used to skip that check. -
The release lane has one path (#201): the binaries and the image build for every tag
planaccepts, andfinalize-releasepublishes only when every build leg succeeded. The branches for a tag with no workspace, whichplannow refuses, are gone. The book’s claims page links to the pinned-versions page for theopenehr-*pin instead of restating the number, anddocs/ci-cd.mdnames all four self-tests of thetracker-helpersjob,rel.shamong them. -
scripts/gh/fields.shchecks its arguments before anyghcall (#198). No argument, an unknown command, a wrong operand count or--helpprints the usage to stderr and exits 2, with no network call and no token needed.fields.sh --self-testproves it against a stub that records every call. -
The per-edit
comment-stylehook also reads theconformance/*.tsvtables (#198), as CI does, and stays a quiet pass for any other file. Conformance track 5 (Dedup + DISTINCT) names #187 beside its issues, as its CP-10 and CP-32 rows do, and the book’s conformance page is re-rendered. -
Error bodies (#57): the gateway’s own refusals (
404,501, a caught panic’s500) answer the ITS-RESTErrorshape withcodeandrequest_id, where they named the code in anerrormember, and the codes are kebab-case (not-found,not-implemented). No error body quotes the query, a parameter value or a header value (§5.4.3); a424or504under all-or-nothing stays the §11.4 result set and echoes the client’s ownq(N17). A node row shorter than the dispatched query selects is now that node’snode-error(424, or reported underpartial), where the whole query answered502(§11.1, §11.2). -
The
openehr-*family moves from 0.0.74 to 0.0.76 (#57). 0.0.76 keeps the members an open ITS-REST schema admits in anadditional_propertiesmap (FerroEHR #3526), so every error body is the generated ITS-RESTErrorwithcodeandrequest_idin that map, and no FerroFED-side error type remains. The generated request and result-set types carry the same map. -
The site, the README and the book describe the released gateway (#176). The landing page opens with the FerroFED mark and the current release, and shows the quickstart, the release binaries and the image
ghcr.io/ferrohealth/ferrofed, with what each specification property has in v0.0.3 and what is planned. The README’s install section names the release assets and their verification, and its quickstart runs the published image withdocker compose up --wait. The book’s introduction, claims, deployment, container and contribution pages, the governance and contribution guides, and the working rules no longer describe a design phase or a project with nothing to install. -
The quickstart and the end-to-end harness run two FerroEHR nodes (#155, decision A44). EHRbase left both, because it refuses a
.inPARTY_REF.namespace, which openEHR BASE admits (#118). Incompose.yamlthe services areferroehr-aandferroehr-b, each on its own database and with its ownsystem_id(node-a.quickstart.local,node-b.quickstart.local, which the quickstart registry declares), and node B moved from port 8091 to 8082 (FERROEHR_B_PORT; node A’sFERROEHR_PORTis nowFERROEHR_A_PORT). Every end-to-end case seeds the patient’s subject on both nodes, and thee2e (containers)CI job now runs the server’s container tests as well as the testkit’s. -
The tracker records the kind, the urgency and the size of an issue in GitHub’s native issue type (Bug, Feature, Task) and the organisation’s Priority and Effort fields (#154). The
bug,enhancementandP0toP3labels are retired, the bug and feature forms set the issue type, andscripts/gh/fields.shandscripts/gh/migrate-fields.shcarry the model and the migration.
Fixed
- The local crate-bump hook judges a commit or a push against
origin/main, the base CI uses, and leaves finding the merge base toscripts/checks/crate-version-guard.sh(#243). A branch that bumps a crate to the version main already took is now blocked locally, as CI refuses it, where it used to pass. The book’s checks page lists thetracker-helpersjob, the self-tests of thescripts/ghhelpers, among the tier-1 checks. - The outbound gate no longer reads the host and port of a registry endpoint URL (#232; §5.4.1, N33, CP-26). An endpoint whose host or port contained a withheld identifier, such as a short local identifier that matched a port number, refused every query for that patient. The authority comes from the operator’s registry and never from a request, and §5.4.1 names the request path, the query string and the headers. The gate still reads the path, the query and the fragment, raw and percent-decoded, with the AQL text, the paging and the headers the gateway adds.
- A
SELECT DISTINCTquery withORDER BYandLIMIT(or a boundedOFFSETpage) that selects a path whose value AQL defines no order for is refused400(incomparable-distinct-key), where a node could cut among distinct rows differently on each repeat (#234; §11.6.1, N13, CP-8, CP-32). AQL orders on data “available to primitives andOrderedtypes” (AQL master03-syntax §ORDER BY), so a whole RM object (SELECT DISTINCT c), a data value that is not aDV_ORDERED(c/name, aDV_TEXT), theDATA_VALUEof anELEMENT, a collection and a path the RM does not resolve are not keys. The rewrite decides it by walking the path throughopenehr-rm’s static model from the classFROMbinds, asking the model’sis_primitiveandconforms_to_orderedlookups and following a reference to the target class the model names (FerroEHR #3537). UnderDISTINCTonly the selected paths to a primitive or aDV_ORDEREDare pushed asORDER BYkeys, so the same query without aLIMITis answered and the other paths are compared at the Tier alone. A path to a primitive value, such asc/name/valueor…/value/magnitude, and an ordered data value, such asc/context/start_time, are pinned as before. Theaqlfeature ofopenehr-federationnow depends onopenehr-rm; the crate is 0.0.28 and addsRefusal::IncomparableDistinctKey. - A query with
ORDER BYandLIMIT(orTOP, thefetchmember, or a boundedOFFSETpage) ordered on a path whose value AQL defines no order for is refused400with the new codeincomparable-order-key, where it used to be sent to every node and re-ordered at the Tier unchecked (#242; §11.6.1, N39, CP-32). AQL ordering “assumes that data identified by the path … are comparable”, the primitives andOrderedtypes (AQL master03-syntax §ORDER BY), and §11.6.1 holds the federated firstnrows in each node’s firstnonly “under a total order”, so a node’s cut onORDER BY c/name LIMIT 10, aDV_TEXT, could drop a row of the answer. The rewrite uses the comparability rule of #234, and the refusal points at the key by position. The same order with noLIMITis answered: every row reaches the Tier, which orders them all under its own total order, the same on every repeat. UnderDISTINCTthe refusal staysincomparable-distinct-key.openehr-federationis 0.0.29 and addsRefusal::IncomparableOrderKey. - Under version-identity dedup,
SELECT DISTINCTcompares the version uid without regard to case, as the dedup and the Tier order already do (#234; §10.2, CP-8, CP-9; BASEmaster05-identification_package.adoc§“Composite Identifiers and Case”; AQL master03-syntax §DISTINCT). Two rows of one node whose uids differ only in case are one distinct row, the first in the Tier order, with its text as the node sent it; a node cut at itsLIMITthat returns both is refused asnode-error. Outside dedup a uid compares as sent, in step with the Tier order. - A
SELECT DISTINCTquery withORDER BYandLIMIT(or a boundedOFFSETpage) whose selected function column the selected paths do not fix is refused400(unordered-distinct-cut), where a node could cut among distinct rows tied on every path differently on each repeat (#210; §11.6.1, CP-8, CP-32). AQL orders only on paths (AQL master03-syntax §ORDER BY), and underDISTINCTthe gateway adds no column, so the selected paths are a node’s only keys. A call to a single-row function AQL defines whose arguments are literals, parameters and selected paths, such asLENGTH(c/name/value)besidec/name/value, is answered as before; a call that reads a path the query does not select, a clock function such asNOW(), andTERMINOLOGYare refused. The same query without aLIMITis answered.openehr-federation0.0.22 addsRefusal::UnorderedDistinctCut. - Under version-identity dedup, the Tier orders the version uid without
regard to case (#229; §10.2, §11.6.1, §11.6.2, CP-9, CP-32; BASE
master05-identification_package.adoc§“Composite Identifiers and Case”). Two copies of one version whose ids differ only in case rank as one in the merged order and in the check of each node’s order, as anORDER BYkey and as the tie-break, so anORDER BYwithLIMITand a page under dedup stay exact for them. Every row keeps its uid as its node sent it. The comparison isopenehr-base’scomposite_id_key. A node cut at itsLIMITthat orders uids byte for byte and returns them in an order the Tier’s disagrees with is refused asnode-error, since the Tier cannot change how a node sorts. Outside dedup the uid orders as before, by code point.openehr-federationis 0.0.23. - An onward credential the
Authorizationheader cannot carry is refused at configuration load, byconfig checkand at boot with exit code 78, in place of failing every query to its endpoint with a500(#231). A bearer token, inline or read frombearer_token_file, is checked as theBearer <token>value the node client sends, by the samehttpparse the client applies, and a basic user or password holding a control character, or a basic user holding a colon, is refused as RFC 7617 §2 requires. The refusal names the key the value came from (credentials.<endpoint>.bearer_token, its_filesibling,.useror.password) and never the value. A PIX Manager’s[pixm.manager.credentials]is held to the same rule. - Version-identity dedup compares identifiers without regard to case (#225;
§10.2, CP-9; BASE
master05-identification_package.adoc§“Composite Identifiers and Case”). Two copies whose version ids differ only in case are one version, and the node whosesystem_iddiffers from the version’screating_system_idonly in case keeps the originating copy. Before, both were compared byte for byte. A kept row still comes back as its node sent it. The comparison isopenehr-base’scomposite_ids_equalandcomposite_id_key, which the registry identifiers already use, and the session-scoped resolution bindings now key onEhrIdin place of their own case fold.openehr-federationis 0.0.21. - A patient query is no longer refused at random when its withheld
identifier is a short hexadecimal value that occurs inside the minted
X-Request-Id(#227; §5.4.1, N33, CP-26). The outbound gate reads the AQL text, the paging, the URL and every other header the gateway adds, and skips the minted id, whichOutboundId::mintmakes from no client input. A request a panic, the request timeout or the body ceiling answers now has its request-log line, with the status it answered (500,408,413) under the gateway’s id. The “the federated query failed” line carries the samerequest_idas the request line. The hygiene assertions of the tests compare the raw bytes a mock node received, so a header value or a body that is not UTF-8 is searched too, and the CP-26 row of the conformance matrix names #217. - CP-29 is
plannedagain in the conformance matrix and the gateway badge (#221): only its visibility half, the dedup record of §10.2 and §10.3, is built, and its write-routing and409half is #66. The dedup tests carry CP-9 alone.scripts/checks/crate-version-guard.shrun without its base ref prints its usage on stderr and exits 2. - The book’s “What FerroFED claims” page lists decomposable aggregates (#54)
among what has merged since v0.0.3, and no longer as planned; de-duplication
is what remains planned for v0.0.4 (#211). The
mergemodule doc ofopenehr-federationis wrapped like the rest of the crate’s docs, andopenehr-federationmoves to 0.0.19 with no change to its code. - An
ORDER BYwithLIMITquery whose rows carry no uid, such as one that selects fromEHRalone, returns the same rows on every repeat (#157; §11.6.1, §11.6.2, N39, CP-32). Each node is asked to order on a row key after the client’s keys: the uid of the firstCOMPOSITIONorVERSIONas before, else<ehr>/ehr_id/value, else the uid of anEHR_STATUSorEHR_ACCESS; the key travels as a hidden column and never reachescolumns[]. A node cut atLIMIT n, or at theLIMIT k + nof a boundedOFFSETpage, then keeps the same tied rows each time, so two pages agree on the rows tied across their edge. A patient query, scoped to oneehr_idper node, gets noEHRkey;FOLDERis never a key; aDISTINCTquery keeps its selected columns as the tie-break and gains no column. A query with no row key is still answered: the Tier orders the rows it receives, and which tied rows a node returns is the node’s.openehr-federation0.0.17 carries the change. - A query that calls a function AQL 1.1.0 does not define, such as a
product-specific
MEDIAN(x), inSELECTorWHEREis refused400(undefined-function) when it would reach more than one node, where it was sent to every node and answered one row per node (#187; §11.6.3, N14, N39, CP-10, CP-32). The gateway cannot tell whether such a function aggregates, and per-node aggregate rows are the answer §11.6.3 forbids. Directed to one endpoint, the query is sent unchanged. The single-row functions AQL defines (AQL §Functions: the string, numeric, and date and time functions, andTERMINOLOGY) are still sent to every node as written, and the five aggregates keep the decomposition rules.openehr-federation0.0.14 addsaql::refusal::Refusal::UndefinedFunction. - A query that uses
TOPtogether with aLIMITclause is refused400(top-with-limit), whether or not the two counts agree, because AQL forbids the pair (#162; AQL §TOP, §LIMIT). ATOPquery sent with the ITS-RESTfetchmember is refused400(top-with-fetch), because ITS-REST saysfetch“cannot be combined with AQL-top”.TOP nalone is still read asLIMIT n. - A federated query that leaves no registry member in scope, because every
endpoint is
excluded(suspended by the operator, for example), answers404and asks no node, where it answered200with emptyrows(#166; §11.1, §11.2, §11.3). A candidate set that localization left empty is not that case: every member isnot-localized, and the answer stays200withcomplete: true(§14.1). A patient who isnot-resolvedat every member in scope still answers200. - A gateway that federates refuses to boot, and
config checkrefuses the file, unlessserver.request_timeout_msexceedsfederation.overall_timeout_msby more than one second, the margin kept for combining the answers (#167; §11.5). The refusal names both keys. Before, a request timeout between the two let the server’s408, with no body, cut a slow fan-out instead of the504that carriesmeta.federation. - The documentation describes the gateway that exists (#163). The
openehr-federationREADME names both subject carriers as resolution input. The book’s client contract and configuration pages namePOST /v1/query/aqlas served, and the_filesecrets paragraph sits under the configuration file it describes. Code and doc comments cite the specification sections a decision rests on, or say that no specification governs it. - The landing page no longer scrolls sideways on a phone narrower than about 380 px (#182). The audience cards and the other card grids take a column minimum that shrinks to the page width, so a 320 px viewport shows one full-width column in the light and dark themes.
llms.txtdescribes follow-up routing to the owning CDR as planned for v0.0.5, where it described it as built (#180). The CI log, the guard scripts, the analyzer configuration and theresearchlabel no longer describe the repository as being in a design phase.scripts/gh/labels.shprints its usage and exits2on any argument it does not know,--helpincluded, before it callsgh(#180). It ignored such an argument and wrote the label taxonomy to the repository. Its--self-testproves the refusal.- The
ferrofed-enginecrate description names what it holds (dispatch, fan-out, the budgets, the completeness decision and the outbound identifier-hygiene gate) and lists follow-up routing on the creating system id as planned, where it described routing as built (#191). The workflows, the release checklist, the version pages of the book and the repository, and the working rules no longer describe the Cargo workspace as not yet existing, and the book names theopenehr-*pin as 0.0.76 and the PIXm and PDQm packages as vendored. scripts/gh/rel.shprints its usage and exits2before it callsghon a usage error: no argument, an unknown command, a wrong operand count, a flag other than--replace, or--help(#191). It exited1with no argument,0on--help, and resolved the repository throughghfirst. Its new--self-test, run by the CItracker-helpersjob, proves each write’s endpoint and each refusal.
Security
- The ask-all probe no longer sends a path
ehr_idthat is not a bare UUID to every member (#259; §5.4.1, §12.5.1, N33, CP-26). The pathehr_idwas checked only as aHIER_OBJECT_ID, a form that admits a bare national number as a one-arc ISO OID, so a patient identifier in theehr_idslot of a read nothing routed was broadcast to the whole federation. A read whoseehr_idis an ISO OID, an internet id or a UUID with an extension, and that no targeting header, resolution binding orehr_idindex entry routes, is now refused with the new codeprobe-requires-uuid(400), and no member is asked. The sameehr_idwith the endpoint header is forwarded to the named node alone, and the index then routes it, since a member may mintehr_ids in a scheme other than UUID (§12b.2, N42a). The refusal is logged as the security eventehr-id-probe-refused, which never names theehr_id. Track 10 gains the identifier in theehr_idslot.ferrofed-registryaddsEhrId::is_uuid, andferrofed-engine’sprobe::Probecarries aProbedEhrId, which only a bare UUID converts to, in place of the client’s path segment. - A panic no longer prints its message to stderr (#260; §5.4.3, CP-26).
ferrofed serveused Rust’s default panic hook, which printed the message, and any value formatted into it, to stderr, part of a deployment’s log stream. The binary now installs its own hook before it serves. The hook writes one fixed line throughtracing, “a thread panicked”, with the source location and, inside a request, the gateway’srequest_id, and never the message. It writes nothing to stderr. A panicking handler still answers500, and its “the request handler panicked” line is unchanged. Track 10’s panic case now runs in a child process and asserts that its stderr is empty.ferrofed-serveraddspanic::install_hookandrequest_id::serving. - A request routed to one node no longer logs the client’s
X-Request-Id(#62; §5.4.1, §5.4.3, N33, CP-26). The security events of a refused query parameter or a withheld request, and the failure events of the routed path and the ask-all probe, named the client’s own id, free text that can carry a patient identifier. Every event of the routed path now names the gateway’s own id, the one its request line records; the client’s id stays in the response and its error body only. - A client’s
X-Request-Idno longer reaches any node (#217; §5.4.1, N33, CP-26). A legal client value was sent to every node of the fan-out as it came, so a patient identifier written into it passed the outbound gate, which checks only the identifiers the gateway resolved on. The gateway now mints its own id, a version 4 UUID, for every request and sends only that, the same id to every node of one request. The response header and the error bodies still name the client’s own id. The request log, the security events and the panic line record the gateway’s id and never the client’s, with aclient_namedflag on the request line saying whether the client sent one; a client that sends none gets the gateway’s id back. The book lists every header a node request carries and where its value comes from.ferrofed-engineaddsoutbound_id::OutboundId, which onlyOutboundId::mintmakes, andDispatchOptions::with_request_idandfanout::fan_outtake one in place of a string.
0.0.3 - 2026-10-02
The first two federated milestones in one release (v0.0.2 and v0.0.3; no
v0.0.2 tag was cut). POST {base}/v1/query/aql answers one ITS-REST
RESULT_SET over two openEHR CDRs, with the patient resolved outside AQL
through a PIXm PIX Manager (or the development cross-reference) on either
patient carrier, and no directly identifying identifier sent to a node: the
rewrite refuses it, and an outbound gate re-checks every request before it
leaves. Also the Cargo workspace and its spec-named crates, the PIXm and PDQm
clients, the container and compose quickstart, the end-to-end harness with a
PIX Manager, the fuzz lane, and the release lane at SLSA Build Level 3,
rehearsed as v0.0.2-rc.1.
Added
- The PDQm ITI-78 patient demographics query client in
ihe-iti, featurepdqm(#119; PDQm 3.2.0, ITI TF-2 §2:3.78).pdqm::PdqmClientposts aPatientQueryto[base]/Patient/_searchas a form body, so no URL carries a demographic value, and reads thesearchsetinto the matching Patients (FHIR R4Patientfromfhir-typesresources), thetotal, each match’s score andmatch-grade, theOperationOutcomewarnings and thenextpage link, which it follows only on the Supplier’s origin. A404with anot-foundissue for a query that names an identifier domain is the profile’s unrecognised-domain answer; every other failure is a typed error that carries no value, URL or Supplier text. The ITI-78 artefacts of the PDQm 3.2.0 package are vendored underdocs/specs/ihe-pdqm/byscripts/vendor/ihe-pdqm.sh. TheOperationOutcomeissue type moves toihe_iti::outcome::IssueType, shared by PIXm and PDQm;ihe-itiis 0.0.5. - The identity-lifecycle hook for track 8 (#48):
ResolutionBindings::identity_changeddrops every resolution binding a merge or split at the identity source could have made stale, byehr_idin every session or all of them for an unscoped change, andFederation::identity_changedis the entry point a PMIR subscription calls. The binding lifetime,federation.binding_ttl_ms, is the bound until one exists. Track 8 stays provisional and is not claimed. - The ask-all node selection of a deployment with no localizer (#46; §4.3
Variant B, N4, N10).
federation.node_selection = "ask-all"declares it, and a gateway that federates refuses to boot without the declaration (NodeSelectionUndeclared), so the choice is never a silent default. Every active member’s cross-reference is asked, the query reaches only the members that return anehr_id, and the others arenot-resolvedwithout failing the query (N6, N8). The selection is named in the startup log; theOPTIONSself-description follows with #73. The quickstart and the configuration page declare it. - A PIX Manager in the test harness, seeded by ITI-104 (#47). The testkit’s
pix::PixManageris a test device, not a PIXm implementation: an in-process loopback server that takes the PIXm 3.1.0 Patient Identity Feed FHIR (ITI-104, a conditionalPUT Patient?identifier=held to theIHE.PIXm.Patientminimums, plus the Remove Patient Option’s conditionalDELETE) and answers ITI-83$ihe-pixfrom what was fed, with each case of ITI TF-2 §3.83.4.2.2. The seed builder feeds it a synthetic patient in theurn:oid:2.999arc with oneehr_idper node domain (seed::feed), and a capturing proxy in front of it journals and faults its traffic. The server’s e2e suite now resolves through it, at one member and at both, while the failure classes stay on stubbed Managers. The IG’s ITI-104 artefacts (the SourceCapabilityStatement, thePatientprofiles and the example Patients) are added to the vendored PIXm corpus. - The outbound identifier-hygiene gate and the security events of the
analysis guard (#45, §5.4, N33, CP-26). Right before a request leaves for a
node, the engine re-reads its AQL text (raw and as the printer escapes a
literal), its paging members, its URL (raw and percent-decoded) and the
headers the gateway adds, against the identifiers resolution consumed, and
refuses to send a request that still carries one: nothing reaches the node,
the query fails closed, and the refusal names the part of the request and
never the value. The gate reads past the
ehr_idliteral the rewrite scoped the query to, so a short identifier that occurs inside the node’s ownehr_idis answered. Every patient predicate the rewrite strips, every refused query and every gate stop is a security event underferrofed::security, located by byte range and carrying no identifier. A clinician or facility predicate (composer, care facility, performer, committer) is dispatched unchanged; the same path compared with the patient identifier is refused.openehr-federation0.0.5 addsRefusal::kind,Refusal::atandPatientQuery::strippedfor those events. - The resolution step through a PIX Manager (#43). A
[pixm]table selects the PIXm resolver: each[[pixm.manager]]names a PIX Manager’s FHIR base, its credentials, and theehr_iddomain of every member it resolves, and[pixm.namespaces]maps a client’s issuing namespace to a PIX assigning authority. One ITI-83 call per Manager resolves the patient at its members; only a member that knows the patient is asked its node query, a member that does not isnot-resolvedinmeta.federation, a patient known nowhere is a200with no rows andcomplete: false, and a Manager that cannot answer fails the query424with no node asked. The patient identifier and its namespace reach the PIX Manager only. Boot is refused when a member has no Manager, when a member has two, or when[dev]and[pixm]are both set. The{node, ehr_id}set of each resolution is held in memory as the client session’s resolution bindings, bounded byfederation.binding_ttl_ms(default 15 minutes), and nothing derived from a patient identifier is stored or logged. - The PIXm ITI-83 client (#42):
ihe-iti0.0.3, featurepixm, asks a PIX Manager’sPatient/$ihe-pixfor the identifiers other domains hold for a patient, held to the vendored PIXm 3.1.0OperationDefinitionand read into a cross-reference, the profile’s not-found answer, or a typed error (an unknown source or target domain, a rejection with its issue types, a timeout, a transport failure, a malformed answer). A404without anot-foundissue never reads as “patient unknown”. Identifier values are redacted inDebugand carried by no error. The IHE PIXm 3.1.0 package artefacts of ITI-83 are vendored underdocs/specs/ihe-pixm/byscripts/vendor/ihe-pixm.sh, pinned by version and tarball sha256, and the FHIR R4 model comes fromfhir-types0.1.107. - The fuzz lane (#134): four
cargo fuzztargets over the untrusted inputs (the AQL rewrite over arbitrary text and parameters, the ITS-RESTAdhocQueryExecutebody through the façade’s intake, a federatedRESULT_SETwith itsmeta.federation, and theOPTIONS {base}/body), with seeds generated from the vendored golden cases and the specification’s JSON examples byscripts/fuzz/seeds.sh. The rewrite target asserts the identifier-hygiene property of §5.4.1 (N33) on every query it accepts, string-function reconstructions included.fuzz.ymlruns the targets weekly, on dispatch and on pull requests touching the code they read. - Both patient-identifier carriers resolve (#44; §5.4.3, N33, CP-38): an
ENTRY-levelsubjectpredicate,…/subject/identifiers/id, is resolution input on equal terms withEHR_STATUS.subject.external_ref, with theDV_IDENTIFIERissuerortypeas its issuing namespace. It is consumed and stripped exactly asexternal_refis, so the same patient query through either carrier sends the same node query and returns the same rows. The same value in both carriers is consumed once; a second, different value, and qualifiers that name two namespaces, are refused with a400, as are theassigner, a predicate on the identifier list, anENTRY-levelexternal_ref, and the carrier selected, ordered on or inside a function.openehr-federationis 0.0.4, and the interimRefusal::EntrySubjectis gone. - The release lane at SLSA Build Level 3 (#31): the reusable
release-build.ymlbuildsferrofedper target withcargo auditable, writes a CycloneDX and a syft SBOM, and attests the tarball’s provenance and both SBOMs; the reusablerelease-image.ymlbuilds thelinux/amd64andlinux/arm64image from the attested musl binaries, pushes it toghcr.io/ferrohealth/ferrofedby digest, and attests the index and each platform manifest. A release is published only when the draft carries all eight assets of every target, and a pre-release is never marked latest. - The first federated query (#38):
POST {base}/v1/query/aqlanswers one ITS-RESTRESULT_SETover every member of the registry, with no federation syntax needed (N1, CP-1).- The gateway types the
query_parameters, analyses the query withopenehr-federation’saqlrewrite, resolves the patient at every member through the configured cross-reference, and sends each member that knows the patient standard AQL keyed on its ownehr_id(N2, N7, CP-2, CP-4). - It fans out under the budget and re-injects the selected subject column
as the resolution input (N5, CP-7).
meta.federationnames every endpoint (N16): a member that does not know the patient isnot-resolved, a suspended endpoint and a second endpoint of one member areexcluded. - A refused query is a
400ITS-REST error that locates the fault and quotes nothing (§5.4.3). Without a cross-reference a patient query fails closed with424(decision A17), and without a registry the route stays501. - The configuration gains
profile,[registry] document,[federation](per_node_timeout_ms,overall_timeout_msbelow the request timeout,default_namespace) and the[[dev.crossref]]rows of the development profile.config checkloads the registry too. - The compose quickstart mounts
docker/quickstart/registry.tomlandferrofed.toml, and the README shows one federated query returning the EHR of both nodes. The end-to-end test runs it against FerroEHR and EHRbase behind their capturing proxies, and no node request carries the patient identifier in any carrier (N33).
- The gateway types the
- The fan-out engine, first increment (#37):
ferrofed-engine’sfanoutmodule sends one request per in-scope node at once, each under a per-node deadline cut to the overall budget, with no retry and no hedging (§11.5, N38). A node still outstanding when the overall budget runs out is abandoned and reportedtime-out, without touching any other request, and its late answer contributes nothing.meta.federationis built from every outcome before the decision, so a failing answer carries it, with the effectivetimeoutbudget andcomplete: false. Under the all-or-nothing default anofflineortime-outnode fails the query504, anode-errorfails it424,504taking precedence;not-resolvedandconsent-deniedfail nothing, so a patient found nowhere is a200with empty rows (§11.3, §11.4, N6, N37). Every endpoint record carries its node,system_id, managing organisation, base URL andlatency_ms(§9.5, N40), and the rows of theactivenodes are concatenated in endpoint id order until the merge (#52). - The crates.io lane behind the workspace
publishswitch (#32):publish-crates.ymlruns on everyv*tag, reads the publishable set fromcargo metadata, packages it and publishes it in dependency order through crates.io Trusted Publishing, and is a successful no-op while the root[workspace.package] publishisfalse. Thepublish-dry-runjob packages every library crate on every pull request, andscripts/release/publish-crates.shis the shared implementation. - Node dispatch (#34):
ferrofed-engine’sdispatchmodule builds oneopenehr-itsclient per registry endpoint, rooted at the endpoint’s base URL as the registry holds it plus the ITS-RESTv1segment (N28), and sends each node query as the generatedPOST {base}/v1/query/aql, once, with the per-node deadline and the gateway’s request id. The answer maps to exactly one §11.1 status: a result set toactive, a refused connection or broken stream toofflinewith its reason, a passed deadline totime-out, and a documented error, an undocumented status or a body that is not a result set tonode-errorcarrying the node’s own status and message (§11.2, N16, N40). A credential the provider cannot produce, or a request the client runtime refuses to compose, is a typed dispatch error, never an endpoint status. The engine names no HTTP engine directly, and a test holds it. - The AQL rewrite, first increment (#35): the
aqlfeature ofopenehr-federation0.0.3 parses a façade query withopenehr-query, binds itsquery_parametersbefore analysis, and consumes theEHR_STATUS.subject.external_refpatient predicate with its namespace into a redactedSubject(§5.2). Each node receives the query scoped to its ownehr_id(§7.1, N7, N29); a selected subject or namespace column is re-injected after the merge (N5); andcolumns[]is rendered from the façade query alone (N17). The rewrite refuses with a typed400, never quoting the identifier: a predicate outside the top-levelANDchain or under another operator (§7.1), an integer identifier (decision A6), two different patients (A7), an unqualified identifier with no default namespace (A5), a query with no patient where a localizer decides the node set (A8), paging members that disagree with the query (A10), cross-nodeOFFSET(§11.6.2), an undirected aggregate (N14), and the identifier anywhere else in the query (§5.4.1), including rebuilt byCONCAT,CONCAT_WSorSUBSTRINGover split literals, which the rewrite folds before the value test; a string function over a literal that cannot be folded is refused on an identifier-bearing path (decision A4). The reference implementation’s 17 golden cases run as a corpus, each adjudicated. CI’s test lane runs every feature. - The static registry (#36):
ferrofed-registry0.0.1 loads the federation’s membership from a reviewed TOML bootstrap document ([[organisation]],[[node]]with itssystem_idand[[node.identifier]],[[endpoint]]) withdeny_unknown_fieldsthroughout into an immutableRegistrySnapshot. A document with a dangling reference, a duplicate id, asystem_idshared by two nodes (compared without ASCII case), an endpoint without exactly one managing organisation, a connection type other thanopenehr-rest-query, an unusable base URL or a node without an endpoint refuses to load (N19, N20, N21, §12b.2).NodeId,EndpointIdandSystemIdare distinct types with no conversion between them (N32, §12a.1). - The resolver seam and the development cross-reference (#36):
ferrofed-identity0.0.1 carriesPatientRef, the patient identifier redacted in every rendering and never serialized (§5.4, N33), theResolvertrait (N3, §5.2), andStaticResolver, a fixed table from a synthetic identifier to each member’sehr_idthat a configuration can enable only underprofile = "development". It is FerroFED’s own testing device, not an identity binding. - The container image:
docker/Dockerfileputs theferrofedmusl binary on distroless static, digest-pinned, as the numeric non-root user65532, with no shell and a read-only root;scripts/release/stage-dist.shstages the binaries of a published release after checking their checksums.compose.yamlstarts the gateway beside two member CDRs, FerroEHR 4.3.1 and EHRbase 2.36.0, every image pinned by digest indocs/VERSIONS.mdand held there by the versions guard (#30). - The test harness in
tools/ferrofed-testkit(#39): FerroEHR 4.3.1 and EHRbase 2.36.0 as two nodes on their own documented databases, pinned by digest and started only behind theFERROFED_E2Egate; a capturing and fault proxy in front of each node, whose journal records every request (method, path, query, headers, body) and which refuses, delays or answers with a chosen status per node; and a synthetic seed builder that writes EHRs, the template and compositions over ITS-REST alone, with patient identifiers only inside theurn:oid:2.999example arc. CI runs the container suite in its owne2e (containers)job, and the versions guard holds the image pins todocs/VERSIONS.md.
Fixed
ihe-iti0.0.4 names PMIR’s transactions as ITI-93 and ITI-94 in its description, feature list and README. ITI-104, which it attributed to PMIR, is the PIXm Patient Identity Feed FHIR (#47).
Changed
- The repository moved to the FerroHEALTH organization,
https://github.com/FerroHEALTH/FerroFED, with the roadmap board as
https://github.com/orgs/FerroHEALTH/projects/1; every link, the image name
ghcr.io/ferrohealth/ferrofedand the crates’repositoryfollow it (#123). - The crate layout names every crate that may be published after the
specification it implements, one crate per specification with a feature per
layer or profile (#106):
openehr-federation0.0.1 (the Federation Tier wire types, formerlyferrofed-wire, with theaqlandmergefeatures),ihe-iti0.0.1 (featurespixm,pdqm,mcsd,pmir,xcpd) andnl-generic-functions0.0.1 (featuresnvi,mitz,lrza,nuts-auth). The names are held on crates.io by 0.0.0 placeholders.ferrofed-registry,ferrofed-identityandferrofed-enginemove underapp/and are never published. A new CI job lints every feature of the published crates on its own, and the architecture test also fails when a binding crate depends on FerroFED. - The
openehr-*family moves to 0.0.74, the lockstep release with the AST visitor, spans, parameter binding and the federation directive inopenehr-query, and the router builder, operation matcher, credentials provider and per-call options inopenehr-its(FerroEHR #3505 to #3514).
Security
- A configuration that does not parse no longer quotes its source line
(#133). The TOML reader’s error printed the offending line, so a malformed
[dev]row could put its patient identifier, and a mistyped inline secret its value, into theconfig checkoutput and the boot failure. The refusal now names the file, the line and column, the key path (devalone for any line of the[dev]table) and the kind of fault, and none of the text.
0.0.1 - 2026-10-01
The first release: the repository setup, the documentation site on
https://ferrofed.eu/, the architecture of record, the Cargo workspace and the
server binary’s shape (configuration, telemetry, health, readiness and
shutdown). The ferrofed binary serves health and readiness only; the
federated query follows from v0.0.2.
Added
-
The Cargo workspace (#28): the root manifest with the family lint set, the release profile and the
publish = falseswitch every library crate inherits; theopenehr-*family declared as one lockstep pin group at 0.0.72, with the versions guard failing when one member moves alone;deny.toml; theserde_json::Valueban inclippy.toml; and every crate of the architecture’s crate map as a documented placeholder, with theferrofedbinary over a thin library run path and the testkit’s pin-matrix reader asserting each crate’s specification constant againstdocs/VERSIONS.md. -
The server binary shape (#29):
ferrofed serveandferrofed config check; configuration from a TOML file andFERROFED__environment overrides with unknown keys refused,_filesiblings for every secret, outbound credentials per endpoint id, and a typed refusal (exit 78) on any bad value, a broken log filter included;auto,jsonandprettyconsole formats;GET /,GET /healthandGET /health/readinessover an indicator registry; the request id, the panic catch (a500that carries neither the panic message nor anything the client sent), the request timeout and the body ceiling; a bounded drain onSIGTERM; and501for every path of the ITS-REST surface until the façade lands. The request log records the method, the matched route, the status, the latency and the request id, and never a body, the AQL text, a header value, an unmatched path, or a query value other than the digit-onlyoffsetandfetch, so a façade query’s patient identifier reaches no log line at any level (§5.4.3). -
The federation wire types in
ferrofed-wire(#33):meta.federationwithcompletederived from the endpoint statuses (§11.4), the per-endpoint record whose shape carries the N40errorandlatency_msobligations of each §11.1 status, theOPTIONS {base}/self-description with its two schema conditionals (§7a.2), the federation header names, and the one seam into the ITS-RESTResultSetMetadata, which refuses the flat and_-prefixed members §9.1 forbids (CP-35). Unknown members of every open object round-trip. The tests validate every emitted body against the vendored schemas, the specification’s §9.4 and §7a.2 examples included, fail on drift between the schemas and the types, and pin the rules no schema states. -
The conformance matrix (#41):
conformance/matrix.tsvwith every conformance point of section 17,conformance/tracks.tsvwith the section 16.3 tracks (track 8 deferred as provisional) andconformance/requirements.tsvwith the reachability of all 46 requirements, derived from the vendored specification byscripts/conformance/matrix.sh; the// conformance:test marker; the tier-1conformance-matrixguard; and the matrix rendered into the book’s Evaluate part. -
The clinical-path storage rule (#40): an engine test reads the crate graph and fails when a library crate reaches a storage implementation or the application crate.
Fixed
scripts/checks/crate-version-guard.shno longer exits early on the change that adds the rootCargo.toml, where the base has none.
0.0.1-rc.1 - 2026-10-01
A pre-release that rehearses the release lane (#14): the repository setup, the documentation site and the architecture of record, with no binaries.
Added
- The architecture of record,
docs/architecture.md, from the first research pass (#16, evidence on #18 to #27): the publishedopenehr-*crates as the openEHR surface at the planned 0.0.74 pin, the request pipeline, the AQL rewrite and identifier hygiene onopenehr-query’s AST, the façade split over the ITS-REST route tables, the identity seams with each binding (PIXm, mCSD, PMIR, XCPD, the Dutch Generic Functions) in its own crate, the security handoff, the registry and its storage, the fan-out, completeness and cross-node merge with theLIMITagreement check, the hand-written wire types, the crate map with thepublishswitch, the conformance instrument, the test topology, the milestone map, and the decision register, every entry decided by the owner on 2026-10-01. - The documentation site on ferrofed.eu: a landing page at
/and an mdBook under/docs/organised by reader intent (Evaluate, Operate, Integrate, Contribute), built on every pull request and deployed frommainbydocs.yml, with the pinned docs toolchain, the vendored mermaid assets, the favicon set and itsfavicon-syncguard, and the README badge block (#12). - The release lane,
.github/workflows/release.yml: a signedv*tag is checked againstCITATION.cffand the product row ofdocs/VERSIONS.md, the version’sCHANGELOG.mdsection becomes the release notes, and the release is created as a draft and published once the asset set it promises (none before there is code) is verified.docs/release.mdis the cut (#14). - The Business Source License 1.1 with Vernum Projecten B.V. as the Licensor
and copyright holder (#1), and the contribution-licence terms, the
pull-request checkbox and the
contribution-licence-guardcheck (#3). - The working discipline under
.claude/: the rules, the hooks (no AI attribution, dangerous-command guard, format and comment-style on edit, the SessionStart tracker summary), the issue-loop skills, thespec-researcherandimplementeragents, and the tracked project memory.CLAUDE.mdstates the product, the design-phase status and the hard rules (#6), with the identifier-hygiene rule for everything the gateway composes for a node. - The vendored specification corpora under
docs/specs/, each with aPROVENANCE.mdand a fetch script underscripts/vendor/: the Federation Tier with AQL specification at v0.9.0 (release candidate, CC0 1.0), its reference implementation (Apache-2.0) as evidence, the openEHR ITS-REST 1.1.0 OpenAPI documents and the openEHR AQL 1.1.0 source with its grammar (#7). - One pin matrix,
docs/VERSIONS.md, andscripts/checks/versions.sh, the guard that fails on drift between the matrix and every file repeating a pin (#8). - The GitHub setup: issue and pull-request templates, CODEOWNERS, Dependabot,
the label set and tracker helpers under
scripts/gh/, and the CI workflows (the tier-1 guards with the Rust tier gated until a workspace exists, oneconclusioncheck, CodeQL, Scorecard and SonarQube Cloud), anddocs/ci-cd.md, the design of the workflows and the repository settings (#9, #10, #11). - The community and governance set:
CODE_OF_CONDUCT.md,GOVERNANCE.md,SUPPORT.md,AI_STATEMENT.md,CITATION.cff,llms.txt, and the root toolchain, format and lint configuration (#15).