Repository navigation
Releases: FerroHEALTH/FerroFED
Release list
0.0.11
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.category
codes 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:member
for a member's own answer or failure,client-errorfor a4xxit
answered a routed request with. Such a4xxnow counts as
outcome="node-error", where it wasactive. The shipped
FerroFEDMemberFailingrule 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, a
PARTY_IDENTIFIEDwhose one identifier is theactsub, never the
person'sPARTY_SELF, and its access record carries the
ehds-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 addsRepresentativeand
ReceivedComposition::inserted_by_representative;ehds-logging0.0.24
replacesAccessRecord::subject_insertedwithinserted, an
Insertionof 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 features
prescriptionanddispensation, the crosswalks from the Xt-EHR
EHDSMedicationPrescriptionandEHDSMedicationDispense1.0.0 models to
the HL7 Europe MPD 1.0.0MedicationRequest-eu-mpdand
MedicationDispense-eu-mpdprofiles, held to both vendored packages. With
fhir-r4, the newmedicationmodule reads a member's
MedicationRequestandMedicationDispenseinto 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 extension
https://ferrofed.eu/fhir/StructureDefinition/remaining-dispensationson
dispenseRequest, 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}and
POST {fhir-base}/MedicationDispense:404with anOperationOutcome
of the issue typenot-supported, never an empty list, and no member is
asked.GET {fhir-base}/metadatalistsMedicationRequestand
MedicationDispenseonly 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 onesearchset
with each member'sMedicationRequestas the member sent it except its
idand its entry'sfullUrl, which carry the face's locator, theid
the read takes (#1041); oneProvenancenaming the member, and a
warningwhen two members hold a prescription under the same
identifier._countpages the answer through the gateway's own-offset
parameter. 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 the
composition-search scope over every template and is recorded under
Electronic-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 its
MedicationRequestis 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 the
composition-read scope over every template and is recorded under
Electronic-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 one
MedicationDispense, 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's
MedicationRequest/{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 new
PrescriptionIdentifierCollisionintegrity 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-Dispensationsand
Electronic-Prescriptions, and is counted by outcome in
ferrofed_fhir_dispensations_total. ServingElectronic-Dispensations
now 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 underpartial, 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): `eehr...
0.0.10
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 checkand
servenow 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 and
terminationGracePeriodSeconds: 45in Kubernetes. - If you run the Kubernetes example, move from
deployment.yamlto
statefulset.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 outside
profile = "development", set[metrics] scrape_token_fileto a file
holding a scrape token and give your Prometheus the same token with
authorization.credentials_file, or set[metrics.tls] client_ca_file
(#635). Without either,serveandconfig checkrefuse the
configuration. The Kubernetes example reads the token from the
metrics-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 answers
401 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, set
client_tokens_act_for_professional = trueon its[[auth.issuer]]and
have its tokens name the professional it acts for in
extensions.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 report
reads 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's
EEHRxFDocumentPriorityCategoryCS(#824), which are case-sensitive:
patient-summarybecomesPatient-Summaries,electronic-prescription
Electronic-Prescriptions,electronic-dispensation
Electronic-Dispensations,medical-imagingMedical-Imaging,
medical-test-resultLaboratory-Reportsanddischarge-report
Discharge-Reports, in[access_log.templates],
[access_log.archetypes]and the keys of
[access_log.retention.categories]. Declare each national category in
national_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"), so
config 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 answers404with
no-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 BALPAuditEventsent 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 answers
503 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, behind
balp, 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, naming
signing.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
metrics `http_server_request_durat...
0.0.9
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 andnoncebefore a session begins (the new[oidc]keystoken_endpointand
jwks_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-REST
StoredQuery), 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 optionaloffsetand
limitparameters, withlimitat most 100. It answers
routing ids, counts and stored definitions only, and an incident names an
ehr_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 in
ferrofed-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-metadata
0.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_fileand
trust_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 whosecnf
names 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. A
fapi2grant over mutual TLS uses themtls_endpoint_aliasestoken
endpoint (§5), needs the metadata to listtls_client_authand state
tls_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, an
httptoken endpoint or node, and TLS material in a service's own
credentialssection 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 a
client_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). Anoauth2orfapi2
table under[nl_gf.nvi.credentials]is refused, naming its key, since the
IG defines no such grant for the Localization Service. The
nl-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 the
urn: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-profile
adds 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 in
OPTIONS {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 itsSUM...
0.0.8
Security
-
The trace export records nothing of a client's
traceparentor
tracestate. 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 ownAuthorizationheader. -
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 withnoneor an HMAC, of another
typthanat+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 a
system/aql-*scope counts only for a listed backend client. The
DEMOGRAPHIC API admits only the clients an issuer lists in
demographic_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 behttps, 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 behttpsoutside 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 setsslmode=requirewhen it carries a password
to a networked host:disableandprefer, the driver's default, which
falls back to no TLS, are refused namingstored_queries.urlby
config 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 answered500and 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 own
openEHR-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(or
client_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 ES256
private_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 its
audin 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 (an
identifiersearch that asks for the master domain only) or, where the
deployment declarestransaction = "iti-119", by the ITI-119$match
withonlyCertainMatches. 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 as
timeout.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/$match
and 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:web
key, 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 n...
0.0.7
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 under
secrets/, 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.sh
and 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.yaml
stays 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, and
405with 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 bykind
(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.serve
andconfig 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 the
ferrofed-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 NOTHING
stores exactly one of two racingPUTs of a new version, the other is a
409(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; a
PUTis 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 a
409with 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}/as
definition.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 through
openehr-its's generated client, and answered with the registry's
StoredQueryandmeta.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-targeted
before anything is stored, and stays storable and executable federated.
AGETof a version naming members reports drift per member:active
where its copy is the same query, andnode-errorwith
error.codedefinition-differsordefinition-missingotherwise,207
unless every named member matches. An invocation always runs the
registry's AQL, never a member's copy. With the setting off, a registry
PUTor versionGETcarryingopenEHR-federation-endpointor
openEHR-federation-organisationis refused with
400 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-endpoint
is*(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 is
meta.federationwith oneendpoints[]entry per registry member and no
node's body:200when every named member accepted,207with
complete: falsewhen some failed, and424or504when none...
0.0.6
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 itsETag, teaches a route for a
creating_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, and
OPTIONS {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 refused
409 stored-query-heldand the held text stands, across a restart too.
GETon the same path reads the ITS-RESTStoredQueryback, and
GET {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) and
stored-query-unknown(404). Without[stored_queries]the definition
routes still answer501.openehr-federationis 0.0.30 and adds
aql::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 sends
GET {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 is
404 no-destination, one two members hold is409 ehr-id-collision
listing 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 stays
400 target-required, and nothing is probed. A step that names two
members names none, and no later step picks one of them. The path
ehr_idmust be an openEHRHIER_OBJECT_ID, and any other value is400
with the new code
ehr-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_idpast
federation.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.json
and is built from the running configuration: the federation id, the
major.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, or
reject, 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 selectsp/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 values
meta.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 refusal
endpoint-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 refused400
indecomposable-aggregate, and ordering on an attribute stays refused400
endpoint-variable. The501 not-implementedanswer for these queries is
gone.openehr-federation0.0.27 adds theattributemodule with
EndpointAttribute,ColumnSource::Endpoint(EndpointAttribute),
Analysis::attributes,NodeAnswer::with_attributesand
Merged::attributes; the engine addsPlan::annotatingand
FederatedAnswer::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: anEHR_STATUS.subject.external_refpredicate, a
PARTY_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-organisation
request 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,
ove...
0.0.3
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, feature
pdqm(#119; PDQm 3.2.0, ITI TF-2 §2:3.78).pdqm::PdqmClientposts a
PatientQueryto[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 a
not-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/by
scripts/vendor/ihe-pdqm.sh. TheOperationOutcomeissue type moves to
ihe_iti::outcome::IssueType, shared by PIXm and PDQm;ihe-itiis 0.0.5. - The identity-lifecycle hook for track 8 (#48):
ResolutionBindings::identity_changed
drops 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; the
OPTIONSself-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 the
IHE.PIXm.Patientminimums, plus the Remove Patient Option's conditional
DELETE) 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 the
urn: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 theehr_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::atand
PatientQuery::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 a
not-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/by
scripts/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-REST
AdhocQueryExecutebody through the façade's intake, a federated
RESULT_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 the
DV_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 the
assigner, a predicate on the identifier list, anENTRY-level
external_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/amd64and
linux/arm64image from the attested musl binaries, pushes it to
ghcr.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 with
openehr-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 stays
501. - The configuration gains
profile,[registry] document,[federation]
(per_node_timeout_ms, `...
- The gateway types the
0.0.2-rc.1
A pre-release that rehearses the release lane at SLSA Build Level 3 (#31):
attested binaries, the image ghcr.io/ferrohealth/ferrofed by digest, and
their SBOMs. It carries the first federated query (#38) and everything merged
since v0.0.1.
Added
- 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/amd64and
linux/arm64image from the attested musl binaries, pushes it to
ghcr.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 with
openehr-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 stays
501. - 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.tomland
ferrofed.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'sfanout
module 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 effective
timeoutbudget andcomplete: false. Under the all-or-nothing default an
offlineortime-outnode fails the query504, anode-errorfails it
424,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 the
activenodes 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 from
cargo 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, and
scripts/release/publish-crates.shis the shared implementation. - Node dispatch (#34):
ferrofed-engine'sdispatchmodule builds one
openehr-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 of
openehr-federation0.0.3 parses a façade query withopenehr-query, binds
itsquery_parametersbefore analysis, and consumes the
EHR_STATUS.subject.external_refpatient predicate with its namespace into a
redactedSubject(§5.2). Each node receives the query scoped to its own
ehr_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), theResolver
trait (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.yaml
starts 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.
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) and
nl-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 in
`openeh...
0.0.1
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 thepublish = 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; the
serde_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 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) and
conformance/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
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 frommainby
docs.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 thecontribution-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, the
spec-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 a
PROVENANCE.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 underscripts/gh/, and the CI workflows
(the tier-1 guards with the Rust tier gated until a workspace exists, one
conclusioncheck, CodeQL, Scorecard and SonarQube Cloud), and
docs/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).