Introduction
FerroFED is a pure-Rust openEHR federation gateway, one of the
FerroHEALTH family. It sits in front of several
openEHR CDRs as a transparent ITS-REST intermediary. A client sends it an
ordinary AQL query with no federation syntax, and gets back an ordinary
ITS-REST result set whose meta.federation names each node with its status.
The gateway resolves the patient first, outside the query, through an
identifier cross-reference service. It then sends each node standard AQL
scoped to that node’s own ehr_id, so no request the gateway composes for a
node carries a directly identifying patient identifier, and it merges the
answers with each node’s provenance. It holds no clinical
data of its own.
FerroFED implements the openEHR Federation Working Group’s Federation Tier with AQL specification, release 1.0.1, and reaches every node over openEHR ITS-REST 1.1.0.
This book covers FerroFED alone. What every FerroHEALTH product shares is in the family book: how the products fit together, the manufacturer, Cadasto B.V., with its security policy and the Cyber Resilience Act from the manufacturer’s side, and licensing and trademarks.
See how it works: the gateway in diagrams.
What it does today
The latest release
ships the ferrofed binary for Linux and the image
ghcr.io/ferrohealth/ferrofed, both signed, and a compose.yaml that runs
the gateway alone in front of your own CDRs
(The gateway from a release).
The quickstart runs it beside four
member CDRs. This book describes the gateway as it is built on main, and
the changelog
says which release first shipped each part. Over the members of a registry,
the gateway:
- answers the federated query,
POST {base}/v1/query/aqland itsGETform, with one ITS-RESTRESULT_SETand ameta.federationreport that names every member with its status; - resolves the patient through an IHE PIXm PIX Manager (ITI-83), after an IHE PDQm query (ITI-78 or ITI-119) where the client names the patient by an identifier the cross-reference does not know, and refuses any query that would carry the patient identifier to a node;
- can ask only the members that may hold the patient, localized through IHE XCPD (ITI-55), the Dutch NVI or the PIX Manager, can leave out the members a consent pre-filter such as the Dutch Mitz denies, and can read its members from an IHE mCSD care services directory;
- authenticates to each node as itself, with an OAuth 2.0 token, by client credentials or token exchange and optionally bound with DPoP, a token of the Dutch Nuts or FAPI 2.0 track, or a static credential, and tells each node who is asking in a token it signs for that node;
- audits every IHE transaction it makes or receives to an ATNA Audit Record Repository;
- fails a query when a node it asked does not answer, unless you ask for a
partial answer, and shapes the merged rows as one CDR would:
ORDER BYwithLIMIT, boundedOFFSETpages,DISTINCT, recombined aggregates and opt-in de-duplication of copied versions; - lets you direct a query at named nodes, in the AQL or in a header;
- routes the EHR resources under a path
ehr_idto the one node that holds that EHR, and a versioned write only to the CDR that controls the version; - sends a template or another definition request to the node you name, and can fan a template upload out to several;
- holds stored queries itself, as immutable versions it runs by name, and can distribute them to the members;
- describes itself at
OPTIONS {base}/; - gives you, the operator, a check to run before you admit a node, a
registry reload on
SIGHUP, health probes, metrics for Prometheus or an OpenTelemetry collector, and traces over OTLP.
Every client authenticates with an access token from an issuer you trust (Client authentication). Each page says what is built, and the claims page lists the limitations to know before you deploy it.
How this book is organised
The four parts follow what you came to do.
- Evaluate: whether FerroFED fits your problem. How the gateway works, one question per page, what the Federation Tier is, what FerroFED claims, the conformance matrix and the obligations checklist, the version pins and the licence.
- Integrate: what a client sends and what it gets back.
- Operate: installing, configuring and running the gateway, what it needs around it, and what each release added.
- Contribute: how the work is tracked and which checks a change has to pass.
The tracker is the scope. Open issues are the worklist, milestones are releases, and the milestones page is the public view of both.
How it works
This part of the book explains the gateway in diagrams, one question per
page. Each diagram cites the section of the Federation Tier specification
it shows, and its requirements (N) or conformance points (CP). Each one
shows what is built on main. A part drawn with a dashed border is planned,
and the text names its issue and milestone.
| Page | The question it answers |
|---|---|
| A federated query, end to end | What happens between your AQL and the merged answer? |
| Where the patient identifier stops | How does the gateway keep the patient identifier away from every node? |
| Follow-ups and writes | How does a read or a write find the one node that owns the record? |
| Definitions and stored queries | Where do templates and stored queries live? |
| Trust and keys | Who signs what, and who trusts which key? |
| Deployment | What runs beside the gateway, in the quickstart and in production? |
The gateway between clients and member CDRs
The specification puts a Federation Tier between the applications and the
member CDRs (§3.1). A client sends it ordinary AQL over openEHR ITS-REST and
reads one ordinary RESULT_SET back (N1, N17). Before anything is sent to
a node, the gateway resolves the patient outside AQL, through services of
its own tier (§4.1, N3). Each node then receives standard AQL scoped to its
own ehr_id and never learns that it is part of a federation (N7). The
diagram follows the three tiers of the specification’s own figure in §3.1.
flowchart TB
subgraph app["Application tier"]
client["Client application"]
end
subgraph tier["Federation Tier"]
gw["FerroFED gateway"]
end
subgraph step1["Step 1, outside AQL"]
loc["Localizer"]
pix["PIX Manager"]
end
subgraph nodes["Node tier"]
a["Node A"]
b["Node B"]
end
client -->|"1. AQL"| gw
gw -->|"2. where?"| loc
gw -->|"3. which ehr_id?"| pix
gw -->|"4. ehr_id A"| a
gw -->|"4. ehr_id B"| b
gw -->|"5. RESULT_SET"| client
Step 2 is optional. Under federation.node_selection = "ask-all" every
registry member is a candidate, and the gateway asks the PIX Manager about
all of them (§4.3, N4). Under "localized" a localizer names the candidates
first (§14.1). Four localizers are built: IHE XCPD ITI-55 (Annex A.3), the
NVI of the Dutch binding (Annex B §B.1), the PIX Manager itself, whose one
ITI-83 answer both localizes and resolves (§14.2), and the development
cross-reference.
The parts of the gateway
The second diagram opens the gateway box. The names in it are the names every other page of this part uses.
flowchart TB
client["Client application"]
subgraph gw["FerroFED gateway"]
gate["Gate<br/>token, scope,<br/>purpose of use"]
facade["Façade<br/>ITS-REST"]
store[("Stored-query<br/>store")]
rewrite["Rewrite<br/>AQL per node"]
seams["Identity<br/>seams"]
registry[("Registry<br/>snapshot")]
engine["Engine<br/>fan-out, routing,<br/>outbound gate"]
merge["Merge<br/>rows and<br/>meta.federation"]
admin["Admin<br/>listener"]
keys["Signing key<br/>and JWK Set"]
end
pix["PIX Manager"]
xcpd["XCPD responding<br/>gateways"]
nodes["Member CDRs"]
client -->|"requests"| gate
gate -->|"verified caller"| facade
store -->|"definitions"| facade
facade -->|"parsed query"| rewrite
facade -->|"patient"| seams
seams -->|"ITI-83"| pix
seams -->|"ITI-55"| xcpd
rewrite -->|"node queries"| engine
registry -->|"members"| engine
keys -->|"assertion,<br/>signed caller"| engine
engine -->|"ITS-REST"| nodes
engine -->|"outcomes"| merge
engine -->|"counts"| admin
merge -->|"RESULT_SET"| facade
- Gate: verifies the caller’s access token against the issuers you trust, and checks the operation’s scope and the purpose of use before anything else reads the request (§13.1, N25). A refused request reaches no node (Trust and keys).
- Façade: serves the ITS-REST surface the client sees, from the
generated route tables of
openehr-its(§7a, N1). It takes the query apart, picks the path a request takes, and writes the answer. - Rewrite: parses the AQL with
openehr-query, finds the patient on both carriers, and prints one standard query per node withprinter::to_aql(§7.1, N2, N7). It refuses a query it cannot reduce safely (§5.4.3). - Identity seams: one trait per role of §5.2, §13.2.1 and §14: the resolver (the PIX Manager over ITI-83), the localizer (XCPD over ITI-55, the Dutch NVI or the PIX Manager), the consent pre-filter, and the optional demographics step ahead of them (a PDQm Supplier over ITI-78 or ITI-119, Annex A §A.2). The pre-filter has a development binding and a production binding, the Dutch Mitz (Dutch consent).
- Engine: sends one request per node under one deadline (§11.5, N38), routes a follow-up to the node that owns it (§12), and passes every outbound request through the outbound gate (§5.4.1, N33). Each request carries the node’s own credential and the caller’s identity, signed by the gateway for that node (§13.1, N24, N25).
- Signing key and JWK Set: the gateway’s ES384 key signs its client
assertions to each node’s token endpoint and the caller’s identity on every
node request. Its public half is served at
{base}/.well-known/jwks.json(§13.1, N30). - Merge: orders, de-duplicates and cuts the rows across nodes, and reports every member with its status (§9 to §11, N13, N16, N37, N39).
- Registry snapshot: the members you admitted, read from a reviewed document or an mCSD care services directory into an immutable snapshot, and the follow-up routing table (§12b, §15.1, N21).
- Stored-query store: the definitions the gateway holds when you offer the stored-query registry (§12.7, N44).
- Admin listener: a second listener for your operators only: the metrics and the stored-query repair. No client of the federation reaches it.
What the gateway holds, and what it does not
The gateway holds no clinical data. The specification is silent on storage, so where each piece of state lives is FerroFED’s own design.
| State | Where it lives | Survives a restart |
|---|---|---|
Organisations, nodes, endpoints, system_ids and creating_system_id mappings | the registry document you review, or the mCSD directory, loaded into a snapshot | yes, it is your file or your directory |
ehr_id to node index | memory, bounded, least recently used out first | no |
creating_system_id routes learned from answers | memory | no |
| Resolution bindings per verified caller | memory, under a lifetime and a capacity | no |
| Access tokens from the nodes’ token endpoints | memory, per endpoint, until 30 seconds before each expires | no |
| Stored-query definitions | redb, PostgreSQL or read-only files | yes |
| Outbound credentials and the signing keys | one secret file each | yes, they are your files |
It never writes a patient identifier, a value derived from one, or a result
row to disk. A stored query names its patient through a $parameter, and a
definition with a literal patient identifier is refused (§5.4.1, N33).
A federated query, end to end
You send POST {base}/v1/query/aql with a patient query, as you would to one
CDR. This page follows that request through the gateway in two halves:
Step 1 resolves the patient, and Steps 2 and 3 send the node queries and
merge the answers (§4.1). The two closing diagrams show the status each
member gets and the HTTP status of the whole answer. The names match the
overview.
Step 1: resolve the patient, outside AQL
The client names the patient the openEHR way, for example
WHERE e/ehr_status/subject/external_ref/id/value = 'ffd-test-0001' with its
namespace (§7, N2). The gateway takes that value out of the query and asks
the identity seams where the patient is and under which local ehr_id
(§5.2, N3, CP-3). The order is fixed: the registry snapshot, then the
localizer, then the consent pre-filter, then the resolver. Only a member the
resolver answers with an ehr_id, and the pre-filter did not deny, is sent a
query (N8, CP-36).
%%{init: {"sequence": {"actorMargin": 24}}}%%
sequenceDiagram
participant C as Client
participant F as Façade and rewrite
participant L as Localizer
participant P as Consent
participant X as PIX Manager
C->>F: POST /v1/query/aql,<br/>Bearer token
Note over F: the gate: token,<br/>scope, purpose of use,<br/>or 401, 403
Note over F: parse, bind,<br/>find the patient,<br/>or answer 400
Note over F: take the registry<br/>snapshot
opt node_selection = localized
F->>L: candidates?
L-->>F: members, or fail closed
end
opt a consent pre-filter is configured
F->>P: may each be asked?
P-->>F: denied, or no signal
end
F->>X: ITI-83 $ihe-pix
X-->>F: ehr_id per domain, or none
Note over F: {node, ehr_id}<br/>pairs, kept for<br/>this caller
- Node selection is a declaration you make (§4.3, N4, N10). Under
federation.node_selection = "ask-all", every active member is a candidate. Under"localized", the localizer names the candidates, and a member it does not name isnot-localizedand never asked. A directed query is never localized (§8). - A localizer that fails fails closed: no member is asked, every member
is
not-localizedwith the localizer’s error, andmeta.federation.localization.errorcarries the same error, so an outage never reads as a patient with no data. Onlyfederation.localization.on_failure = "ask-all"widens instead (§14.1, N4, CP-5). - The localizer is one of four (Identity resolution). IHE XCPD asks every responding gateway by the patient identifier alone which communities hold the patient (ITI-55, Annex A.3). The NVI of the Dutch binding names the care providers that hold data for the patient’s pseudonym (Annex B §B.1, Dutch localization). Without either, the PIX Manager localizes: its candidates are the members whose domain holds an identifier for the patient, and the resolution reuses that one ITI-83 answer (§14.2). The read of an EHR by subject is localized the same way.
- The consent pre-filter is optional and never the gate: a member it
denies is
consent-denied, never resolved and never sent a request, and every other member is asked so its node can decide (§13.2.1, N27, N27a). When the pre-filter cannot answer, every candidate is asked. The pre-filter is the Dutch binding, Mitz (Dutch consent). - “No identifier in this domain” is an answer: that member is
not-resolvedand does not fail the query (N6). A PIX Manager that cannot answer is a failure, reported on the member and failing the query424under the default (§11.4). - The PIX Manager is the one service that receives the patient identifier, because resolving it is its role (§5.2). The gateway never records the request URL of that call.
Steps 2 and 3: dispatch and merge
The rewrite builds one query per resolved node from the parsed AST. The
subject predicate becomes e/ehr_id/value = '<that node's ehr_id>', the
canonical form of N29, and printer::to_aql prints it (§7.1, N7, CP-4). The
engine sends every node query at once, under one deadline fixed when the
request arrived (§11.5, N38), and each request passes the outbound gate
first (Where the patient identifier stops). Each
request carries that node’s own credential and the caller’s identity in a
token the gateway signs for that node (Trust and keys).
%%{init: {"sequence": {"actorMargin": 24}}}%%
sequenceDiagram
participant C as Client
participant F as Façade and rewrite
participant E as Engine
participant A as Node A
participant B as Node B
Note over F: one AQL per node,<br/>printed by to_aql
F->>E: fan out, one deadline
Note over E: outbound<br/>gate
par every resolved node at once
E->>A: AQL, ehr_id A,<br/>node token, signed caller
A-->>E: rows
and
E->>B: AQL, ehr_id B,<br/>node token, signed caller
B-->>E: rows, error,<br/>or time-out
end
E-->>F: outcome per member
Note over F: merge
F-->>C: RESULT_SET with<br/>meta.federation
A node that answers after the deadline is ignored, and abandoning one node
never cancels another (§11.5). meta.federation.timeout reports the budget
that applied, and each dispatched endpoint reports the latency_ms the
gateway saw (N38, N40, CP-31).
The merge
The gateway reads every node’s rows by position against your own SELECT,
never against the column names a node sends back. It then shapes them the
way one CDR would (§9 to §11.6). The order of the steps is FerroFED’s own
where the specification gives none.
flowchart TB
rows["Rows of each active node"] -->|"by position"| check["Check each node's<br/>visible order"]
check -->|"accepted rows"| dedup["De-duplicate copies,<br/>on request"]
dedup -->|"one copy per version"| order["ORDER BY, then<br/>endpoint_id and uid"]
order -->|"one global order"| distinct["DISTINCT"]
distinct -->|"distinct rows"| cut["OFFSET and LIMIT"]
cut -->|"the page"| inject["Re-inject subject and<br/>ENDPOINT columns"]
inject -->|"rows and columns[]"| out["RESULT_SET"]
ORDER BYwithLIMIT n: each node is sentLIMIT n, and the gateway orders the union and cuts it tonagain (§11.6.1, N39, CP-32). A node that returnednrows out of the Tier’s order isnode-error, because its cut may hide a row.OFFSET k: each node is sentLIMIT k + n, up to a window you configure, and the gateway slices the merged order (§11.6.2, N39).- Aggregates:
COUNT,SUM,MIN,MAXandAVGover several nodes come back as one row, recombined exactly; any other undirected aggregate is a400(§11.6.3, N14, CP-10). - De-duplication: off by default.
openEHR-federation-dedup: version-identitykeeps one copy of a version held at several nodes, the one from the CDR that created it, andmeta.federation.dedupnames the copies dropped (§10, N15, CP-9). - The subject column: a selected subject is the value you sent, put back by the gateway. No node is asked for it (N5, CP-7).
The status of each member
Every registry member appears in meta.federation.endpoints[] exactly once,
with one status from the set of §11.1 (N16, CP-11). The diagram shows how a
member reaches each status.
flowchart TD
m["A registry member"] -->|"node selection"| sel{"In scope?"}
sel -->|"left out or<br/>suspended"| excl["excluded"]
sel -->|"not a<br/>candidate"| nloc["not-localized"]
sel -->|"in scope"| pre{"Consent<br/>pre-filter"}
pre -->|"denied"| cden["consent-denied,<br/>no latency_ms"]
pre -->|"not denied"| res{"Resolver"}
res -->|"no ehr_id"| nres["not-resolved"]
res -->|"no answer"| nresf["not-resolved,<br/>with the error"]
res -->|"ehr_id"| sent["Sent the<br/>node query"]
sent -->|"rows"| act["active"]
sent -->|"no<br/>connection"| off["offline"]
sent -->|"deadline"| tout["time-out"]
sent -->|"error or<br/>unusable"| nerr["node-error"]
sent -->|"403, a listed<br/>consent code"| cref["consent-denied,<br/>with latency_ms"]
A member settled before dispatch carries no latency_ms, and a member the
gateway sent a request carries the time it observed (N40). That is why the
two consent-denied boxes differ. ITS-REST defines no consent signal, so a
node’s refusal is consent-denied only when it is a 403 whose ITS-REST
Error carries a code the registry lists for that endpoint in
consent_refusal_codes; every other refusal is node-error
(Consent). The list is empty by default,
and the key is FerroFED’s own design.
The status of the whole answer
The default is all-or-nothing: a node that was asked and did not give a
usable answer fails the query, and the failing answer still carries
meta.federation (§11.4, N37, CP-30). Best-effort is an opt-in per request
with openEHR-federation-completeness: partial.
flowchart TD
start["Every member settled,<br/>meta.federation built"] -->|"read the header"| mode{"completeness<br/>header"}
mode -->|"all, or none"| t{"In-scope node<br/>offline or<br/>time-out?"}
t -->|"yes"| s504["504 with<br/>meta.federation"]
t -->|"no"| e{"node-error, or<br/>resolver failed?"}
e -->|"yes"| s424["424 with<br/>meta.federation"]
e -->|"no"| s200["200 with every<br/>active node's rows"]
mode -->|"partial"| p200["200 with the<br/>rows that arrived"]
s200 -->|"set the flag"| c{"Every in-scope<br/>member active?"}
p200 -->|"set the flag"| c
c -->|"yes"| yes["complete: true"]
c -->|"no"| no["complete: false"]
not-resolvedandconsent-deniedare answers. They clearcompleteand never fail the query (§11.3, N6). A patient no member holds is a200with no rows.- A localizer that failed closed leaves every member
not-localized, so the answer is a200withcomplete: trueand no rows. Readmeta.federation.localization.errorto tell that apart from a patient no member holds (§14.1). excludedandnot-localizedmembers were never in scope, so they do not clearcomplete(§11.1).- Read
meta.federation.complete, never the status code: a200can carrycomplete: false(§11.4). - A
partialrequest for an aggregate the gateway recombines is a400, because a sum over the nodes that answered is a wrong value.
The client contract and Errors and status codes list every field and code.
Where the patient identifier stops
The patient identifier you put in a query is resolution input. The gateway
consumes it, resolves it to each node’s own ehr_id, and then locates every
node by that ehr_id alone. No part of a request the gateway composes for a
node carries the identifier: not the AQL, not the path, not the query
string, not a header (§5.4.1, N33). This is the central safety property of
the specification, and its adversarial test track (§16, track 10). A
pseudonym is handled exactly like a direct identifier (§5.3).
The identifier’s path through the gateway
The diagram shows §5.4.3 and §7.1 with the conformance points CP-2, CP-26 and CP-38. Two independent layers stop the identifier: the rewrite, which builds the node query from the parsed AST, and the outbound gate, which re-reads each finished request before it leaves.
flowchart TD
q["Your query:<br/>AQL and parameters"] -->|"parsed"| scan["Rewrite: find the<br/>patient on both carriers"]
scan -->|"cannot consume<br/>exactly"| refuse["400, names the<br/>position, never<br/>the value"]
scan -->|"consumed"| ref["Patient reference,<br/>redacted everywhere"]
ref -->|"ITI-83"| pix["PIX Manager"]
pix -->|"ehr_id per member"| rw["Rewrite: subject becomes<br/>e/ehr_id/value"]
rw -->|"one AST per node"| print["printer::to_aql"]
print -->|"node AQL"| gate["Outbound gate"]
gate -->|"a consumed<br/>value found"| stop["Not sent"]
gate -->|"clean"| node["Node: its own<br/>ehr_id only"]
- Both carriers resolve. The patient may sit in
EHR_STATUS.subject.external_ref, or in anENTRY-levelsubjectwith itsissuerortype. Both are resolution input on equal terms (§5.4.3, CP-38). - Strip or refuse, nothing in between. A value the gateway resolved on is
removed with its namespace. A second patient value, a subject under
OR,!=orLIKE, the subject inORDER BY, or the patient value anywhere else in the query is a400before any dispatch (§5.4.1, §5.4.3). - The value is the test. A clinician or facility identifier on
composerorhealth_care_facilityis ordinary query material and is sent (CP-2). The same value as the patient’s, on any path, is refused (CP-26). - Text the parser drops never travels. The node query is printed from the rewritten AST, so a comment or a string spliced around it cannot reach a node.
- A selected subject comes back from the gateway. The node is asked for
ehr_idrows, and the gateway re-injects the value you sent (N5, CP-7).
What the outbound gate reads
Every request to a node passes the gate, a federated query and a routed follow-up alike. The gate searches each part a node can read for every value resolution consumed, in plain and in escaped form. For a routed request it also decides which of the client’s own parts travel at all, from the parameters the matched ITS-REST operation declares.
flowchart LR
subgraph client["From the client"]
ch["Headers"]
cq["Query string"]
cb["Write body"]
end
subgraph composed["Composed by the gateway"]
aql["Node AQL<br/>and paging"]
path["URL path with<br/>the node's ehr_id"]
conv["openEHR-federation-client<br/>caller claims"]
end
ch -->|"declared"| gate["Outbound gate"]
ch -->|"Authorization,<br/>X-Request-Id,<br/>openEHR-federation-*"| held["Withheld"]
cq -->|"all declared"| gate
cq -->|"undeclared"| q400["400"]
cb -->|"byte for byte"| node["Node"]
aql -->|"searched"| gate
path -->|"searched"| gate
conv -->|"searched"| gate
gate -->|"clean"| node
gate -->|"value found"| stop["Not sent"]
- The URL authority and the
Hostheader come from your registry, never from a request, so the gate does not read them (§5.4.1). - The
openEHR-federation-clienttoken the gateway signs for each node carries claims about the verified caller. The gate reads each of them before it is signed, and a caller claim that would carry the identifier refuses the request with nothing sent (What a node is told about the caller). - A write body is clinical content. A
DV_IDENTIFIERinside a committedCOMPOSITIONreaches the node byte for byte, because the gateway has no right to alter it (§5.4 scope note, N22). GET {base}/v1/ehr?subject_id=…names the patient in its query string. The gateway resolves that subject and sends the nodeGET {base}/v1/ehr/{ehr_id}with no query string (Reading an EHR by subject).- An ask-all probe carries the path
ehr_id. It is sent only for anehr_idthat is a bare UUID, because any otherHIER_OBJECT_IDform could be a patient identifier, and the probe would send it to every member.
The gateway’s own surfaces
The rule covers what a node receives. FerroFED holds its own records to the same rule, because a log pipeline or a metrics store is read by people the patient never dealt with. No specification governs these surfaces: our own design.
| Surface | What it carries instead |
|---|---|
| Error bodies and refusals | the position of the offending path or leaf, never the value |
| The request log | method, route template, status, latency and request id; no body, AQL text, header value or request path |
| Metrics labels | closed enums and registry endpoint ids only |
A node’s error message quoted in meta.federation | the message with each consumed value masked |
| The stored-query store | parameterised AQL; a literal patient identifier is refused |
| Integrity incidents | the ehr_id and the claiming endpoints, never a patient identifier |
The identity resolution page configures the PIX Manager the identifier goes to.
Follow-ups and writes
A row of a federated answer leads to a follow-up: you read the composition, or you commit a new version of it. Every such request goes to exactly one node, unchanged, and its answer comes back as that node sent it (§12, N22, N31). Four identifiers decide which node, and §12a says which one governs which request:
| You want to | The key that routes it |
|---|---|
send a request under {base}/v1/ehr/{ehr_id} | the ehr_id, through the order below (§12.5.1, N41) |
| commit a new version of an existing object | the creating_system_id inside the version’s uid: only the CDR that created it may take the write (§12.4, N23) |
| create a new EHR | the endpoint you name, and nothing else (§12.4, N23) |
| know which node gave you a row | the row’s endpoint_id (§9.3) |
Routing a path ehr_id
An ehr_id carries no system component, so a path that names one does not
say which node holds the EHR (§12.5). The gateway asks five sources in a
fixed order and stops at the first that names exactly one node (§12.5.1 as
release 1.0.1 sets it, N41, CP-33). It never chooses between two claimants (§12.5.2, N42).
flowchart TD
req["Request under<br/>{base}/v1/ehr/{ehr_id}"] -->|"step 1"| s1{"Header names<br/>one endpoint?"}
s1 -->|"yes"| route["The owning node"]
s1 -->|"no, step 2"| s2{"Binding of<br/>this caller?"}
s2 -->|"one node"| route
s2 -->|"none, step 3"| s3{"ehr_id index?"}
s3 -->|"one node"| route
s3 -->|"two nodes"| c409["409 ehr-id-collision"]
s3 -->|"none, step 4"| s4{"Path names a version<br/>whose creating_system_id<br/>the registry maps?"}
s4 -->|"one node"| route
s4 -->|"no"| rw{"Read or write?"}
rw -->|"write"| t400["400 target-required"]
rw -->|"read, step 5"| probe["Ask-all probe:<br/>GET ehr/{ehr_id}<br/>at every member"]
probe -->|"one holder"| route
probe -->|"two holders"| c409
probe -->|"none"| n404["404 no-destination"]
route -->|"forwarded once"| answer["The node's answer"]
- Step 1 is the one you should use: the row you act on carried its
endpoint_id, so name it inopenEHR-federation-endpoint(§12.5.1). - Step 2, the resolution binding your earlier query left behind, is kept
per verified caller (issuer, subject and client), in memory, for
federation.binding_ttlafter the last resolution that returned it, and up tofederation.binding_capacitybindings over every caller; another caller never sees it, and a consent denial of a member drops the bindings naming it (§12.5.1, N41). - Step 3, the
ehr_idindex, is in memory and learns from resolutions and from the nodes’ successful answers. A miss costs one fallback step, never a wrong route. - Step 4 applies when the path carries a version uid, an
OBJECT_VERSION_ID: the member whosesystem_idis that uid’screating_system_id, as the registry maps it (a member’s ownsystem_idor a[[creating_system]]entry, never a route learned from answers). The gateway asks it only when steps 1 to 3 named no node, so it never sends yourehr_idto the creator while an earlier step named another node (§12.5.1). - Step 5, the probe, runs for reads only, and only for an
ehr_idthat is a bare UUID (§5.4.1). A member that fails or times out during the probe fails the read424or504, because the owner is then unknown. Two holders also raise an integrity incident for the operator (N42).
A read of one version, such as
GET {base}/v1/ehr/{ehr_id}/composition/{uid}, routes by its path ehr_id
the same way, because every ITS-REST version read is EHR-scoped (§12.3,
§12a.1, N22). When the node it reaches holds an imported copy, you get the
copy, and the openEHR-federation-system-id response header names that
node: a value that differs from the creating_system_id in the uid you
asked for tells you it is a copy. To read the original, address the
creator with its own ehr_id for the patient (§12.3, §7a.3).
A versioned write
A versioned write amends a version that exists. It routes by its path
ehr_id through steps 1 to 4, never through the probe, and is then held to
the version it names: the registry must map that version’s
creating_system_id to the routed node (§12.4, §12a.1, N23, CP-15). A
write that lands on a node holding only a copy is refused 409 naming the
controlling system, and is never redirected to the owner under the
holder’s ehr_id (§10.3 copy-write-reject, N36).
flowchart TD
w["PUT or DELETE naming<br/>a version, or a CONTRIBUTION"] -->|"read If-Match,<br/>path or body"| pv{"One preceding<br/>version each?"}
pv -->|"no"| b400["400 preceding-<br/>version-invalid"]
pv -->|"yes"| r["Route by the path<br/>ehr_id, steps 1 to 4"]
r -->|"the routed node"| ctl{"Registry maps the<br/>creating_system_id<br/>to that node?"}
ctl -->|"yes"| send["Forward once to<br/>the controlling CDR"]
ctl -->|"another node,<br/>or none known"| c409["409 controlling-system-<br/>unreachable, sent<br/>to no node"]
A route the gateway learned from answers never makes a node the controller, because a node that holds versions of a system need not have created them (§12.2). The sequence below is the hazard of §10.3: node A created a composition, node B holds an imported copy, and de-duplication kept node A’s row (N36, CP-29).
%%{init: {"sequence": {"actorMargin": 24}}}%%
sequenceDiagram
participant C as Client
participant G as Gateway
participant A as Node A
participant B as Node B
C->>G: PUT under B's ehr_id<br/>If-Match: …::node-a.example::1
Note over G: path routes to B,<br/>node-a.example is A
G-->>C: 409, names node A
Note over B: sent nothing
C->>G: the same PUT<br/>under A's ehr_id
G->>A: forward once
A-->>G: 200, ETag, Location
G-->>C: node A's answer
The gateway refuses without asking node A, so the refusal holds while node A is down too. It never falls back to writing at the copy.
Creating an EHR
A new EHR has no owner yet, so it goes only where you send it (§12.4, N23).
A PUT that chooses its own ehr_id is checked against what the gateway
already knows, so one ehr_id never lands at two members (§12.5.2).
flowchart TD
c["POST {base}/v1/ehr, or<br/>PUT {base}/v1/ehr/{ehr_id}"] -->|"targeting headers"| h{"Endpoints<br/>selected?"}
h -->|"none"| t400["400 target-required"]
h -->|"several"| s400["400 endpoint-several"]
h -->|"one"| known{"ehr_id known at<br/>another member?"}
known -->|"yes"| held["409 ehr-id-held,<br/>sent to no node"]
known -->|"no"| fwd["Forward to the<br/>endpoint you named"]
fwd -->|"on success"| idx["The ehr_id index<br/>learns the node"]
Two creates of one ehr_id that race past the check both reach their nodes.
The second to succeed raises the index-insert alarm of §12b.2, and from then
on a request for that ehr_id must name its endpoint.
The follow-ups page lists every header, code and case.
Definitions and stored queries
A template lives at the node it was uploaded to, and a COMPOSITION built on
it validates only there. A gateway that hid that from you would let you
upload a template at one node and commit against it at another (§12.6). So
the definition area goes to one node you name, with two opt-in exceptions
your deployment declares in OPTIONS {base}/: a template upload fanned out
to several members, and a stored-query registry the gateway holds itself
(§12.6, §12.7, N43, N44).
Where a definition request goes
The diagram shows §12.6 with N43 and CP-34.
flowchart TD
d["Request under<br/>{base}/v1/definition/"] -->|"which path?"| sq{"Stored query, with<br/>the registry on?"}
sq -->|"yes"| reg["The gateway's<br/>stored-query registry"]
sq -->|"no"| tgt{"Targeting<br/>headers"}
tgt -->|"one endpoint"| one["Forward to that node,<br/>its answer as sent"]
tgt -->|"none"| t400["400<br/>target-required"]
tgt -->|"several, a template<br/>upload, fan-out on"| fan["Send the upload to<br/>each named member"]
tgt -->|"several,<br/>otherwise"| bad["400"]
fan -->|"outcome per member"| out{"How many<br/>accepted?"}
out -->|"all"| s200["200"]
out -->|"some"| s207["207, complete: false,<br/>nothing rolled back"]
out -->|"none"| s5xx["504 or 424"]
- The gateway never picks a node for you and never merges two nodes’ template lists into one catalogue (§12.6, N43).
- Fan-out template upload is off by default and declared as
definition.fan_out_template_upload(§7a.2). A member that accepts keeps the template whatever the others answer, and a partial success is never reported as success (§12.6template-fanout). - The fan-out answer carries
meta.federationwith one entry per member, in the shape of a federated query’s (§9.5).
The stored-query registry
With [stored_queries] set, the gateway is authoritative for each stored
query (§12.7, N44, CP-40). A version is immutable: a second PUT of a held
name and version is refused, before and after a restart. You invoke a
stored query by name, and the gateway runs its AQL over the members exactly
as an inline query.
%%{init: {"sequence": {"actorMargin": 24}}}%%
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Stored-query store
participant A as Node A
C->>G: PUT /v1/definition/query/<br/>org.example::compositions/<br/>1.0.0
G->>S: insert if absent
S-->>G: stored, or held
G-->>C: 200 with Location,<br/>or 409 stored-query-held
C->>G: POST /v1/query/<br/>org.example::compositions
G->>S: highest version
S-->>G: the stored AQL
Note over G: resolve, rewrite<br/>per node
G->>A: AQL scoped to ehr_id A
A-->>G: rows
G-->>C: RESULT_SET, name<br/>of the stored query
- A definition names its patient through a
$parameter. A literal patient identifier is refused, because the store would hold it at rest (§5.4.1, N33). - The store is
redbfor one gateway, PostgreSQL for several replicas, or read-only files your operator publishes (Queries and API areas). Reads go through an in-memory cache of immutable versions, so no query waits on the store while it talks to nodes.
Distribution, drift and the repair
With federation.fan_out_stored_queries set beside the registry, a PUT
that names members in the targeting headers is stored first and then sent to
each named member, on the template fan-out’s four terms (§12.7
stored-query-fanout). A member’s copy can drift later: a failed
distribution, a local PUT at the node, a restore, or a member admitted
after the distribution (§12.7 stored-query-drift). An invocation always
runs the registry’s copy, so drift never changes an answer. The drift check
and the repair show it to you and fix it.
flowchart TD
chk["GET the version,<br/>naming members"] -->|"each member's copy"| cmp{"Same canonical<br/>AQL as the<br/>registry's?"}
cmp -->|"yes"| act["active"]
cmp -->|"differs"| diff["node-error<br/>definition-differs"]
cmp -->|"no copy"| miss["node-error<br/>definition-missing"]
diff -->|"operator names them"| rep["Admin listener:<br/>POST /admin/stored-queries/<br/>{name}/{version}/distribute"]
miss -->|"operator names them"| rep
rep -->|"the held copy"| nodes["The named members"]
The repair runs on the admin listener only, because the specification gives
drift repair no request and a second PUT of a held version is always
refused (§12.7, N44). No specification governs the repair action: our own
design. A definition that carries a FROM ENDPOINT directive is never
distributed, because a node cannot run a directive that names other members
(§12.7 fanout-endpoint-targeted-refused).
The stored queries and templates pages give every request and code.
Trust and keys
The gateway stands in two trust relationships. Callers trust it with their access tokens, and the nodes trust it to say who is asking. The specification requires both: the client authenticates to the gateway, the gateway authenticates onward to each node, and the client’s identity travels with every request (§13.1, N24, N25, CP-16, CP-17).
All three are built on main for v0.0.8: client authentication
(#80), OAuth 2.0 to
each node with the gateway’s own key
(#81) and the caller’s
identity conveyed to each node
(#82), with token
exchange per caller and DPoP-bound tokens where a node asks for them
(#439).
The gate
Every request to the ITS-REST surface, and OPTIONS {base}/, passes the
gate before anything else reads it: the caller’s access token is verified,
the operation’s scope and the purpose of use are checked, and a refused
request reaches no node (Client authentication).
flowchart TB
client["Client application"] -->|"HTTPS, Bearer<br/>access token"| proxy["Your reverse proxy:<br/>TLS"]
proxy -->|"HTTP"| gate["The gate: token,<br/>scope, purpose of use"]
gate -->|"401, 403, 503"| client
gate --> gw["FerroFED gateway"]
gw -->|"its own credential,<br/>the signed caller"| node["Node A"]
A caller’s Authorization header never reaches a node. A proxy that
authenticates callers itself uses the explicit edge mode, signing an
assertion the gate verifies. Each onward credential is a file named by a
_file key, sent only to its own endpoint.
Two trust relationships
flowchart TB
as["Callers' authorization servers<br/>one signing key each"]
subgraph gwbox["FerroFED gateway"]
trust["Trust list<br/>of issuers"]
key["Gateway key pair, ES256 or ES384<br/>private half: a _file secret"]
jwks["{base}/.well-known/jwks.json<br/>current and previous kid"]
end
subgraph nodebox["Member node A"]
tok["Token endpoint"]
cdr["CDR"]
end
as -->|"each issuer's JWKS"| trust
key -->|"public half"| jwks
jwks -->|"verifies the<br/>client assertion"| tok
jwks -->|"verifies openEHR-<br/>federation-client"| cdr
tok -->|"access token"| cdr
- Callers to the gateway (#80): each calling organisation’s authorization server signs its own access tokens. The gateway keeps a trust list of issuers and reads each issuer’s JWKS, so it holds many public key sets and no caller’s private key.
- The gateway to each node (#81,
§13.1, N25, N30): the gateway has one key pair of its own, on P-256
(ES256) or P-384 (ES384). A deployment whose nodes hold to the FAPI 2.0
Security Profile uses P-256, since that profile admits ES256 and not
ES384 (§5.4.1). The private half is a secret file; the public half is
published at
{base}/.well-known/jwks.jsonand declared asfederation.auth.jwks_uriinOPTIONS {base}/, so a node finds it without an out-of-band arrangement (§13.1jwks-discovery). A rotation keeps the current and the previouskidin the set for one overlap window. The gateway needs no node’s key for this. - The identity sent to the node (#82):
the
openEHR-federation-clientheader is a JWS signed with the same gateway key, so the node verifies it against the same JWKS.
The token flow
%%{init: {"sequence": {"actorMargin": 24}}}%%
sequenceDiagram
participant AS as Caller's AS
participant C as Client
participant G as Gateway
participant T as Node A token<br/>endpoint
participant N as Node A
C->>AS: token request
AS-->>C: JWT, aud: the gateway
C->>G: request, Bearer<br/>(caller token)
G->>AS: read JWKS, cached
Note over G: verify alg, iss,<br/>aud, exp, scope,<br/>purpose of use
G->>T: client credentials, or the<br/>caller token exchanged,<br/>RFC 7523 assertion
T->>G: read the gateway JWKS
T-->>G: node token, cached
G->>N: request, Bearer (node token),<br/>openEHR-federation-client
N-->>G: answer
G-->>C: answer
Checking the caller (#80).
The access token is a JWT (RFC 9068), checked against the issuer’s JWKS,
and every check fails closed. The issuer must be on the trust list, and the
algorithm on an allow-list (ES256, ES384, PS256, RS256), so none and HMAC
are refused (RFC 8725). The audience must name this gateway, and the token
must not have expired. Any of those is a 401. The scopes are read in the
SMART on openEHR grammar with openehr-sdt, and a token without a scope
that covers the operation is a 403. A token without a purpose of use is a
403 too, unless your deployment declares it optional (§13.4). For a
deployment whose proxy already authenticates callers, the edge mode is
configured explicitly: the proxy signs an assertion of the caller, the
gateway verifies it like a token and records which identity the proxy
asserted. A patient/ scope grants nothing at the gateway, because no
token claim defines the patient in a form the gateway can confine across
nodes, unless the deployment binds the token’s issuer to one member. The
gateway then resolves the token’s ehrId as an identifier of that member
through the cross-reference service (§5.2), sends only to the patient’s own
{node, ehr_id} pairs, and tells each node the patient’s ehr_id there
(Patient grants).
Authenticating to the node (#81,
§13.1, N25). The gateway sends the node’s token endpoint an OAuth 2.0 client
credentials request with a client assertion of RFC 7523 §2.2. The assertion
lives 300 seconds at most, carries a unique jti, and names the node’s
token endpoint as its audience. The token endpoint verifies it against the
gateway’s JWKS and issues an access token. The gateway caches that token
per node until 30 seconds before it expires, and drops it on a 401. A
token it cannot obtain fails that node node-error; the gateway never
sends a request without one.
A token per caller, and a token bound to a key
(#439). Where a node’s
authorization server supports token exchange (RFC 8693), the gateway asks
it for a token per verified caller instead: the caller’s token is the
subject, a second assertion of the gateway the actor, the node the
resource (RFC 8707), and the scope only the caller’s scopes that cover the
operation (N26). The node then sees the caller in its own token as well as
in the signed statement below. A caller the edge asserted has no token of
its own, so that node fails node-error with nothing sent. Where a
deployment requires sender-constrained tokens, a grant binds its tokens to
a key of the gateway’s with DPoP (RFC 9449): every request to that node
and its token endpoint carries a proof of the key over its method and URL,
and a node that demands a nonce is answered once more with it.
Telling the node who asks (#82,
N24, §12.4). Every request to a node carries openEHR-federation-client: a
query, a routed read or write, a definition request, the ask-all probe and
the read of an EHR by subject alike. It is a JWS signed with the
gateway key, ES256 or ES384 as the key’s curve says, typed
openehr-federation-client+jwt, that lives 60 seconds,
addressed (aud) to that node’s endpoint id. It names the verified caller
(sub) and its issuer (iss_upstream), how the gateway verified it
(verified_by, edge for an identity the edge asserted), the caller’s
organisation, the purpose of use and the caller’s scopes as granted. It
never carries a patient identifier (N33): the outbound gate reads every
caller claim, and a request whose claims would carry the identifier the
query was resolved on is refused before it is sent. A request that reaches
the dispatcher with no verified caller is a 500, with nothing sent. The
gateway’s own requests for its operator, the admission check and the
redistribution of a held stored query, name the gateway itself as sub.
The specification leaves end-user conveyance open (§13.1), so the header
and its claims are FerroFED’s own; a node verifies it as
Verifying it at the node
describes.
The caller’s token is never forwarded
The caller’s token was issued for the gateway: its audience is the gateway. Forwarding it would hand every node a credential that unlocks every other member that accepts the same issuer (RFC 9700 §2.3), and a node could replay it at its neighbours. So the gateway authenticates to each node as itself, with a token the node’s own authorization server issued, and conveys the caller as a signed statement the node can verify. Under token exchange the caller’s token reaches the node’s authorization server, which exchanges it, and never the node. It never asks a node for more than the caller holds: the caller’s scopes are enforced at the gateway and conveyed, so the node applies them too, and an exchanged token is asked for only the scopes that cover the operation (N26). Consent stays the node’s decision either way (§13.2, N27).
The §13.4 decisions a deployment must publish, such as which identity is verified across each boundary and who authenticates the end user, are answered for the gateway, with a template for the rest, in The §13.4 deployment decisions (#84, CP-39).
The Nuts track of the Dutch binding (Annex B §B.4) is built: an endpoint
whose [credentials] name a nuts grant obtains its token with a
Verifiable Presentation of the gateway’s credentials, bound to a key of the
gateway’s with DPoP (The Nuts grant,
#88). So is the
harmonised BgZ and eOverdracht track (Annex B §B.4a): an endpoint whose
oauth grant sets security_profile = "fapi2" discovers an authorization server
under the FAPI 2.0 Security Profile, authenticates with an ES256
private_key_jwt assertion naming its issuer, sends the configured Rich
Authorization Requests authorization_details, and binds its token with
DPoP (The FAPI 2.0 grant,
#497).
Deployment
The gateway is one static binary in one container image. What it needs around it is the roles of §4.1: member CDRs over ITS-REST, an identifier cross-reference, and the registry that addresses each member (§15, N21). This page draws the two shapes the book describes: the quickstart you run on one machine, and a production layout. No specification governs the process model or the topology: our own design.
The quickstart
The repository’s compose.yaml runs the gateway beside four member CDRs, four
FerroEHR instances, on one PostgreSQL server that holds a database per node
(The quickstart). The gateway runs
in the development profile and resolves every patient through FerroPIX, the
FerroHEALTH patient index, which Compose runs beside it on its own
PostgreSQL and seeds with four synthetic patients over ITI-104 before the
gateway starts; a one-shot seed creates their EHRs at the nodes. It trusts
one development issuer, a service whose network namespace the gateway
shares, and signs with a key you write once beside compose.yaml
(The quickstart issuer).
flowchart TB
you["You: curl"]
keys["./secrets:<br/>signing key"]
subgraph compose["docker compose"]
iss["issuer, :8190<br/>tokens"]
gw["ferrofed, :8080<br/>in the issuer's namespace"]
ehrseed["ehr-seed<br/>ITS-REST, once"]
pix["ferropix<br/>PIXm Manager"]
seed["ferropix-seed<br/>ITI-104, once"]
subgraph nodes["Four member CDRs"]
a["ferroehr-a<br/>:8081"]
b["ferroehr-b<br/>:8082"]
c["ferroehr-c<br/>:8083"]
d["ferroehr-d<br/>:8084"]
end
pg[("ferroehr-postgres<br/>ferroehr_a to ferroehr_d")]
end
you -->|"client credentials"| iss
you -->|"AQL, Bearer"| gw
keys -->|"a secret"| gw
ehrseed -->|"seed over ITS-REST"| nodes
gw -->|"AQL, Basic auth,<br/>the signed caller"| nodes
gw -->|"ITI-83"| pix
seed -->|"ITI-104"| pix
nodes -->|"a database each"| pg
- Every port binds the loopback interface, so nothing is reachable from the network until you name an interface.
- The seed writes over each node’s ITS-REST API only, with
identifiers in the example arc
urn:oid:2.999. - The node credentials and database passwords are development values that must not reach anything real.
A production layout
In production the gateway sits behind your reverse proxy, from the release’s
compose.yaml or the Kubernetes example (The gateway from a
release,
Kubernetes). It resolves patients
through your PIX Manager and, when you configure them, asks a PDQm Supplier
first, localizes through XCPD or the Dutch NVI, and pre-filters on consent
through Mitz.
flowchart TB
clients["Client applications"] -->|"HTTPS, Bearer"| proxy["Your reverse proxy"]
proxy -->|"HTTP"| gw["FerroFED gateway"]
cfg["ferrofed.toml, registry,<br/>secret files, signing key"] -->|"start, SIGHUP"| gw
issuers["Callers' issuers"] -->|"key sets"| gw
subgraph identity["Step 1 services"]
pdq["PDQm Supplier<br/>ITI-78, ITI-119"]
pix["PIX Manager<br/>ITI-83"]
xcpd["XCPD responding<br/>gateways, ITI-55"]
nvi["NVI<br/>Dutch localization"]
mitz["Mitz<br/>consent pre-filter"]
mcsd["mCSD directory<br/>ITI-90, ITI-91"]
end
subgraph member["Each member"]
tok["Token<br/>endpoint"]
cdr["CDR"]
end
subgraph ops["Your operations"]
store[("redb, PostgreSQL<br/>or files")]
logs["Log pipeline,<br/>ferrofed::audit"]
spool[("Audit spool,<br/>encrypted volume")]
arr["ATNA Audit Record<br/>Repository"]
admin["Admin listener<br/>/metrics, /admin"]
end
gw --> identity
gw -->|"assertion"| tok
gw -->|"node token,<br/>signed caller"| cdr
gw -->|"definitions"| store
gw -->|"events"| logs
gw -->|"IHE audit<br/>records"| spool
spool -->|"ITI-20, syslog<br/>or FHIR Feed"| arr
gw -->|"serves"| admin
pmir["PMIR Patient<br/>Identity Registry"] -->|"ITI-93 feed"| gw
gw -->|"ITI-94<br/>subscription"| pmir
prom["Prometheus"] -->|"scrapes"| admin
gw -->|"spans, OTLP"| otel["OpenTelemetry<br/>collector"]
- The proxy terminates TLS. The gateway authenticates each caller itself, against the key sets of the callers’ issuers, or verifies the assertion of a proxy in the explicit edge mode (Client authentication).
- The configuration is your reviewed files. The registry reloads on
SIGHUPwith no restart, and every credential and the signing key is a file named by a_filekey (Configuration). - The Step 1 services. The PIX Manager resolves each patient to an
ehr_idper member, and without[xcpd]or[nl_gf.nvi]it is the localizer too. The XCPD responding gateways localize when you configure[xcpd], and the NVI when you configure[nl_gf.nvi]. A PDQm Supplier, under[pdqm], names the master identity of a patient the cross-reference does not know by the client’s identifier, and Mitz, under[nl_gf.mitz], is the consent pre-filter (Identity resolution). The mCSD directory, when you read the registry from one instead of a document, is asked with ITI-90 at start and with ITI-91 every refresh interval, off the clinical path (The registry). - Each member’s token endpoint, where its
oauthsection names one, issues the gateway an access token for a client assertion its client key signs, and checks that assertion against{base}/.well-known/jwks.json. With anonwardtable beside it the token is issued per verified caller, and with a DPoP key it is bound to the gateway’s key. Anutssection, or anoauthsection under the FAPI 2.0 profile, obtains the token on a track of the Dutch binding instead. A member without one is sent its static credential, if it has one (Trust and keys, Onward credentials). - The audit of each IHE transaction goes to your ATNA Audit Record
Repository: written to a spool on disk first, then sent with ITI-20, so a
repository outage delays the audit and fails no query. An XCPD exchange,
under
[xcpd] audit = "repository", is a DICOM audit message over syslog on TLS; the PIXm, PDQm, mCSD and PMIR transactions, under[audit], are FHIRAuditEvents sent with the FHIR Feed. The spool holds audit records that name patients, so it belongs on an encrypted volume (The audit trail). Under[xcpd] audit = "log"or[audit] destination = "log"the records go to the log targetferrofed::auditinstead, without the patient or the caller; a production gateway with a registry refuseslogfor[audit], because its access records must name both. - The PMIR Patient Identity Registry, under
[pmir], takes the gateway’s ITI-94 subscription and sends each identity change to the gateway’s feed route, which drops the resolution bindings the change could have made stale (The identity feed). - The admin listener is a second listener for your operators, off unless
[metrics] listenis set and on loopback unless you allow otherwise (Metrics). - The OpenTelemetry collector, when
[telemetry.otlp]names one, receives the gateway’s spans and metrics over OTLP (Tracing, Metrics).
Several replicas
Replicas share nothing in memory: each holds its own resolution bindings,
ehr_id index and learned routes, and a miss costs a probe or an explicit
target, never a wrong route. A follow-up write is never probed for, so a
write that names no node is routed only by the replica that resolved the
patient and refused 400 target-required by the others: clients name the
node on every write, or the balancer keeps each client on one replica
(Running several replicas).
The stored-query registry is the one state they must share,
because a stored version must be the same on every replica and a second
PUT refused on every replica (§12.7, N44).
flowchart TB
lb["Your proxy or<br/>Kubernetes Service"] -->|"any request"| r1["Replica 1<br/>own index in memory"]
lb -->|"any request"| r2["Replica 2<br/>own index in memory"]
r1 -->|"stored queries"| pg[("One shared<br/>PostgreSQL database")]
r2 -->|"stored queries"| pg
r1 -->|"node queries"| nodes["Member CDRs"]
r2 -->|"node queries"| nodes
A redb file opens in one process at a time, so replicas that store queries
use the postgres backend, or the read-only files backend when your
operator publishes the definitions
(Running several replicas).
The Kubernetes example runs two replicas under a PodDisruptionBudget.
Replicas behind one address share the PMIR subscription that names it, and a draining replica leaves it for the others (Several replicas). A new signing key is published by every replica before any replica signs with it (Rotating the signing key).
The Federation Tier with AQL
This page is a plain account of the specification FerroFED implements. It
cites the sections of the vendored text at
docs/specs/federation-spec/
and adds nothing of FerroFED’s own; the next page does that.
Three tiers
The specification adds a Federation Tier to openEHR querying, so that a client can run one query across several openEHR CDRs as though they were a single repository (§1). The system has three tiers (§3.1):
- the application tier, where a client sends AQL and reads the combined result;
- the Federation Tier, one or more gateways that act as the intermediary;
- the node tier, member CDRs that run ordinary openEHR queries scoped to a single EHR and need not know they belong to a federation.
The gateway is a technically transparent intermediary (§3.2). The client interface is a conformant openEHR Query API, and a conformant ITS-REST surface generally, so a basic patient query needs no federation-specific syntax.
The flow
Federation keys on each node’s local ehr_id. Patient identity is resolved
outside the query, and each node receives standard AQL scoped to its own
ehr_id (§4).
flowchart LR
C[Client] -->|"AQL with a subject predicate"| G[Gateway]
G -->|"patient identifier"| X[Cross-reference service]
X -->|"ehr_id per node, or not found"| G
G -->|"standard AQL scoped to ehr_id A"| A[Node A]
G -->|"standard AQL scoped to ehr_id B"| B[Node B]
A -->|rows| G
B -->|rows| G
G -->|"one RESULT_SET with meta.federation"| C
- The façade. The client sends
POST {base}/v1/query/aqland may identify the patient the openEHR way, withWHERE e/ehr_status/subject/external_ref/id/value = <patientId>(§4.1, §7). - Resolution. The gateway resolves the identifier to a set of
{node, ehr_id}pairs through a localization service, a directory and an identifier cross-reference service. The proposed bindings are IHE XCPD, mCSD and PIXm (§4.1, §5, Annex A). Without a localization service the gateway asks every known node (§4.3). - Fan-out. Each node with a resolved
ehr_idreceives the node AQL: the subject predicate replaced bye/ehr_id/value = '<resolved>', every other patient identifier removed or the query refused, and everything else forwarded unchanged (§7.1). - Combine. The gateway concatenates the rows, applies
DISTINCTandORDER BYacross nodes, and reports every node it asked undermeta.federation.endpoints[]with a status (§9, §11.1).
What the specification insists on
- No identifier leaves the gateway. The patient identifier used for resolution is consumed at the gateway and never reaches a node, in the query, its path or its headers (§5.4, N33).
- A partial answer is never presented as a whole one. When a node that was
asked does not answer, the default is to fail the query, and every response
carries
meta.federation.complete(§3.2, §11.4). - Consent stays at the node. A node enforces consent before it releases data; a gateway pre-filter is optional and never replaces that check (§1, N27).
- Follow-up reads and writes go to the owning CDR. The registry maps each
creating_system_idto its CDR, and a versioned write goes only to the CDR that created the version (§12.2, §12.4, N21, N23). A request under a pathehr_idis routed to the node that holds that EHR (§12.5). - Transparency is bounded. Where a gateway cannot behave as one
repository (federated demographics, object creation across nodes,
OFFSETpaging, undirected aggregates), it says so inOPTIONSor refuses the request, and never returns an approximation the client cannot detect (§3.2). A new object is created at one node the client names (§2.3, N23).
The specification closes with a consolidated list of numbered conformance points (§17) and a Connectathon-style test approach (§16).
What FerroFED claims
FerroFED claims what a release ships and a test holds. This page lists the standing decisions and what each release shipped, and the limitations to know before you deploy it. The changelog has the detail of every change.
Decided
The architecture of record was decided on 2026-10-01 and is
docs/architecture.md.
These decisions hold across every release:
- The specification is the authority. The Federation Tier with AQL text decides; its reference implementation is read as evidence, never as an oracle. Where the specification is silent, the decision is FerroFED’s own and is labelled that way in the code and in this book.
- The openEHR surface comes from the published
openehr-*crates. The ITS-REST contract, the AQL parser and printer, the RM and the typed identifiers are those crates. A gap in one of them is fixed in that crate. - No clinical data of its own. The gateway holds the registry, the
ehr_idindex and the stored-query definitions it is authoritative for. The record stays on the nodes. - Identifier hygiene is a hard rule. Nothing the gateway composes for a node carries a directly identifying patient identifier, and every carrier the specification names has a negative test (§5.4, N33).
- Pure Rust, a single binary, as across the FerroHEALTH family.
Shipped
v0.0.3: the federated query and identity resolution
POST {base}/v1/query/aqlanswers one ITS-RESTRESULT_SETover every member of a registry, with no federation syntax needed (N1, CP-1).- The patient is resolved outside AQL through an IHE PIXm PIX Manager
(ITI-83) on either patient-identifier carrier, and each member that knows
the patient receives standard AQL keyed on its own
ehr_id(§5, §7.1, N7). - The rewrite refuses a query that would carry the identifier to a node, and an outbound gate checks every request again before it leaves (§5.4, CP-26).
v0.0.6: the federated answer, routing and targeting
v0.0.6 carries the milestones v0.0.4, v0.0.5 and v0.0.6; no v0.0.4 or v0.0.5 was tagged.
- The full per-endpoint report in
meta.federation, all-or-nothing completeness with the best-effort opt-in, the per-node and overall budgets withPrefer: wait, and the §11.2 statuses with stable error codes (§9.5, §11.1 to §11.5). ORDER BYwithLIMITre-applied at the Tier, boundedOFFSETpages,SELECT DISTINCT, recombinedCOUNT,SUM,MIN,MAXandAVG, and opt-in version-identity de-duplication (§10, §11.6).- Routing of the EHR resources under a path
ehr_idto the one node that holds the EHR, byte for byte, through the targeting headers, theehr_idindex and the ask-all probe; thecreating_system_idmap learned from every answer (§7a.1, §12.2, §12.5). - The
FROM ENDPOINTandORGANISATIONdirective, the targeting headers, and ENDPOINT attributes in rows (§8, §9.3). - The registry document in FHIR form,
OPTIONS {base}/, and the stored-query registry with immutable versions invoked by name (N19, §7a.2, §12.7). - Track 10, the identifier-leakage suite, run against two FerroEHR nodes.
v0.0.7: definitions and membership
Released on 2026-10-03.
- Definition requests routed to one named node, the opt-in fan-out template upload, and stored-query distribution with drift reporting and an operator’s repair (§12.6, §12.7, N43, N44).
- Versioned writes,
CONTRIBUTIONs included, sent only to the CDR that controls the version; a new EHR created only at a named node;ehr_idcollisions refused and raised as integrity incidents (§12.4, §12.5.2, §12a.1, N23, N42). GET {base}/v1/ehrby subject, theGETforms of query execution, bothehr_idforms of N29, the provenance headers, a configurable base path, and the DEMOGRAPHIC area routed to one declared endpoint (§4.1, §7a.3, N28, N29, N31, N32).ferrofed admission checkfor the identifier-integrity conditions of §12b.2 (N42a), the registry reload onSIGHUP, the stored-query registry onredb, PostgreSQL or read-only files, health probes, and metrics.
v0.0.8: security and the bindings
Released on 2026-10-04. It carries §13 to §15, Annex A and Annex B.
- Client authentication at the gateway: RFC 9068 access tokens checked by key set or by introspection, SMART on openEHR scopes per route, the purpose of use, and an explicit edge mode for a proxy that authenticates callers (§13.1, N25, CP-17; #80, Client authentication).
- OAuth 2.0 client credentials to each node with an RFC 7523 assertion
signed ES384, the node tokens cached, and the gateway’s JWK Set published
at
{base}/.well-known/jwks.json(§13.1, N25, N30, CP-17; #81). - RFC 8693 token exchange per endpoint, which gives each verified caller a
token of its own at a node, and node tokens bound to a key of the
gateway’s with DPoP (RFC 9449), the proof written and a node’s nonce
answered by the
openehr-its0.0.87 client (#439, #448, Onward credentials). - The two authentication tracks of the Dutch binding as onward credentials:
the Nuts grant, a Verifiable Presentation of the gateway’s credentials for
a DPoP-bound token (Annex B §B.4, Nuts RFC021), and the FAPI 2.0 grant of
the BgZ/eOverdracht track: client credentials or token exchange with a
private_key_jwtassertion, DPoP-bound tokens, and the healthcare attributes in an RFC 9396authorization_detailsobject (Annex B §B.4a) (#88, #497, The Nuts grant, The FAPI 2.0 grant). - A
patient/grant honoured only for an issuer the deployment binds to one member: the token’sehrIdresolved through the cross-reference at every member (§5.2), every request held to the patient’s own{node, ehr_id}pairs with nothing sent beyond them, and each node told the patient’sehr_idthere; a bareehrIdis never compared across members (§12.5, N26; #443, Patient grants). - The caller’s identity conveyed on every request to a node, in a token the gateway signs for that node and a node verifies against the published JWK Set (§13.1, N24, CP-16; #82, What a node is told about the caller).
- Consent left to the node, with the optional Step-1 pre-filter on every
patient route, the read of an EHR by subject included, its outage carried
in
meta.federation, on the health report and in the metrics, and a node’s own refusal reported asconsent-denied(§13.2, N27, N27a; #83, #399, #400). - Mitz as that pre-filter in the Netherlands: the closed authorization
question asked once per data holder, a member Mitz denies reported
consent-deniedand never asked (Annex B §B.6, N27a; #475, Dutch consent). - The §13.4 deployment decisions, answered for the gateway, with an operator’s template for the rest (CP-39; #84, The §13.4 deployment decisions).
- Undirected patient queries localized by IHE XCPD ITI-55 or by the PIXm
resolver, which shares one ITI-83 answer between localization and
resolution. A member no localizer names is
not-localizedand never asked, and a localizer that does not answer fails closed, with its error on every member and inmeta.federation. The read of an EHR by subject is localized too, the localizer is on the health report and in the metrics, and every XCPD exchange is audited to a log target (N4, N10, §14.1, §14.2, Annex A.3, CP-5; #85, #408, #409, #410). - NVI localization in the Netherlands: an undirected patient query asks the national index which care providers hold data for the patient’s pseudonym, and the members that hold those providers’ data are the candidates, with the custodian map read from the URAs a care services directory with the LRZa as its source publishes (Annex B §B.1, §B.2; #87, Dutch localization).
- A PDQm step ahead of localization and resolution, for a patient named by an identifier the cross-reference does not know: the Patient Demographics Supplier is asked with ITI-78 or ITI-119 for the patient’s identifier in the master domain, and an ambiguous match is never picked (Annex A §A.2, §A.7; #487, Demographics first).
- The XCPD audit sent to an ATNA Audit Record Repository with ITI-20: the DICOM PS3.15 audit message in RFC 5424 syslog over TLS, stored in a bounded spool on disk first and delivered from it, so a repository outage fails no discovery and a full spool fails it closed (ITI TF-2 §3.20, §3.55.5.1.1; #418, #512, The audit repository).
- Every other IHE transaction the gateway makes or receives audited as its
profile requires: ITI-83, ITI-78, ITI-119, ITI-90, ITI-91, ITI-93 and
ITI-94, each as a FHIR
AuditEventon the IHE Basic Audit Log Patterns (BALP), sent to the repository with the FHIR Feed of ITI-20 through the same spool, and a transaction whose record cannot be stored failed (#486, #469, The audit trail). - The registry read from an IHE mCSD care services directory with ITI-90 and kept in step with ITI-91, a shared directory included, with in-scope deletions recorded in the replica so a dangling listing is judged the same at a start and on a refresh, and a deleted endpoint dropped (§15.1, §15.2, N21, Annex A.5; #86, #423, #433).
- The §12.5.1 resolution bindings kept per verified caller (N41; #412).
- The identity lifecycle over IHE PMIR: an ITI-94 subscription at a Patient Identity Registry, and an authenticated ITI-93 feed whose merges and deletes drop the resolution bindings they could have made stale; track 8 stays deferred, since the specification marks it provisional (Annex A.4; #147, The identity feed).
- A credential or a patient identifier sent only over
httpsoutside the development profile, and the stored-query store’s PostgreSQL password only over TLS (#402, #416). - A Kubernetes example that starts, checked in CI (#428).
- Traces exported through OpenTelemetry over OTLP, with a sample ratio and a
traceparentof the gateway’s own trace on every node request; nothing of a client’s trace context is recorded, so no client-chosen trace id reaches the collector (#353, #437, #446, Tracing). - The texts this work cites, vendored with their provenance: the SMART on openEHR source and the IHE IUA supplement (#414), PMIR, BALP, the Dutch Generic Functions IG, the Nuts specifications, and the country research corpora (#498). The Mitz documents state no licence, so they are pinned by digest and fetched into a local cache, never committed (#501).
- One client build and one TLS type for every outbound identity client, one credential mapping per credentials section, and mutual TLS toward the PIX Manager, the PDQm Supplier, the PMIR Registry and the mCSD directory (#507).
- An audit record still queued behind another spool write when its exchange’s time runs out is written once that write ends, and delivered in the order it was queued (#532).
v0.0.9: conformance and the operator console
Released on 2026-10-05. It carries §16 and §17.
- Every conformance point of §17 scored by a marked test, or deferred by a recorded decision with its actor and reason, and the conformance statement that claims the Federation-Gateway profile from that matrix (#89, #95).
- The Connectathon tracks 1 to 7 and 9 to 11 of §16.3 as runnable scenarios against two FerroEHR nodes, with node-side wire capture for track 10 and a per-track and per-point report (§16.4; #90, #91, #92).
- The Federation-Node profile of §16.2 checked against FerroEHR and EHRbase (#93, #549).
- A differential run against the reference implementation, each difference judged against the specification text (#94).
ferrofed conformance run, which scores your own deployment (#546, #573, Scoring a deployment).- The operator console’s skeleton and its operator views, over a read-only operator surface on the gateway (#275, #276, The operator console).
- The console’s query console, signing out, and its browser journeys in CI (#274, #277, #584, #608).
- Each regional binding one module behind one feature,
binding-iheandbinding-nl, wired through oneBindingtrait, with the RFC 8414 checks from the family’sferrohealth-smart(#489, #551, #1342). - Mutual TLS to a node with RFC 8705 client authentication and certificate-bound tokens, with a token endpoint alias accepted on a host the operator names (#492, #560, Mutual TLS to a node).
- An ES256
[signing]key beside ES384, a FAPI 2.0 client key rotated with an overlap, the Nuts grant toward the NVI, and the gateway’s did:web DID document (#513, #514, #539, #503). - A deployment setting that keeps a consent exclusion out of the answer, and a pre-filter that says it did not ask, with a closed reason (N27a; #493, #496, #568, Consent exclusions).
- PIXm ITI-83 sent as
POST, so the patient identifier stays out of the request URL, and the verified caller named as the user agent of each IHE audit record (#494, #500). - FerroFED’s intended purpose and its classification as an EHR system under the EHDS Regulation (#520, Regulatory status).
v0.0.10: EHDS readiness and the production gaps
Released on 2026-10-15. It implements the 1.0.0 release of the specification (#17).
- Every access to patient data recorded with the verified caller, the
professional, the assurance level the issuer states, the client’s
address behind a trusted proxy, the categories of the data per origin and
the period the record is kept, as Annex II, point 3.2, of Regulation (EU)
2025/327 asks of the European logging component, and
ehds-loggingas a library, published from the FerroHEALTH audit library (FerroAUDIT) (#623, #742, #722, #694, #795, The access log). - The FHIR face: the patient summary in the HL7 Europe exchange format, the
document list and the read of a listed document, and the receipt of a
patient summary document into the member you declare for it, with
the published
eehrxfcrate as a library (#809, #810, #802, #803, The patient summary over FHIR, Receiving a document). - The information sheet, the instructions for use, the clinical safety risk file, this page’s claims review, and the post-market and Cyber Resilience Act procedures, with security fixes published for five years after each release, only in the newest release (#670, #673, #674, #672, #762, #763).
- Identity resolution in production through a PIX Manager the members feed (#622, Identity resolution).
- Restarts with no dropped request, several replicas, inbound metrics behind an authenticated admin listener, overload protection and TLS on the listeners (#625, #626, #627, #629, #635, #631, #632).
- The production, upgrade, data-protection, threat-model, hardening and troubleshooting pages (#624, #630, #633, #634, #640, A production deployment).
v0.0.11: prescriptions, configuration revisions and the console
Released on 2026-10-16.
- Electronic prescriptions and dispensations on the FHIR face, in the HL7 Europe MPD profiles, passed through from the members that serve them: the search and the read of a patient’s prescriptions, and a received dispensation written to the member that owns its prescription and read back, with no dispensability, count or lock computed at the gateway (FerroHEALTH/FerroBRIDGE#1321, Prescriptions and dispensations over FHIR).
- A received document written under a person’s own grant, or under a representative’s, marked as theirs in every entry and in its access record, and a person’s own patient summary through an electronic health data access service (#992, #1003, #991).
- Access records that carry a security label per category and origin, name the patients a population query read up to a bound, name the version a write and the EHR a create left at the member, and log each semantic transformation the FHIR face applies (#797, #819, #946, #983, #985, Labelling the access records).
- The export of the routing state a gateway learned, for a system that replaces it (Annex II, point 2.6; #798, Exporting the identity state).
- Configuration as versioned revisions under
config.ui = "managed": saved, compared, applied, confirmed and rolled back on the admin listener and from the operator console, exported and imported as TOML, and followed by every replica on the store;[auth]and the listener certificates reload without a restart (#576, #577, #578, #579, #954, #636, Configuration revisions). - The operator console redesigned, with a dashboard, a page per member, the routing table, the stored queries by namespace, the query console as an editor and a results card, the configuration in tabs, a light and a dark theme, and the operator’s two admin actions (#1074, #909, The operator console).
- The Annex II checklist, the technical documentation with its Annex III index, the EU declaration of conformity written for each release once the manufacturer issues it, the register of economic operators, the rights of natural persons, the cybersecurity risk assessment, the CRA information to the user, the decommissioning page, and security fixes as patch releases of the newest release (#941, #671, #940, #787, #961, #1058, #1059, #1063, #1064, Annex II checklist, Technical documentation).
- The
openehr-*crates at 0.0.87 with FHIRconnect 0.1.117 on the same release (#714).
v0.0.12: the console rebuilt on FerroUI, and every finding judged
Released on 2026-10-24.
- The operator console rebuilt on FerroUI 0.3.6 as
ferrofed-console, its own imageghcr.io/ferrohealth/ferrofed-console: every page drawn on the server from FerroUI’s components and every action a form post that works with no WebAssembly, the pages of FerroUI’s reviewed FerroFED design, and every word from a catalog, English the one language that ships (#1219, #1230, #1233, #1162, The operator console). - Client authentication, the SMART on openEHR grants and the gateway’s own
OAuth 2.0 clients on
ferrohealth-smart, the resource-server crate the FerroHEALTH products share, with every static bearer token removed: a machine authenticates with HTTP Basic or the client credentials grant (#1174, #1163, #1186, Client authentication). - The audit trail and the access record from the published FerroAUDIT crates, and the identity crates from FerroPIX, with what the gateway records unchanged (#1177, #1211, #367).
- The patient summary’s term-mapping rule: a mapping that is no equivalence is written into the section’s narrative and never as a coding (#926).
- Each release scanned in full, its images and binaries at every severity, and refused while a finding has no OpenVEX statement that judges it; each image scanned before it is tagged, and the published images every week (#1238).
- A Security section that names each fix’s advisory and, beside other changes, says why the fix did not ship alone (#1237).
v0.0.13: the national contact point, and full validation of a received document
Released on 2026-10-25.
- A national contact point’s national connector served on the FHIR face: it reads the patient summary, the document list and the prescriptions and posts a dispensation, held by an end-to-end test over two FerroEHR nodes with a harness connector, and a restricting member leaves no trace in any answer (#524, #735, A national contact point). Decisions A67 to A72 re-checked against the Xt-EHR deliverables and the public MyHealth@EU material, vendored with its provenance (#1265, #1290, #1306).
- A patient looked up by identifier on the FHIR face, resolved at the gateway with no member asked, and recorded in the access log when it finds the patient (#1294).
- A received document validated in full by FerroBRIDGE’s
ferrobridge-validator: every invariant, binding and entry profile, with each constraint it did not evaluate named in the outcome (#808, #1291, Receiving a document). - A summary code in no preferred code system translated through the
configured terminology server, asked through FerroBRIDGE’s
ferrobridge-term(#1213, #925). - A read by locator id held to the consent pre-filter, a document list that asked no member recorded as an access, and an ITI-20 record no longer lost when the gateway ends its repository connection (#1270, #1271, #1278).
- The Kubernetes package, with the gateway and console images pinned by digest; the OTLP export over TLS; the 95th percentile latency beside the median; the running configuration on the admin listener with the console’s settings and Authentication tabs; and the operator console in the release’s compose file (#638, #644, #1220, #1235, #1269, #1258, The Kubernetes package).
v0.0.14: no patient index, and the safety controls of the clinical review
Released on 2026-10-28.
- No patient index in any profile: the static development cross-reference
[dev]is refused at load, every patient resolves through a PIX Manager, and the quickstart runs FerroPIX seeded over ITI-104 (#1377). - The patient summary’s safety controls from the clinical review: a probable duplicate medicine across members marked in the narrative, the medical alert and functional status sections written as unavailable, the medication order selected for the medication summary, and a composition that holds a mapped archetype twice left out and named rather than merged (#1343, #1344, #1350).
- A received dispensation checked against the owner’s dispensations by its business identifier before it is written (#1346).
- A
CONTRIBUTIONin XML answered415before it is routed, as a node answers it (#1391). - Consent before release (CP-19) observed at FerroEHR on a restriction of processing the harness sets, and honoured by the federated query (#1389).
- The family crates on
ferrohealth-smart0.8, with a Basic credential cached for five minutes and each access record’s labels on its entities (#1390).
v0.0.15: the family’s server crates, and the MyHealth@EU catalogue
Released on 2026-10-30.
- The listeners, their TLS and drain, the configuration loader, the health
routes, the secrets, the request log, the telemetry export, the panic line
and the installation report taken from the FerroHEALTH family’s shared
crates, with FerroFED’s own copies deleted: TLS 1.3 by default, a client
certificate asked under an explicit
client_auth,--seton every subcommand, a liveness route of its own, one OTLP collector for spans and metrics, and responses compressed for a client that accepts it (#1397, #1408, #1418, #1421, #1448, #1453). - Every error outside the FHIR face an RFC 9457 problem details document,
and a member’s problem details refusal reported by its
detail(#1412, #1413). - The operator console hydrates the whole page on FerroUI 0.5 and runs on the same shared crates; every page still reads and every action still posts with no WebAssembly (#1397, #1414, #1445).
- FerroFED’s reading of the MyHealth@EU Requirements Catalogue v10.0.0, a self-assessment and no MyHealth@EU compliance check, with the gateway’s changes it found: the missing Annex attributes of a contact point’s token named, a member’s own refusal of a dispensation relayed, and an imported copy of a composition listed once in the patient summary (#1423, #1424, #1426, #1427, #1428, MyHealth@EU self-assessment).
- The interoperability component taken as the published
eehrxffrom FerroBRIDGE, and the access record of a routed openEHR read or write naming its object aswhat.identifier(#813, #1416).
v0.0.16: laboratory reports, prescriptions from openEHR, and Federation Tier 1.0.1
Released on 2026-11-05.
- The laboratory report, Art 14(1)(e), on the FHIR face: received at a
[[fhir.receive]]member as an HL7 Europe Laboratory Report 2.0.0 document, written from a member’s laboratory composition as aBundle-eu-laband listed, and a member’s own reports, served as an EU Health Data API Document Access Provider, passed through the document list with only their attachment URL readdressed to the gateway’s (#1432, #1555, #1556, #1404). - Prescriptions served and dispensations written in the map mode, from a
member’s openEHR medication orders through FerroBRIDGE’s MPD project, and
a received dispensation withdrawn as
entered-in-error(#1559, #1560, #1561, #1468, #1450). - The Federation Tier with AQL 1.0.1: an EHR-scoped version request that names no node reaches the member that created the version (#1478).
- The patient summary’s medication entries selected by the template section that holds them (#1551).
- The operator console drawn whole on its server with FerroUI 0.6, with no
WebAssembly and a Content-Security-Policy of its own origin alone, signing
in through the family’s
ferrohealth-session, with a session watch (#1457, #1465, #1504, #1505, #1512). - The
[server]keys 0.0.15 still read under their old names refused, as any unknown key (#1490).
v0.0.18: discharge and imaging reports, and the national contact point
Released on 2026-11-10. v0.0.17 was tagged and never released, so v0.0.18 ships what it holds.
- The discharge report, Art 14(1)(f), and the medical imaging report, Art
14(1)(d), on the FHIR face: written from a member’s composition as an HL7
Europe Hospital Discharge Report and Imaging Report and listed, a
member’s own passed through the document list with only their attachment
URL readdressed, and the discharge report received at a
[[fhir.receive]]member (#1405, #1406, #1714, #1777). - A national contact point held to the MyHealth@EU profile: its token
bound to the connector’s certificate and checked by
ferrohealth-smart’s eHDSI validator, each patient-data request held to the patient of its treatment relationship confirmation on both faces, the emergency access limit of eHDSI Section II §2.5 counted across replicas, a dispensation and its discard under the catalogue’s rules, and every refusal naming its catalogue exception code (#1620, #1622, #1623, #1631, #1632, #1633, #1640). - FerroFED’s own PostgreSQL store,
[store], for the state every replica shares: the stored queries, the emergency counts, theDPoPproofs and a contact point’s dispensations (#1636, #1637). - Every issuer an
[[auth.issuer]]of its ownkindonferrohealth-smart0.13.0, no Basic caller, and each node request a caller originates sent under a token exchanged for that caller (#1682, #1703, #1704, #1705, #1749). - Every outbound client on the family’s
TlsClient, at TLS 1.3 unless itstlssub-table lowers it (#1750, #1766).
v0.0.19: imaging reports received, and what a report loses declared
Released on 2026-11-11.
- The medical imaging report, Art 14(1)(d), received at a
[[fhir.receive]]member over the HL7 Europe Imaging Report package closure, validated in full and written at its member (#1716). - Each received category compiled with the declarations of the project
eehrxfships for it, so what a received report loses is relayed to the sender as not carried, and a written report names what its mapping declared it left out; a written report names its member the custodian (#1785, #1826, #1827, #1838). - Every outbound service, the identity and Dutch service clients included, configured with one endpoint table: its address, TLS, timeouts and credential (#1805, #1806, #1815).
- The family crates on
ferrohealth-smart0.18, with a password in the userinfo of any configured URL masked in every rendering of the configuration, and the quickstart on FerroPIX 0.0.26 under client credentials tokens, published as a self-containedcompose.yaml(#1825, #1838, #1849).
v0.0.20: FerroEHR nodes reached under the caller
Released on 2026-11-12.
- A FerroEHR 5.7.0 node reached under the caller, through an onward lane
of kind
personson each of its endpoints, in the quickstart, the end-to-end harness and the release compose example; no quickstart node is reached under a Basic user (#1615). - The professional, the organisation and the purposes of use a caller’s
token states read only through its issuer’s
[[auth.attributes]]or[[auth.federation]]table, byferrohealth-smart(#1615, #1704). - A received imaging report’s performing organization carried into the member’s structured record, and a performer that is no organization declared to the sender as not carried (#1860).
- The family crates on
ferrohealth-smart0.19, with the issuer of every[auth]table masked in every rendering of the configuration, and the quickstart on FerroPIX 0.0.29 (#1864, #1474).
Limitations
Regulation (EU) 2025/327 Art 28(b) forbids “failing to inform the professional user of likely limitations related to interoperability or security features of the EHR system in relation to its intended purpose”. These are the ones to know before you deploy FerroFED; the claims review records how this list is kept.
- The specification is release 1.0.1. FerroFED implements the 1.0.1
release of the Federation Tier with AQL, the erratum of 2026-11-01 to
the 1.0.0 release the openEHR SEC approved on 2026-10-12, and reports
spec_version"1.0"inOPTIONS {base}/, since a patch release keeps the wire contract (§7a.2). Upstream has not tagged 1.0.1, so the vendored text is its untagged release commit (#1478). Releases up to v0.0.15 implement 1.0.0, and releases up to v0.0.9 the 0.9.0 release candidate, which report"0.9"; v0.0.10 re-pinned it (#17). - No EHDS harmonised component is complete. The gateway records every
access to patient data as Annex II, point 3.2, asks of the European
logging software component (Art 25(1)), read at your Audit Record
Repository with ITI-81 (point 3.3), each record stating its retention by
origin and category (point 3.4). A query over many patients’ data names
the patients its delivered rows project by
ehr_id, up to a bound, and otherwise records only its scope, which shows a person that a query ran over a member holding their EHR, never that it read their record. Each record carries a security label per category and per origin, which your repository applies as access rights (Labelling the access records). The European interoperability software component writes the patient summary and receives a document in the exchange format on the gateway’s FHIR face, and passes prescriptions and dispensations through from the members that serve them in the HL7 Europe MPD profiles on their own FHIR endpoint, or maps them from a member’s openEHR medication data in the map mode (Prescriptions and dispensations over FHIR). It lists, reads and receives laboratory reports (Laboratory reports over FHIR), with hazard I-16 of the clinical safety risk file open and I-18 closed at S4, P1. It lists and reads discharge and imaging reports and receives both (Discharge reports over FHIR, Imaging reports over FHIR), with hazards I-19 and I-20 closed at S4, P1 (#1826) and I-21 and I-22 open at S4, P3 (#1794, #1716). No EU declaration of conformity has been drawn up, and no release carries the CE marking (Regulatory status). - The access records are kept by your Audit Record Repository. Every
federated query, stored-query execution, routed read and routed write
that reaches a node, a query from the operator console included, is
recorded with the verified caller and the patient, and an access whose
record cannot be stored is refused
503 access-unrecorded, a write before any member is asked (The access log). The gateway keeps no copy, so who reads the records is the repository’s, and it keeps each for the period the record states from[access_log.retention], at least three years (How long a record is kept). FerroFED ships no category map: until you declare one in[access_log], the categories of the data an access reached are recordedehds-unclassified. The record names the professional the token names, and the assurance level when the issuer declares one (The person behind an access). An access whose token declares a purpose of use you name in[[access_log.emergency_purpose]]is marked an emergency access (Art 11(5)); FerroFED names none, and the mark records what the caller asserted, never whether restricted data were released, which the node decides (Emergency access). - The assurance level is yours to set. Patient data needs a natural
person behind the token, or a client application you declare to act for
the professional its token names; any other token is refused
401 natural-person-required. The gateway reads and requires an assurance level only for an issuer with[auth.issuer.assurance], andconfig checknames each issuer without one (Professionals and assurance). Your issuer authenticates the person; the gateway verifies the token. - Consent exclusions are shown by default. A member a consent pre-filter
excludes is reported
consent-denied, as the specification requires (N27a). Art 8 of the Regulation forbids showing that restriction to a healthcare provider, so a deployment in the EU sets[federation.consent] disclose = false(Withholding consent exclusions). A node’s own consent refusal fails the query424until you list the codes that node uses (Consent). - One silent member fails the query. A federated query is all-or-nothing by default (N37): when a member that was asked does not answer, the client gets no rows. A client that accepts a partial answer asks for one per request (Queries and API areas).
- A write body travels as the client sent it. The gateway keeps the
patient identifier out of every request it composes for a node, but a
committed
COMPOSITIONreaches its node byte for byte, aDV_IDENTIFIERinside it included (§5.4, N22; Where the patient identifier stops). - A received document is checked only as far as the validator can
evaluate it. A constraint
ferrobridge-validatorcannot evaluate is listed in the outcome of the write as awarning, not refused, and arequiredbinding the packages cannot decide is listed as not evaluated when no terminology server is configured (The check against the profiles). - Part of ITS-REST is not served. The gateway never federates the
DEMOGRAPHIC area: it answers
501there unless you name one member to serve it, and501for any ITS-REST path it does not serve (The DEMOGRAPHIC area). - The listener speaks plain HTTP by default. Terminate TLS in front of
the gateway, or set
[server.tls], with a client CA to admit only your proxy, when the hop from the proxy to the gateway crosses a network others share (TLS on the listeners). - Members in several Member States are not detected. FerroFED neither detects nor refuses a federation whose members sit in different Member States (Regulatory status).
Not claimed
FerroFED claims a conformance point only when a test carries its marker and CI runs it. The conformance statement is that claim for the Federation-Gateway profile, with every deferral and its reason. The conformance matrix records where each point stands, and the obligations checklist does the same for every normative statement of the specification. Both are held to the 1.0.1 release, to which the vendored text was re-pinned from 1.0.0 (#1478), itself re-pinned from the 0.9.0 release candidate (#17).
Claims review
FerroFED’s manufacturer reviews what its public texts claim, once per
release, against two provisions of Regulation (EU) 2025/327 on the European
Health Data Space. This page is the method, the record of each review, and
the assessment of the features that could burden access to health data.
Every quotation is from the Official Journal text, vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
This page is not legal advice.
What the Regulation asks
Art 28 forbids, “in the information sheet, instructions for use or other information accompanying EHR systems, and in the advertising of EHR systems”, any text or sign that may mislead the professional user “with regard to their intended purpose, interoperability and security” by:
- “(a) ascribing functions and properties to the EHR system which it does not have;”
- “(b) failing to inform the professional user of likely limitations related to interoperability or security features of the EHR system in relation to its intended purpose;”
- “(c) suggesting uses for the EHR system other than those stated to form part of the intended purpose in the technical documentation.”
Annex II, point 2.5: “The harmonised software components of an EHR system shall not include features that prohibit, restrict or place an undue burden on authorised access, personal electronic health data sharing or use of personal electronic health data for permitted purposes.” Art 7(1) gives a person the right to have their data transmitted to another healthcare provider “without hindrance from the healthcare provider or from the manufacturers of the systems used by that healthcare provider”.
How a review is done
The review reads every public text: README.md, SUPPORT.md, SECURITY.md,
llms.txt and CITATION.cff; every page of this book; the landing page at
https://ferrofed.eu; the README and the manifest description of each crate;
and the title, description and labels of both container images. Each
statement about FerroFED’s intended purpose, interoperability or security is
held to three questions, one per point of Art 28:
- Does a release ship it, and does a test hold it? A statement that ascribes more is rewritten, (a).
- Does it leave out a limitation a professional user would need? The limitation goes on the Limitations list and next to the statement, (b).
- Does it suggest a use outside the intended purpose? It is rewritten, (c).
A review runs before every release cut (docs/release.md, Before the tag),
and adds a row to the record below. A finding that is not about a claim, such
as a broken command, is fixed in the same change.
The record
| Release | Reviewed on | Texts read | Findings | Fixed in |
|---|---|---|---|---|
| v0.0.10 | 2026-10-06 | see below | 16, below | #674, except finding 16 |
| v0.0.10 | 2026-10-15 | see below | 17 to 30, below | the v0.0.10 version bump, except finding 16, which stands |
| v0.0.11 | 2026-10-15 | this page and the clinical safety risk file | 31 and 32, below | #962 |
| v0.0.11 | 2026-10-16 | see below | 33 to 43, below | the v0.0.11 version bump, except finding 16, which stands |
| v0.0.12 | 2026-10-24 | see below | 44 to 55, below | the v0.0.12 version bump |
| v0.0.13 | 2026-10-25 | see below | 56 to 63, below | the v0.0.13 version bump |
| v0.0.14 | 2026-10-28 | see below | 64 to 69, below | the v0.0.14 version bump |
| v0.0.15 | 2026-10-30 | see below | 70 to 74, below | the v0.0.15 version bump |
| v0.0.16 | 2026-11-05 | see below | 75 to 81, below | the v0.0.16 version bump |
| v0.0.18 | 2026-11-10 | see below | 82 to 91, below | the version bump of v0.0.17, tagged and never released, re-cut as v0.0.18 |
| v0.0.19 | 2026-11-11 | see below | 92 to 95, below | the v0.0.19 version bump |
| v0.0.20 | 2026-11-12 | see below | 96 to 100, below | the v0.0.20 version bump |
Findings of 2026-10-06
Read in full: README.md, SUPPORT.md, SECURITY.md, llms.txt,
CITATION.cff, the landing page, the book’s introduction, the Evaluate
pages written by hand, the self-description section of the client contract,
the README and manifest description of every crate, and the image labels.
Every other page of the book was searched for absolute and security words
(never, always, guarantee, certified, compliant, secure, safe, “never
learns”) and each hit read in its paragraph.
Against Art 28(a), a function or property FerroFED lacks:
README.md, the book’s introduction, the landing page andllms.txtsaid a client “never learns it was federated”. A client reads every member with its status inmeta.federation. Now: a client sends an ordinary query with no federation syntax and gets back an ordinary result set whosemeta.federationnames each node.README.mdand the book’s introduction said no directly identifying identifier “travels to a node”. The rule covers every request the gateway composes; a body the client writes reaches its node byte for byte (§5.4, N22). Now said that way.- The landing page’s card “No identifier reaches a node”, for the same reason. Now “No identifier in what the gateway sends a node”, with the write body named.
- The landing page credited mutual TLS to a node to v0.0.8. It shipped in v0.0.9 (#492).
- The landing page said the conformance statement “scores every conformance point … against FerroEHR and EHRbase”. Some points are deferred with a recorded reason, and EHRbase is checked for the node profile only.
- The landing page’s quickstart sent a query with no access token and
started the gateway with no signing key, so it answered
401. It now writes the key and mints the token as the README does. - The README’s quickstart sent the body
5820in place of the query. It now reads the query from standard input.
Against Art 28(b), a limitation left out:
- Nothing listed the limitations a professional user needs before deploying FerroFED. The claims page now carries them: Limitations.
README.mdclaimed ITS-REST 1.1.0 “on both faces” without saying that the DEMOGRAPHIC area is not federated and an unserved path answers501. Now said beside the claim.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.7 as current and v0.0.8 as unreleased.llms.txtnamed v0.0.8 as onmain.- The claims page listed v0.0.9 as unreleased and its query console as planned.
- The engine’s and the console’s READMEs listed follow-up forwarding and the query console as planned. Both shipped.
- The client contract said
OPTIONS {base}/declares noauth.jwks_uribecause the gateway publishes no JWK Set. Every federating gateway publishes one and declares it. CITATION.cffdated v0.0.9 2026-10-04; its tag and its changelog section are of 2026-10-05.
Not fixed here:
- The
openehr-federationREADME says version-identity de-duplication “follows in build order”; it shipped in v0.0.6. The text under-claims, so Art 28 is not breached. A change to that README is a change to the packaged crate and moves its version, which is that crate’s own pull request.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-15
The review at the v0.0.10 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the book’s introduction, the Evaluate pages
written by hand, the instructions for use, the patient summary page, and the
README and manifest description of ehds-logging, eehrxf and
openehr-federation. Every other page of the book, SUPPORT.md and
SECURITY.md was searched for “planned” and “yet” next to an issue that is
closed, and for the words of the first review.
Against Art 28(a), a function or property FerroFED lacks: none found.
Against Art 28(b), a limitation left out:
- The instructions for use did not say that a received document is not yet checked in full (#808), that the terminology rules are not yet applied to the summary the gateway writes (#926), or that behind several replicas a follow-up write that names no node can be refused (#641). All three are now listed.
Stale or wrong statements, which read as claims about the release a user runs:
README.mddescribed v0.0.10 as being built onmainand planned the reporting procedure and the support period, which ship in it (#762, #763).llms.txtsaid retention by origin and category is planned and the gateway does not serve the interoperability component; both ship (#795, #809).- The landing page named v0.0.9 as current. It now names v0.0.10 and has a card for it.
- The claims page planned the re-pin to 1.0.0, the reporting procedure and the support period for v0.0.10 and listed no v0.0.10 section under Shipped.
- The instructions for use listed the professional’s identification and the assurance level as missing from the access record (#742).
- The regulatory status page planned the Art 14 reporting procedure, the support period and the end-of-support notice (#762, #763, #782), and put the remaining EHDS work in the v0.0.10 milestone; it is in v0.0.11.
- The information sheet planned the support period of each release (#763). It now states the end date of 0.0.10.
- The information sheet’s standards table left out the HL7 Europe packages, the Xt-EHR models, FHIRconnect and the TLS versions the release uses. They are added, held to the pin matrix where it pins them.
- The data-protection page and the threat model said the records are kept for no period by origin and category; each record states its period (#795). The data-protection page also planned the serious-incident procedure, which is written (#672).
- The clinical safety risk file said the gateway does not serve the interoperability component, and marked as planned controls that shipped (#742, #722, #663, #687, #688, #734, #802). Its review row for 0.0.10 records the update.
- The patient summary page planned the FHIR Validator lane, which CI runs (#688).
- This page said the harmonised components are not built; the logging component’s access record and the interoperability component’s FHIR face are.
- The release notes repeated two upgrade notes and two security entries,
and told an operator to read
GET /health/dependencies, which v0.0.10 no longer serves (#751). The duplicates are merged and the note names the operator surface.
Not fixed here: finding 16 stands, and the clinical safety file’s X-01 cites server tests planned with #689, which is closed without them.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-15, after the cut
Read against the log work that shipped: this page and the clinical safety risk file.
Stale or wrong statements, which read as claims about the release a user runs:
- This page planned the export of the gateway’s state for a replacement product (Annex II, point 2.6) with #660. It shipped with #798.
- The clinical safety risk file held L-07 open until #660, although the review of the records with ITI-81 is built and tested. L-07 is re-rated and its review row records it.
Findings of 2026-10-16
The review at the v0.0.11 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the prescriptions page, the Upgrading page, and the
assembled release notes. Every other page of the book, SUPPORT.md,
SECURITY.md and the README and manifest description of each crate was
searched for “planned”, “not yet” and “v0.0.1” next to an issue that is
closed or a release that has shipped.
Against Art 28(a), a function or property FerroFED lacks:
- The claims page listed electronic prescriptions and dispensations among what v0.0.10 shipped. They ship in v0.0.11 (#1039, #1040, #1041, #1042), and the bullet moved to a new v0.0.11 section.
- The instructions for use said the record of a query over many patients’
data names no patient. Since #819 it names the patients behind the
ehr_ids its rows project, up to a bound, and records only its scope otherwise. Now said that way.
Against Art 28(b), a limitation left out:
- The instructions for use and the claims page did not say that a member
holding its prescriptions only as openEHR data is not served
(FerroHEALTH/FerroBRIDGE#1322), that the gateway offers no log access interface of its own
(#660), or which settings stay in the file under
config.ui = "managed"and that the console reads one replica at a time. All are now listed.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.10 as current and planned the rest of the interoperability component with no word of prescriptions. It now names v0.0.11, what it adds, and the planned work by issue.- The landing page named v0.0.10 as current and v0.0.11 as planned. It now names v0.0.11 and has a card for it.
llms.txtsaid every priority category other than the patient summary is planned. Prescriptions and dispensations are passed through from the members that serve them.- The claims page planned for v0.0.11 work that shipped in it or moved to v0.0.12 and v0.0.13; the regulatory status page put the remaining EHDS issues in the v0.0.11 milestone, left prescriptions out of what the interoperability component does, and planned the Annex II checklist and the technical documentation, which are written. Each now says where the work stands.
- The configuration page planned the export and import of a revision (#579), which ships.
- The information sheet listed the HL7 Europe MPD package twice in its standards table. It is listed once, and the operator console’s OpenID Connect sign-in is added beside the standards.
- The release notes described the console’s top bar, navigation and system theme as the styling change of #1037 left them; the new shell of #1075 in the same release replaced all three. The entry now describes the styling and points at the shell.
- The release notes named
fhirconnect0.1.111 onopenehr-*0.0.86 as where the mapping engine moves (#1028); the release ships the later versions of #714. The entry now says so.
Not fixed here: finding 16 stands.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-24
The review at the v0.0.12 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page, the configuration revisions page
and the assembled release notes. Every other page of the book,
SUPPORT.md, SECURITY.md and the README of each crate was searched for
“planned”, “not yet”, “v0.0.1”, ferrofed-viewer, FERROFED_VIEWER and
bearer_token next to an issue that is closed, a release that has shipped
or a name the release removes.
Against Art 28(a), a function or property FerroFED lacks:
- The Upgrading page says a key is never renamed or removed at once and
the old key is read for at least one release. v0.0.12 moves the issuer
policy of
[auth]to[trust], removes every static bearer token and renames the console’sFERROFED_VIEWER__overrides with no release that reads the old keys (#1174, #1163, #1219). The page now says so under the rule, and the upgrade notes list each key. - The landing page said v0.0.12 brings the national contact point’s connector. Its end-to-end test waits on a published FerroPIX image and moved to v0.0.13 (#524, #735). The page now names what v0.0.12 ships, and the claims page plans the connector for v0.0.13.
Against Art 28(b), a limitation left out:
- The instructions for use and the configuration revisions page left
[trust]out of the bootstrap core that stays in the file underconfig.ui = "managed"; the gateway holds it there beside[auth]. Both pages now list it.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.11 as current and planned the logging component’s log access interface (#660), which is closed: the records are read at the Audit Record Repository with ITI-81. It now names v0.0.12, what it adds, and plans the Art 36(1) re-check (#799).- The landing page named v0.0.11 as current. It now names v0.0.12 and has a card for it.
llms.txtnamed only the gateway image, called the gateway’s outbound authentication “a static credential”, and planned the logging component’s review tools. It now names the console image, says HTTP Basic, and says where the records are read.- The claims page planned the national contact point’s connector for v0.0.12 and the log access interface of #660 for v0.0.13; the regulatory status page put the remaining EHDS issues in the v0.0.12 milestone, planned the logging component’s review, retention and export, which are built, and listed #526, which is closed. Each now says where the work stands, and the claims page has a v0.0.12 section.
- The release notes described the console as each FerroUI step from
ferrohealth-ui0.1.3 to 0.3.6 left it, so an entry described a theme toggle in the top bar that a later entry removes and named paths underapp/ferrofed-viewer/. The console entries now describe the console this release ships, on FerroUI 0.3.6. - The release notes’ upgrade note for the console said the theme cookie
keeps its name, which holds only from the first FerroUI 0.3 build on
main; from v0.0.11 it moves fromferrofed_viewer_themetoferrofed_console_theme, so each operator chooses the theme again. The note now says so. - The release notes’ Removed entry for the Tailwind CSS pin named
scripts/release/viewer-tools.sh, which the rename madescripts/release/console-tools.sh. The entry now names it. - The cybersecurity risk assessment said
deny.tomlholds two exceptions and that the console’s site build runs a CSS tool. It holds one,paste, whose OpenVEX statement gives the argument, and the site build runs only its WebAssembly tools. Point (a) now says so, with the release’s full scans. - The record held finding 16 as standing at the v0.0.10 and v0.0.11
reviews. The
openehr-federationREADME lost the “follows in build order” sentence in the v0.0.10 version bump (commitef7e3084), so the finding is closed there.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-25
The review at the v0.0.13 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page and the assembled release notes.
Every other page of the book, SUPPORT.md, SECURITY.md and the README
of each crate was searched for “planned”, “not yet”, “plain gRPC” and the
issues the v0.0.13 milestone closed next to a statement that the work is
still to come.
Against Art 28(b), a limitation left out:
- The instructions for use said a received document is not yet checked
in full (#808), which v0.0.13 builds, and the claims page’s list of
limitations said nothing of the received document. Both now name what
stays: a constraint the validator cannot evaluate is listed as a
warning, not refused, and a
requiredbinding the packages cannot decide needs the terminology server.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.12 as current and planned the national contact point’s connector (#524), which v0.0.13 serves. It now names v0.0.13 and what it adds.- The landing page named v0.0.12 as current and said v0.0.13 continues the connector. It now names v0.0.13, has a card for it, and plans v0.0.14.
- The claims page planned for v0.0.13 the connector and the operations packaging (#735, #638, #644, #645), which shipped, beside work the v0.0.14 milestone holds. It now has a v0.0.13 section and plans v0.0.14.
- The regulatory status page planned the national route in #524 and put the remaining EHDS issues in the v0.0.13 milestone, and the rights page called the Art 7(2) route planned. Both now say the FHIR face serves a national connector, and the remaining issues are in the v0.0.14 milestone.
- The threat model called the OTLP export plain gRPC with
#644 open, and left egress to the deployment pending
#638. An
https://collector is now reached over TLS with mutual TLS on request, and the Kubernetes package’segresscomponent restricts egress; the row and both residual risks now say so. - The clinical safety risk file kept I-03 open on #808 and #1213, both closed by v0.0.13. The release’s review row names what keeps it open: the medical alert and the functional status, which no openEHR content feeds yet.
llms.txtdid not name the national connector the FHIR face serves. It now does.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-28
The review at the v0.0.14 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page and the assembled release notes.
Every other page of the book, SUPPORT.md, SECURITY.md and the README
of each crate was searched for “planned”, “v0.0.14”, “cross-reference” and
the issues the v0.0.14 milestone closed next to a statement that the work
is still to come.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.13 as current. It now names v0.0.14.- The landing page named v0.0.13 as current and said v0.0.14 brings prescriptions from openEHR medication data (FerroHEALTH/FerroBRIDGE#1322), which this release does not ship. It now names v0.0.14, has a card for what it ships, and plans no prescription mapping in FerroFED.
- The claims page planned for v0.0.14 the other priority categories and the prescription mapping, which move to FerroBRIDGE with the interoperability component (#813). It now has a v0.0.14 section, says where that work is built, and lists the re-checks that wait on the EU apart.
- The regulatory status page put the remaining EHDS issues in the v0.0.14 milestone. It now says which wait on an adopted act and that the prescription mapping moves to FerroBRIDGE.
llms.txtplanned the further categories without saying where, and did not say the gateway keeps no patient index (#1377). It now says both.
Against Art 28(b), a limitation left out:
- A draft EU declaration of conformity carried the Annex IV point 4 statement of conformity under a banner that said only “Not issued”. The draft now says it claims no conformity, that no conformity assessment has been carried out and that the Article 40 testing environment does not exist yet (#940).
No text suggested a use outside the intended purpose, (c).
Findings of 2026-10-30
The review at the v0.0.15 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page and the assembled release notes.
Every other page of the book, SUPPORT.md, SECURITY.md and the README
of each crate was searched for “planned”, “v0.0.15”, “v0.0.16”, the
configuration keys and health routes the release renames, and the issues
the v0.0.15 milestone closed next to a statement that the work is still to
come.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.14 as current. It now names v0.0.15.- The landing page named v0.0.14 as current. It now names v0.0.15 and has a card for what it ships.
- The claims page planned the rest of the conformity work for v0.0.15, which the v0.0.16 milestone holds (#525, #940). It now has a v0.0.15 section and plans v0.0.16, with the gateway half of the categories FerroBRIDGE maps (#1403 to #1406).
- The claims page’s v0.0.14 section gave no release date. It now says 2026-10-28.
Against Art 28(b), a limitation left out:
- The instructions for use did not say that the gateway keys this
release moves out of
[server]are read for one release only. Their maintenance table now says FerroFED 0.0.16 refuses them, and the Upgrading page has notes for v0.0.15 (#1408).
No text suggested a use outside the intended purpose, (c).
Findings of 2026-11-05
The review at the v0.0.16 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page, the new Laboratory reports over
FHIR page and the assembled release notes. Every other page of the book,
SUPPORT.md, SECURITY.md and the README of each crate was searched for
“planned”, “v0.0.16”, “v0.0.17”, the laboratory report, the map mode, the
configuration keys the release refuses, and the issues the v0.0.16
milestone closed next to a statement that the work is still to come.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdnamed v0.0.15 as current. It now names v0.0.16.- The landing page named v0.0.15 as current. It now names v0.0.16 and has a card for what it ships.
- The claims page planned discharge reports and imaging for v0.0.16, which the v0.0.17 milestone holds (#1405, #1406). It now has a v0.0.16 section and plans them for v0.0.17.
- The information sheet named
eehrxf=0.1.142, and the pin matrix namedeehrxf,fhirconnect,openehr-mapping-coreandferrobridge-validatorat the versions before #1551 and #1590, behind the rootCargo.toml. Both now name the pins the release builds with. - The information sheet had no row for the HL7 Europe Laboratory Report package and said the gateway serves only the patient summary and serves prescriptions only through members that serve them. It now lists the package, says the gateway serves laboratory reports, and names the map mode.
Against Art 28(b), a limitation left out:
- The instructions for use did not say that a console whose
[oidc]namesissueralone needs the identity provider reachable at its start. Their maintenance table now says so, and the Upgrading page has the note for v0.0.16 (#1504). - The instructions for use described the laboratory reports the gateway writes and not the reports a member serves itself, which are listed as the member holds them. Their limitations now link the new page (#1404).
No text suggested a use outside the intended purpose, (c).
Findings of 2026-11-09
The review at the v0.0.17 cut. Read in full: README.md, llms.txt,
CITATION.cff, the landing page, the Evaluate pages written by hand, the
instructions for use, the Upgrading page, the Discharge reports and Imaging
reports over FHIR pages and the assembled release notes. Every other page of
the book, SUPPORT.md, SECURITY.md and the README of each crate was
searched for “planned”, “v0.0.17”, “v0.0.18”, the discharge and imaging
reports, the national contact point, the configuration keys the release
refuses, and the issues the v0.0.17 milestone closed next to a statement
that the work is still to come.
Stale or wrong statements, which read as claims about the release a user runs:
README.mdand the landing page named v0.0.16 as current. Both now name v0.0.17, and the landing page has a card for what it ships.- The release notes, the information sheet and the Receiving a document
page said the gateway receives imaging reports. The component maps
them, but a
Medical-Imaging[[fhir.receive]]entry refuses the start until the gateway’s package set holds the imaging package’s dependency closure (#1716). The notes, the sheet’s table of the categories received and the page now say so, and the Annex II checklist’s point 2.2 no longer counts imaging. - The claims page planned discharge reports and imaging for v0.0.17 and cited their closed issues as remaining work. It now has a v0.0.17 section and plans the receipt of an imaging report for v0.0.18.
- The regulatory status page, the rights page and the Chapter III record named discharge reports and imaging as a gap with no interface, citing #1405 and #1406, both closed. They now say what is served and received, and cite #1716 and #1794.
- The MyHealth@EU self-assessment rows 08 and 08.01 said the face serves
no laboratory, discharge or imaging document. They are marked
not-assesseduntil they are read again against the catalogue (#1795). - The pin matrix named
ihe-atnaandehds-loggingat 0.4.0, behind the rootCargo.tomlat 0.8.0. It now names the pins the release builds with, and the information sheet adds the HL7 Europe Imaging Report and Hospital Discharge Report packages. - The Receiving a document page and the clinical safety review of 2026-11-06 left the rating of a received discharge or imaging report’s lost content to #1405 and #1406, which closed with the served reports rated and the received ones not. The page and the release’s review row now say the rating is open (#1794).
Against Art 28(b), a limitation left out:
- The instructions for use said imaging and discharge reports are neither produced nor received. Their limitations now say what the gateway writes, lists and receives of each, with hazards I-19 and I-20 and the imaging receipt that waits.
- The instructions for use did not name the keys 0.0.17 refuses. Their maintenance table now does, with the Upgrading page’s notes for v0.0.17.
- The instructions for use did not name the backup of FerroFED’s own store or the rotation of a node grant’s private key, both new with this release. Their maintenance table now names both.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-11-11
The review at the v0.0.19 cut. Read in full: README.md, CITATION.cff,
the information sheet, the claims page, the instructions for use, the
Upgrading page, the clinical safety risk file’s hazards I-20 to I-22 and
the assembled release notes. Every other page of the book and README.md
was searched for “planned”, “v0.0.18”, “v0.0.19”, the imaging report’s
receipt, the FerroPIX and ferrohealth-smart versions and the keys the
release moves, next to a statement that the work is still to come.
Stale or wrong statements, which read as claims about the release a user runs:
- The information sheet said the gateway receives each category but the imaging report. It now says each is received, as its table of the categories received already did (#1716).
- The claims page had no section for the release. It now has one for v0.0.19.
- Hazard I-22 named the declaration of what a received imaging report loses as planned. The risk file now names it as a control in place, every loss declared to the sender, and keeps the hazard open at S4, P3 because no reader of the member’s record is told (#1827, #1838).
Against Art 28(b), a limitation left out:
- The instructions for use did not name the keys 0.0.19 moves, or that the keys 0.0.18 refused by name are now refused as unknown keys. Their maintenance table now does, with the Upgrading page’s notes for v0.0.19.
No text suggested a use outside the intended purpose, (c).
Findings of 2026-11-12
The review at the v0.0.20 cut. Read in full: README.md, CITATION.cff,
the information sheet, the claims page, the instructions for use, the
Upgrading page, the page on receiving documents, the onward credentials
page, the clinical safety risk file’s hazards I-21 and I-22 and L-03 and
L-04, and the assembled release notes. Every other page of the book and
README.md was searched for “planned”, “v0.0.19”, “v0.0.20”, FerroEHR
5.6, a Basic user at a node, the IUA extension and authorization_details,
the performing organization, the eehrxf, ehds-logging, FerroPIX and
ferrohealth-smart versions and the keys the release removes, next to a
statement that the work is still to come.
Stale or wrong statements, which read as claims about the release a user runs:
- The information sheet named the interoperability component as
eehrxf0.3.2, and seven rows ofdocs/VERSIONS.mdnamed the component and audit crates of releases ago. Both now name the rootCargo.tomlrequirements,eehrxf0.7.0 among them, andscripts/checks/versions.shfails when one drifts again (#1874). - The instructions for use and the page on receiving documents said a received imaging report keeps its performing organization only in the original document, and that the answer names none of a received report’s losses. Both now say the organization reaches the member’s composition and each loss is declared to the sender and told to no reader of the member’s record (#1827, #1860).
- The release notes said a deployment adds a FerroEHR node’s
personsissuers “from this release” without naming the release. They and the onward credentials page now name v0.0.20 as the first FerroFED release that reaches a FerroEHR node under the caller (#1615). - The claims page had no section for the release. It now has one for v0.0.20.
Against Art 28(b), a limitation left out:
- The instructions for use did not name the
[[auth.attributes]]table a purpose-of-use issuer needs from 0.0.20, the imaging template it needs, or that the keys 0.0.19 refused by name are now refused as unknown keys. Their maintenance table now does, with the Upgrading page’s notes for v0.0.20.
No text suggested a use outside the intended purpose, (c).
Annex II, point 2.5
Point 2.5 binds the harmonised software components, which are partly built (Regulatory status). The patient summary of the FHIR face runs on the same query path, and the rest of the interoperability component is planned on it (#522), so the features of that path that can stop or delay an answer are assessed here, with Art 7(1), which names the manufacturers of the systems a healthcare provider uses. The Annex II checklist takes its reasoning for point 2.5 from this section.
The completeness default
A federated query is all-or-nothing by default: when a member that was asked
does not answer, the query fails 504 or 424 with no rows, and
meta.federation names every member with its status (§11.4, N37). A client
that accepts a partial answer asks for one per request with
openEHR-federation-completeness: partial, and gets the rows of the members
that answered, with every missing member named
(Completeness).
Assessment: no undue burden. The default is the one the Federation Tier specification requires (N37), and it keeps a clinician from reading part of a record as the whole of it, which Annex II, point 1.1, asks of an EHR system (“does not put at risk patient safety”). Access is not withheld: the partial answer is one header away, and both answers name what is missing. Two settings can turn the default into a burden, and the instructions for use say so:
[federation] best_effort = falsewithdraws the partial answer, so one silent member blocks every answer. Keep the default,true.- A node’s consent refusal fails the query
424until its codes are listed inconsent_refusal_codes(Consent). List them for every node that refuses on consent.
The budgets
A member’s request has per_node_timeout (10 seconds by default) and the
whole fan-out overall_timeout (25 seconds); a client may shorten the
wait, never lengthen it
(Timeouts). The gateway serves at
most max_concurrent_requests at once and answers 503 with Retry-After
past it, may rate-limit each caller with 429 and Retry-After, and sends a
member at most max_in_flight_per_node requests at once
(Overload protection).
Assessment: no undue burden. The budgets bound how long one request or
one slow member can hold the gateway, so one caller cannot take access away
from the others. Every limit is configurable, the time budget is declared in
OPTIONS {base}/, the per-caller rate is off by default and the same for
every caller, and a refused request gets a status and a Retry-After that
say when to try again. A deployment that sets the limits below what its
members need can delay access; the instructions for use give the
sizing.
The consent pre-filter
The pre-filter is optional and off by default. It leaves out a member only where the consent service denies the request, which carries out a restriction the person chose (Art 8). When the consent service cannot answer, every candidate is asked and each node checks consent itself (§13.2.1, N27a) (Consent).
Assessment: no undue burden. The pre-filter removes access only where the
person restricted it, and its outage never blocks an answer. A deployment in
the EU sets [federation.consent] disclose = false, so an exclusion is not
shown to the healthcare provider
(Withholding consent exclusions).
Art 7(1): transmission without hindrance
FerroFED holds no clinical data of its own: a record stays at the member CDR
that holds it. A write goes, as the client sent it, to the member that
controls the record, or for a new object to the member the client names
(§12.4, N22, N23), and a routed read comes back as the node answered. The
gateway adds no fee, no export limit and no format of its own to either.
Exporting the gateway’s own state for a replacement product is Annex II,
point 2.6: an operator exports the routing state the gateway learned with
POST /admin/identity-state/export
(Exporting the identity state), and
the access records are exported from the Audit Record Repository.
Conformance statement
FerroFED claims the Federation-Gateway profile of the Federation Tier with AQL specification, at the version and commit the claim table names. This page is that claim. It names the specification text the claim is made against, the node products the tests ran against, every conformance point of §17 and every test track of §16.3 with how each is scored, and every deferral with its reason.
The part between the statement markers is rendered by
scripts/conformance/matrix.sh --statement-write-into when the site is
built, from the conformance matrix, docs/VERSIONS.md, the vendored specification and the
// conformance: markers on the tests. The docs build and the
conformance-matrix guard both fail when it disagrees with them, so the
statement cannot claim more than the matrix holds. The
conformance matrix shows the same points by requirement,
and the obligations checklist covers every normative
statement of the text.
What the claim covers
- Scored by a test means at least one test carries the point’s marker
and CI runs it. The conformance tests page lists
each test by point and track. A test in an
e2emodule runs in the CI e2e job, withFERROFED_E2E=1, against the node products below. - The Federation-Gateway profile only. FerroFED holds no clinical data and is not a CDR, so it makes no Federation-Node claim. A point whose actor is Node or Operator is scored against that actor, and a gateway is not marked down for it (§17, §16.2). FerroFED assists those actors with the node profile checks and the admission check, and the last section of the generated part records what each found.
- CP-20 and CP-39 are tested without a marker. A marker on a Node or
Operator point belongs to a harness check under
tools/, which writes the finding the report reads. The gateway’s part of each is tested in its own crate: the registry load refuses an endpoint with a FHIR connection type (CP-20,app/ferrofed-registry/tests/it/refusal.rs::a_fhir_rest_connection_type_is_refused), and the book answers each §13.4 deployment decision in a section of its own (CP-39,app/ferrofed-server/tests/it/deployment_decisions.rs). - A deferral is the owner’s decision, recorded on the issue its row names, with the reason the specification gives for it.
The claim
| Profile | Federation-Gateway (§16.2) |
| Specification | Federation Tier with AQL 1.0.1, approved by the openEHR SEC, 2026-11-01 |
| Source | syntaric/openehr-federation-spec at b4242c291b08990df718bbfaa1188e4865478e5a |
| Rendered text | https://syntaric.github.io/openehr-federation-spec/federation-aql/1.0/ |
| Gateway points | 35 of 35 scored by a test, 0 deferred, 0 planned |
| Node points | 3, scored against the member CDR |
| Operator points | 3, scored against the federation operator |
| Test tracks | 10 of 11 scored by a test, 1 deferred, 0 planned |
| Requirements | 46: 46 reached by a point, 0 by a track only, 0 by neither |
| Marked tests | 1058, carrying 1361 point and track markers |
The nodes
| Product | Pinned | Scores |
|---|---|---|
| FerroEHR 5.7.0 | ghcr.io/ferrohealth/ferroehr:5.7.0@sha256:44c9268cb8a154df118a10ffe84329633f22923ea2cc54261ff0cea57a2ee7e7 | the Connectathon tracks, the differential run and the node profile |
| EHRbase 2.38.0 | ehrbase/ehrbase:2.38.0@sha256:b265855956270f028b7e457340d87c96290a007252ac5a52dffed3321a6ce93a | the node profile |
| The reference implementation | syntaric/openehr-federation-ref at 9cb8606d1fce457b6b7f3a42f703d7069686a413 | the differential run, as evidence and never as an oracle |
The scored matrix
| Point | Actor | Requirements | Tracks | Status | Scored by |
|---|---|---|---|---|---|
| CP-1 | Gateway | N1 | 1 | covered | 10 tests |
| CP-2 | Gateway | N2, N5 | 1, 2 | covered | 4 tests |
| CP-38 | Gateway | N33 | 2, 10 | covered | 5 tests |
| CP-3 | Gateway | N3 | 2 | covered | 13 tests |
| CP-4 | Gateway | N7 | 2 | covered | 3 tests |
| CP-5 | Gateway | N4, N10 | 3, 4 | covered | 50 tests |
| CP-6 | Gateway | N11 | 3 | covered | 22 tests |
| CP-7 | Gateway | N5 | 2 | covered | 2 tests |
| CP-8 | Gateway | N13 | 5 | covered | 55 tests |
| CP-9 | Gateway | N15 | 5 | covered | 46 tests |
| CP-10 | Gateway | N14 | 5 | covered | 45 tests |
| CP-11 | Gateway | N16 | 4 | covered | 5 tests |
| CP-12 | Gateway | N6, N16 | 4 | covered | 17 tests |
| CP-13 | Gateway | N19, N20, N21 | 6 | covered | 19 tests |
| CP-14 | Gateway | N22 | 6 | covered | 5 tests |
| CP-15 | Gateway | N23 | 6 | covered | 33 tests |
| CP-16 | Gateway | N24 | 7 | covered | 29 tests |
| CP-17 | Gateway | N25 | 7 | covered | 161 tests |
| CP-18 | Node | N26 | 7 | node-profile | the member CDR, by 4 harness checks |
| CP-19 | Node | N27, N27a | 7 | node-profile | the member CDR, by 2 harness checks |
| CP-20 | Operator | N19 | 6 | operator | the federation operator |
| CP-21 | Gateway | N28 | 9 | covered | 10 tests |
| CP-22 | Gateway | N29 | 9 | covered | 12 tests |
| CP-23 | Gateway | N30 | 9 | covered | 39 tests |
| CP-24 | Gateway | N31 | 9 | covered | 23 tests |
| CP-25 | Gateway | N32 | 9 | covered | 14 tests |
| CP-26 | Gateway | N33 | 10 | covered | 165 tests |
| CP-27 | Node | N34 | 10 | node-profile | the member CDR, by 6 harness checks |
| CP-28 | Gateway | N35 | 3 | covered | 18 tests |
| CP-29 | Gateway | N36 | 5, 6 | covered | 5 tests |
| CP-30 | Gateway | N37 | 4 | covered | 34 tests |
| CP-31 | Gateway | N38, N40 | 4 | covered | 19 tests |
| CP-32 | Gateway | N39, N14, N9 | 5 | covered | 159 tests |
| CP-33 | Gateway | N41, N42 | 6, 11 | covered | 53 tests |
| CP-33a | Operator | N42a | 11 | operator | the federation operator, assisted by 3 harness checks |
| CP-34 | Gateway | N43 | 9 | covered | 30 tests |
| CP-35 | Gateway | N17, N18 | 1 | covered | 13 tests |
| CP-36 | Gateway | N8 | 2, 7 | covered | 43 tests |
| CP-37 | Gateway | N12 | 3 | covered | 29 tests |
| CP-39 | Operator | N25 | 7 | operator | the federation operator |
| CP-40 | Gateway | N44 | 9 | covered | 62 tests |
| Track | Title | Points | Status | Scored by |
|---|---|---|---|---|
| 1 | Transparency | CP-1, CP-2, CP-35 | covered | 3 tests |
| 2 | subject → ehrId resolution | CP-2, CP-38, CP-3, CP-4, CP-7, CP-36 | covered | 7 tests |
| 3 | Directed / endpoint pin | CP-5, CP-6, CP-28, CP-37 | covered | 6 tests |
| 4 | Partial results | CP-5, CP-11, CP-12, CP-30, CP-31 | covered | 7 tests |
| 5 | Dedup + DISTINCT | CP-8, CP-9, CP-10, CP-29, CP-32 | covered | 4 tests |
| 6 | Follow-up read/write routing | CP-13, CP-14, CP-15, CP-20, CP-29, CP-33 | covered | 4 tests |
| 7 | Auth conveyance + consent-deny | CP-16, CP-17, CP-18, CP-19, CP-36, CP-39 | covered | 6 tests |
| 8 | PMIR merge/split (ITI-93/94) (provisional) | - | deferred | deferred |
| 9 | REST surface & self-description | CP-21, CP-22, CP-23, CP-24, CP-25, CP-34, CP-40 | covered | 7 tests |
| 10 | Identifier leakage (adversarial) | CP-38, CP-26, CP-27 | covered | 45 tests |
| 11 | Integrity: ehr_id collision & admission conditions | CP-33, CP-33a | covered | 5 tests |
Deferrals
Track 8
- Actor: Gateway: no conformance point scores the track (§16.3), and N3 falls in the Federation-Gateway profile (§16.2)
- Requirements: N3
- Decision: provisional in section 16.3, and section 18 lets its subject matter be deferred; the binding lifetime, the invalidation hook and the PMIR ITI-94 subscription that drives it from ITI-93 merges are built (#48, #147), and propagation to a later resolution is not claimed
- Recorded on: #48, #147
Points scored against another actor
- CP-18, Node: a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the gateway’s node profile checks assist it, which the harness runs against its products and conformance run –node-profile against each member of a deployment: they ask FerroEHR and EHRbase for an EHR they do not hold and for an unparsable query and read the ITS-REST error status, and read whether an EHR the node withholds from one principal is refused on GET /ehr/{ehr_id} and on both ehr_id-scoped query forms while it is served to another: FerroEHR started with Basic authentication and authz.rbac.ehr_access_default restricted withholds every EHR from a clinician, and EHRbase started with Basic authentication withholds the request path of one EHR from its user role through security.additionalAuthorizations, its narrowest access control; FerroEHR from 4.3.3 refuses that EHR on the read and on both query forms (FerroEHR #3562, #566), and EHRbase refuses the read and releases the EHR’s rows on both query forms; the verdicts are the report node class per product, never a gateway pass, and no specification governs the choice of products: our own design (#83, #93, #549, #573).
- CP-19, Node: a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the node profile consent check reads a consent refusal an operator arranges at the node, on the read and on both ehr_id-scoped query forms; at FerroEHR the harness arranges a restriction of processing (GDPR Art. 18) through the node’s ADMIN API, outside ITS-REST, and the restriction check reads the restricted composition withheld on the read and the composition query, and the federated query through the gateway leaves it out (#1389); EHRbase records no consent decision, and ITS-REST defines no consent resource, so the point reads node-not-observable there; the gateway half is CP-36, and no specification governs the choice of products: our own design (#83, #93, #549, #573, #1389).
- CP-20, Operator: verified at admission or in the registry, not on a request (section 16.2); the gateway assists: its registry load refuses a Query API endpoint whose connectionType is hl7-fhir-rest or no defined code (#74).
- CP-27, Node: a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the gateway’s node profile checks assist it, which the harness runs against its products and conformance run –node-profile against each member of a deployment, against FerroEHR and against EHRbase: whether the node serves an EHR, its EHR_STATUS and its compositions by the ehr_id alone, with no patient identifier in any request the capturing proxy journals, whether an EHR created with no EHR_STATUS is read and queried by its ehr_id, and the ehr_id exchange the admission check reports over a PIXm Manager stub; the verdicts are the report node class per product, never a gateway pass, and no specification governs the choice of products: our own design (#93, #549, #573).
- CP-33a, Operator: verified at admission or in the registry, not on a request (section 16.2); the gateway assists: ferrofed admission check reports each identifier-integrity condition of section 12b.2 as pass, fail or cannot-check with its evidence, and the registry load refuses a second member with an existing system_id; the harness applies the check as the operator of track 11 to a FerroEHR candidate sharing a member’s system_id (#91); the node profile runs the same check against a FerroEHR member and an EHRbase member, each under a system_id of its own, and records each condition with its evidence (#93, #549) (#79, #91, #93, #549).
- CP-39, Operator: verified at admission or in the registry, not on a request (section 16.2); FerroFED’s own answers to the five section 13.4 obligations, each naming the configuration that changes it, and the operator’s template are the book page website/book/src/operate/deployment-decisions.md, held to one section per obligation by app/ferrofed-server/tests/it/deployment_decisions.rs (#84).
Not observable
Some obligations cannot be observed from outside the node that holds them.
- CP-18 and CP-19 in a deployment run.
ferrofed conformance run --node-profilerecords the CP-18 access check and the CP-19 consent check asnot-observable. The refusal under test is the node’s own access policy or consent decision, which an operator cannot arrange through ITS-REST, and ITS-REST defines no consent resource (Scoring a deployment). - CP-18 in the harness. The harness starts FerroEHR and EHRbase with an
access policy that withholds one EHR from one principal, and its checks
read the node’s answer on the read and on both
ehr_id-scoped query forms. - CP-19 in the harness. Neither product records a consent decision, so
the harness arranges no consent refusal and the point reads
node-not-observablefor both. The gateway’s own part of consent is CP-36 and track 7, both scored by tests (N27, N27a). - Track 10 in a deployment run. The run reports it
not-run, because only node-side wire capture shows what reached a node (§16.3). The harness scores it with a capturing proxy in front of each node.
Where the specification disagrees with itself
CP-14
scores
N22:
a follow-up read of a version is routed on its creating_system_id first.
The EHR-scoped rule of
§12a.1
routes every request under {base}/v1/ehr/{ehr_id}/… on the ehr_id
binding, “not on creating_system_id”, and
N41
orders the steps for every path ehr_id
(§12.5.1).
Every ITS-REST version read sits under a path ehr_id, so the two rules
disagree for every such read. FerroFED routes by N41, scored under CP-13 and
CP-33, and defers CP-14 until the text settles it. The other half of N22,
that a uid is never rewritten, is held by tests the obligations checklist
names.
Every defect, contradiction or silence found in a specification while scoring is reported on the standing upstream-reports issue, #212. That includes the findings of the differential run (#94), which sent the same Connectathon requests to FerroFED and to the reference implementation over the same FerroEHR nodes and judged each difference against the specification text.
Rerunning the suite
The record, offline: the matrix against the vendored specification, every marker, and the generated pages.
bash scripts/checks/conformance-matrix.sh
The tests, and then the per-track and per-point report of a run over the
whole workspace with the e2e harness, which needs Docker. The report lands in
target/conformance/report.md.
cargo nextest run --workspace --locked --all-features
scripts/conformance/report.sh --run
To score your own deployment, run ferrofed conformance run
(Scoring a deployment).
After a change to a status or a pin, render the matrix page again. The
statement and the tests page are rendered when the site is built, so a new
marker needs nothing; scripts/site/assemble.sh shows them locally:
scripts/conformance/matrix.sh --render-write
When the specification is re-pinned
The pinned text is the 1.0.0 release, re-pinned from the 0.9.0 release
candidate (#17); the
release moved no point, requirement or track. When a later release is
pinned, scripts/conformance/matrix.sh --derive refreshes the derived
columns. A point new to the text arrives planned, and the guard refuses it
until the re-pin scores it with a test or records the owner’s deferral. This
statement then names the new version and commit. CP-14 and the other choices
the reports on #212 bear on are revisited against the 1.0.0 text
(#354).
Conformance tests
Every test that carries a // conformance: marker, under each point and
track the marker names, grouped by file. A file marked (e2e) runs in the
e2e harness, with FERROFED_E2E=1, against the node products the
conformance statement names. A point or track
with no marked test is not listed.
CP-1
10 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/track1.rs(e2e):an_unmodified_client_gets_one_single_cdr_shaped_result_setapp/ferrofed-server/tests/it/facade/query.rs:a_patient_query_is_one_result_set_over_both_nodesapp/ferrofed-server/tests/it/query_get.rs:a_client_ehr_id_in_the_query_string_is_dropped,a_malformed_query_string_is_a_400_body_invalid_that_asks_nobody,a_malformed_stored_get_query_string_is_a_400_body_invalid_that_asks_nobody,a_quoted_get_parameter_binds_the_string_a_post_body_binds,a_stored_get_reads_a_q_key_as_a_query_parameter,a_stored_query_runs_by_get_with_and_without_a_version,get_and_post_send_every_node_the_same_request_and_answer_the_same
CP-2
4 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/track1.rs(e2e):an_unmodified_client_gets_one_single_cdr_shaped_result_setapp/ferrofed-server/tests/it/e2e/track2.rs(e2e):the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_idapp/ferrofed-server/tests/it/facade/query.rs:a_patient_query_is_one_result_set_over_both_nodes
CP-38
5 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):both_patient_carriers_resolve_to_the_same_rows_over_two_cdr_nodesapp/ferrofed-server/tests/it/e2e/track2.rs(e2e):the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_idapp/ferrofed-server/tests/it/outbound.rs:a_clinician_predicate_reaches_the_node_unchangedcrates/openehr-federation/tests/it/aql/entry.rs:both_carriers_rewrite_the_same_patient_query_to_the_same_node_querycrates/openehr-federation/tests/it/aql/guard.rs:a_clinician_or_facility_predicate_is_dispatched_unchanged
CP-3
13 tests.
app/ferrofed-identity/tests/it/pixm.rs:a_member_whose_domain_holds_nothing_does_not_know_the_patient,an_unknown_patient_is_unknown_at_every_member,one_call_resolves_the_patient_at_every_member_by_its_domainapp/ferrofed-server/tests/it/ask_all.rs:an_undirected_patient_query_over_three_members_dispatches_to_the_two_that_resolveapp/ferrofed-server/tests/it/e2e/pixm.rs(e2e):a_patient_fed_at_both_members_resolves_through_pix_at_both,a_pix_resolved_query_asks_only_the_member_that_knows_the_patientapp/ferrofed-server/tests/it/e2e/santempi.rs(e2e):a_patient_each_member_fed_to_santempi_is_answered_by_bothapp/ferrofed-server/tests/it/e2e/track2.rs(e2e):the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_idapp/ferrofed-server/tests/it/identity_grant.rs:the_manager_is_asked_with_a_token_the_grant_obtained_and_refreshedapp/ferrofed-server/tests/it/pdqm/flow.rs:an_iti_119_match_finds_the_master_identity_the_pix_manager_resolves,an_iti_78_search_finds_the_master_identity_the_pix_manager_resolvesapp/ferrofed-server/tests/it/resolution.rs:one_pix_call_resolves_the_patient_and_both_members_answer,the_identifier_and_namespace_reach_the_pix_manager_and_no_node
CP-4
3 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/track2.rs(e2e):the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_idapp/ferrofed-server/tests/it/facade/query.rs:a_patient_query_is_one_result_set_over_both_nodes
CP-5
50 tests.
app/ferrofed-engine/tests/it/fanout/localization.rs:a_localizer_failure_is_carried_in_meta_federation_and_on_every_endpoint,a_localizer_that_answered_leaves_no_localization_memberapp/ferrofed-identity/tests/it/atna.rs:a_full_spool_fails_the_discovery_closedapp/ferrofed-identity/tests/it/nvi_directory.rs:the_directory_maps_each_custodian_to_its_membersapp/ferrofed-identity/tests/it/nvi_localizer.rs:a_refusing_service_fails_the_localization_closed_with_its_status,a_silent_service_runs_out_the_budget,the_members_holding_the_returned_custodians_are_the_candidatesapp/ferrofed-identity/tests/it/pixm_localizer.rs:a_manager_that_fails_leaves_the_localization_unavailable,a_patient_the_manager_does_not_know_has_no_records,the_candidates_are_the_members_whose_domain_holds_the_patient,the_resolution_of_the_same_query_reuses_the_localization_callapp/ferrofed-identity/tests/it/xcpd.rs:a_community_outside_the_federation_adds_no_member,a_discovery_whose_audit_is_not_stored_by_the_deadline_fails_closed_in_time,a_discovery_whose_audit_is_refused_fails_closed,a_silent_gateway_runs_out_the_budget,an_audit_failure_outranks_another_gateway_s_timeout,no_match_anywhere_is_no_records,one_failing_gateway_fails_the_whole_discovery_closed,the_communities_a_broadcast_names_are_the_candidatesapp/ferrofed-server/tests/it/audit_repository.rs:a_full_spool_fails_the_query_closed_and_asks_no_memberapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):an_undirected_query_asks_only_the_members_the_localizer_namesapp/ferrofed-server/tests/it/e2e/xcpd.rs(e2e):an_undirected_query_asks_only_the_member_ferropix_names_over_iti_55app/ferrofed-server/tests/it/localization.rs:a_directed_query_is_never_localized,a_localizer_silent_past_its_budget_fails_closed,a_localizer_that_finds_no_records_leaves_every_member_not_localized_without_error,a_localizer_that_is_down_fails_closed_dispatches_nothing_and_names_its_error,a_member_the_localizer_does_not_name_is_not_localized_and_never_asked,an_undirected_query_that_names_no_patient_is_refused_under_localization,ask_all_on_failure_asks_every_member_only_where_it_is_declared,the_fixed_cross_reference_localizes_to_the_members_that_know_the_patient,without_the_declaration_a_failed_localizer_is_never_widened_to_ask_allapp/ferrofed-server/tests/it/localizer_audit.rs:a_discovery_still_recording_its_audit_at_the_budget_fails_closed_under_ask_allapp/ferrofed-server/tests/it/localizer_surface.rs:a_localizer_outage_still_widens_under_a_declared_ask_all,a_subject_no_member_holds_by_localization_is_no_destination,a_targeted_read_by_subject_is_never_localized,an_unaudited_exchange_fails_closed_even_under_ask_all,an_unaudited_read_by_subject_fails_closed_even_under_ask_all,the_read_by_subject_asks_every_member_when_ask_all_is_declared,the_read_by_subject_asks_only_the_members_the_localizer_names,the_read_by_subject_fails_closed_when_the_localizer_does_not_answerapp/ferrofed-server/tests/it/nl_gf.rs:a_member_the_nvi_leaves_out_is_not_localized_and_complete_holds,an_nvi_that_does_not_answer_fails_the_query_closed,the_pseudonymised_walkthrough_runs_end_to_end_and_no_node_sees_the_pseudonymapp/ferrofed-server/tests/it/nvi_nuts.rs:a_refused_nuts_grant_fails_the_localization_closed,the_nvi_is_asked_with_the_dpop_bound_nuts_tokenapp/ferrofed-server/tests/it/pixm_localizer.rs:a_pix_manager_that_does_not_answer_fails_the_query_closed,the_members_whose_domain_holds_the_patient_are_asked_over_one_iti_83_callapp/ferrofed-server/tests/it/registry_mcsd/lrza.rs:the_custodians_come_from_the_directory_without_a_tableapp/ferrofed-server/tests/it/xcpd.rs:a_failing_gateway_fails_the_query_closed_and_asks_no_member,the_communities_xcpd_discovers_select_the_members_asked
CP-6
22 tests.
app/ferrofed-server/tests/it/directive.rs:a_directed_aggregate_to_one_endpoint_reaches_it_unchanged,a_directed_subject_query_reports_unlisted_members_excluded_and_unknown_ones_not_resolved,a_selected_endpoint_attribute_is_added_to_the_rows_and_asked_of_no_node,an_endpoint_the_registry_does_not_know_is_refused_400_before_any_node_is_asked,no_node_receives_the_directive_and_each_receives_the_undirected_node_query,the_organisation_selector_asks_every_endpoint_the_organisation_managesapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):a_directive_selects_the_node_set_and_leaves_the_row_shape_alone,a_named_member_where_the_patient_does_not_resolve_is_reported_not_erroredapp/ferrofed-server/tests/it/fhir/native.rs:a_directive_naming_the_native_endpoint_is_refused_400,a_targeting_header_naming_the_native_endpoint_is_refused_400app/ferrofed-server/tests/it/localization.rs:a_directed_query_is_never_localizedapp/ferrofed-server/tests/it/localizer_surface.rs:a_targeted_read_by_subject_is_never_localizedapp/ferrofed-server/tests/it/targeting.rs:a_header_and_a_directive_that_differ_are_refused_400_naming_both_sets,a_header_and_a_directive_that_select_the_same_set_proceed,the_endpoint_header_selects_the_node_set_as_the_directive_doescrates/openehr-federation/tests/it/aql/directive.rs:a_directed_aggregate_to_one_endpoint_is_dispatched_unchanged_and_to_two_is_refused,a_directed_patient_query_reaches_the_node_as_the_undirected_one_does,an_undefined_function_directed_to_one_endpoint_passes_and_to_two_is_refused,an_unscoped_query_selecting_only_endpoint_attributes_asks_each_node_one_ehr_column,the_directive_is_lifted_out_with_its_kind_and_identifiers_in_order,the_organisation_selector_never_reaches_a_nodecrates/openehr-federation/tests/it/aql/golden.rs:golden_case_02_keeps_the_directive_and_its_projections_out_of_the_node_query
CP-7
2 tests.
app/ferrofed-server/tests/it/e2e/track2.rs(e2e):a_selected_subject_column_is_the_input_and_never_asked_of_a_nodeapp/ferrofed-server/tests/it/facade/query.rs:a_patient_query_is_one_result_set_over_both_nodes
CP-8
55 tests.
app/ferrofed-engine/tests/it/fanout/distinct.rs:a_cut_node_returning_one_value_twice_fails_the_query_424,a_row_two_nodes_return_takes_one_slot_of_the_limitapp/ferrofed-engine/tests/it/fanout/order.rs:interleaved_node_answers_merge_into_the_global_top_napp/ferrofed-server/tests/it/distinct.rs:a_duplicate_straddling_the_page_edge_is_answered_once,without_distinct_the_duplicate_is_answered_twiceapp/ferrofed-server/tests/it/e2e/track5.rs(e2e):duplicates_pass_through_by_default_and_distinct_folds_them_at_the_tier,order_by_and_limit_return_the_global_top_rows_deterministicallyapp/ferrofed-server/tests/it/endpoint_attributes.rs:under_distinct_a_shared_organisation_collapses_the_rows,under_distinct_an_endpoint_id_keeps_the_rows_of_two_endpoints_apartapp/ferrofed-server/tests/it/order.rs:a_distinct_function_of_an_unselected_path_under_a_limit_is_refused_400,a_distinct_function_of_selected_paths_returns_the_same_rows_on_every_repeat,a_distinct_whole_object_under_a_limit_is_refused_400,the_client_gets_the_global_top_n_without_the_hidden_columncrates/openehr-federation/tests/it/aql/comparable.rs:under_distinct_a_bounded_page_with_a_whole_rm_object_is_refused,under_distinct_a_limit_on_an_incomparable_order_by_key_is_refused,under_distinct_a_limit_with_a_collection_is_refused,under_distinct_a_limit_with_a_dv_text_is_refused,under_distinct_a_limit_with_a_path_the_rm_does_not_resolve_is_refused,under_distinct_a_limit_with_a_whole_rm_object_is_refused,under_distinct_a_limit_with_an_element_value_is_refused,under_distinct_a_limit_with_ordered_data_values_and_primitives_is_pinned,under_distinct_with_no_limit_an_incomparable_path_is_not_a_keycrates/openehr-federation/tests/it/aql/dedup.rs:under_distinct_a_selected_uid_is_the_dedup_key,under_distinct_an_unselected_uid_is_not_addedcrates/openehr-federation/tests/it/aql/distinct.rs:a_re_injected_subject_column_is_not_compared,every_selected_column_of_an_unscoped_query_is_compared,the_column_kept_for_a_subject_only_query_is_not_comparedcrates/openehr-federation/tests/it/aql/order.rs:under_distinct_a_bounded_page_with_an_unpinned_function_is_refused,under_distinct_a_function_of_selected_paths_is_pinned_by_them,under_distinct_a_limit_with_a_function_of_an_unselected_path_is_refused,under_distinct_a_limit_with_a_value_from_outside_the_row_is_refused,under_distinct_an_unpinned_function_with_no_limit_is_answeredcrates/openehr-federation/tests/it/attributes.rs:distinct_on_attributes_alone_keeps_one_row_per_value_and_refuses_no_cut_node,under_distinct_a_shared_attribute_value_collapses_the_rows,under_distinct_rows_of_two_endpoints_are_two_valuescrates/openehr-federation/tests/it/dedup/distinct.rs:outside_dedup_distinct_compares_a_uid_as_sent,under_dedup_a_node_cut_on_two_case_variants_is_refused,under_dedup_distinct_folds_case_beside_the_endpoint_attributes,under_dedup_distinct_holds_case_variants_of_a_uid_one_valuecrates/openehr-federation/tests/it/dedup/mod.rs:dedup_runs_before_distinct_so_the_distinct_row_is_the_originating_copycrates/openehr-federation/tests/it/distinct.rs:a_column_the_client_does_not_see_never_makes_rows_distinct,a_cut_node_holding_two_equal_rows_is_refused,a_data_value_is_one_value_whatever_the_order_of_its_members,a_duplicate_from_two_nodes_collapses_under_distinct_and_stays_without_it,a_duplicate_straddling_a_page_edge_takes_one_slot,a_number_written_two_ways_is_one_value,an_uncut_node_holding_two_equal_rows_is_collapsed,date_times_are_one_value_only_as_one_instant_written_one_way,distinct_is_idempotent_and_leaves_no_two_rows_equal,distinct_runs_before_the_limit,distinct_with_order_by_and_a_bounded_page_is_the_page_of_the_distinct_union,distinct_without_order_by_keeps_limit_distinct_values_of_the_union,the_kept_copy_is_the_first_under_order_by_then_endpoint_id,without_order_by_a_duplicate_collapses_to_the_first_endpoint_copycrates/openehr-federation/tests/it/merge.rs:the_pushed_down_merge_is_the_order_and_limit_of_the_union
CP-9
46 tests.
app/ferrofed-engine/tests/it/fanout/dedup.rs:a_failing_424_envelope_records_the_mode,a_failing_504_envelope_records_the_mode,a_version_uid_of_the_wrong_form_fails_the_query_424,by_default_the_imported_composition_comes_back_twice,under_version_identity_the_originating_copy_is_kept_and_the_copy_namedapp/ferrofed-server/src/facade/dedup.rs:a_repeated_header_is_refused,any_other_value_is_refused,each_offered_mode_is_selected_by_its_name,no_header_is_the_pass_through_defaultapp/ferrofed-server/tests/it/dedup.rs:a_failing_answer_still_records_the_mode,a_recombined_aggregate_under_version_identity_is_refused,a_value_naming_no_offered_mode_is_refused_before_any_node_is_asked,none_is_accepted_explicitly,under_version_identity_one_row_comes_back_and_the_copy_is_named,without_the_header_both_copies_come_back_and_none_is_recordedapp/ferrofed-server/tests/it/e2e/track5.rs(e2e):duplicates_pass_through_by_default_and_distinct_folds_them_at_the_tiercrates/openehr-federation/tests/it/aql/dedup.rs:a_directed_single_node_aggregate_under_dedup_is_dispatched,a_limit_without_order_by_is_ordered_on_the_uid,a_query_that_reads_no_version_has_no_dedup_key,a_recombined_aggregate_under_dedup_is_refused,a_selected_uid_is_read_where_it_is,a_version_variable_supplies_the_uid,the_version_uid_is_asked_as_a_hidden_column,under_distinct_a_selected_uid_is_the_dedup_key,under_distinct_an_unselected_uid_is_not_added,under_order_by_the_row_key_is_the_dedup_keycrates/openehr-federation/tests/it/dedup/distinct.rs:under_dedup_a_node_cut_on_two_case_variants_is_refused,under_dedup_distinct_folds_case_beside_the_endpoint_attributes,under_dedup_distinct_holds_case_variants_of_a_uid_one_valuecrates/openehr-federation/tests/it/dedup/mod.rs:a_row_too_short_for_its_version_column_is_refused,a_row_with_no_version_uid_is_never_suppressed,a_system_id_that_differs_from_the_creating_system_id_only_in_case_keeps_its_copy,a_version_uid_of_the_wrong_form_is_a_node_defect,by_default_both_copies_of_an_imported_composition_come_back,copies_whose_version_ids_differ_only_in_case_are_one_version_kept_as_sent,dedup_runs_before_distinct_so_the_distinct_row_is_the_originating_copy,every_row_of_the_dropped_copy_is_suppressed_and_every_row_of_the_kept_one_stays,the_record_carries_the_mode_and_under_version_identity_what_was_suppressed,two_versions_of_one_object_both_survive,under_version_identity_the_originating_copy_is_kept_and_the_copy_recorded,with_no_originating_holder_the_lowest_endpoint_id_keeps_its_copycrates/openehr-federation/tests/it/dedup/order.rs:a_case_variant_copy_at_a_page_edge_ranks_as_its_version_and_keeps_its_text,a_node_that_orders_uids_byte_for_byte_across_case_is_refused_at_its_cut,a_suppressed_copy_does_not_take_a_slot_of_the_limitcrates/openehr-federation/tests/it/dedup/properties.rs:dedup_keeps_one_endpoint_per_version_and_records_exactly_what_it_dropped,dedup_with_order_by_limit_and_a_bounded_page_is_the_page_of_the_deduplicated_union
CP-10
45 tests.
app/ferrofed-engine/tests/it/fanout/aggregate.rs:a_node_that_does_not_answer_fails_the_aggregate_504,a_recombining_plan_is_refused_best_effort_before_dispatch,a_value_the_recombination_cannot_use_fails_the_query_424,the_node_answers_recombine_into_one_rowapp/ferrofed-server/tests/it/aggregate.rs:a_failing_node_fails_the_aggregate_424_with_no_row,a_partial_request_for_a_recombined_aggregate_is_refused_400,the_client_receives_one_recombined_row_in_its_own_columns,with_nothing_declared_the_aggregate_is_refused_400app/ferrofed-server/tests/it/dedup.rs:a_recombined_aggregate_under_version_identity_is_refusedapp/ferrofed-server/tests/it/e2e/aggregate.rs(e2e):an_undirected_avg_is_the_mean_weighted_by_the_counts_each_node_returnsapp/ferrofed-server/tests/it/e2e/track5.rs(e2e):an_undirected_aggregate_is_recombined_or_refused_and_a_directed_one_answeredcrates/openehr-federation/tests/it/aql/aggregate.rs:a_directed_single_node_aggregate_is_dispatched_unchanged,a_distinct_count_does_not_decompose,a_function_left_out_of_the_declaration_is_refused,a_plain_column_beside_an_aggregate_is_refused,a_recombined_aggregate_is_never_answered_best_effort,an_aggregate_directed_to_two_endpoints_is_recombined,an_aggregate_naming_no_patient_is_recombined_across_every_member,an_indecomposable_aggregate_suggests_both_alternatives,avg_is_asked_of_each_node_as_its_sum_and_count,distinct_breaks_the_decomposition,every_declared_function_but_avg_is_dispatched_as_written,several_aggregates_map_to_their_node_columns,the_columns_are_the_clients_query_not_the_dispatched_one,with_nothing_declared_every_undirected_aggregate_is_refusedcrates/openehr-federation/tests/it/aql/dedup.rs:a_recombined_aggregate_under_dedup_is_refusedcrates/openehr-federation/tests/it/aql/function.rs:a_function_outside_aql_directed_to_one_endpoint_passes_through,a_function_outside_aql_directed_to_two_endpoints_is_refused,a_function_outside_aql_is_refused_before_the_aggregate_beside_it,a_function_outside_aql_is_refused_in_a_patient_query_across_nodes,a_function_outside_aql_nested_in_an_aql_function_is_refused,an_aggregate_follows_the_decomposition_rules,an_undirected_function_outside_aql_in_select_is_refused,an_undirected_function_outside_aql_in_where_is_refused,the_refusal_suggests_both_alternativescrates/openehr-federation/tests/it/aql/golden.rs:golden_case_06_is_refused_with_nothing_declared_and_recombined_under_the_default_setcrates/openehr-federation/tests/it/aql/hygiene.rs:no_recombined_aggregate_query_carries_the_patient_identifier,the_avg_rewrite_reaches_the_node_without_the_patientcrates/openehr-federation/tests/it/aql/rules.rs:an_aggregate_directed_to_one_endpoint_is_dispatched_unchanged,an_aggregate_directed_to_two_endpoints_is_refused,an_undirected_aggregate_is_refused_with_its_reasoncrates/openehr-federation/tests/it/combine.rs:a_value_of_the_wrong_kind_refuses_its_node_and_leaves_no_row,an_avg_whose_sum_and_count_disagree_refuses_its_node,every_recombined_function_is_the_aggregate_over_the_union,values_no_one_order_compares_refuse_every_node_that_holds_one
CP-11
5 tests.
app/ferrofed-server/tests/it/e2e/track3.rs(e2e):an_undirected_query_asks_only_the_members_the_localizer_namesapp/ferrofed-server/tests/it/e2e/track4.rs(e2e):a_node_answering_an_error_fails_the_query_424_and_is_named_node_error,an_unreachable_node_fails_the_query_504_and_is_named_offlineapp/ferrofed-server/tests/it/e2e/xcpd.rs(e2e):an_undirected_query_asks_only_the_member_ferropix_names_over_iti_55app/ferrofed-server/tests/it/endpoint_report.rs:a_member_a_directed_query_did_not_name_is_reported_excluded_and_out_of_scope
CP-12
17 tests.
app/ferrofed-engine/tests/it/fanout/best_effort.rs:every_node_not_resolved_is_a_200_with_no_rows_in_either_modeapp/ferrofed-server/tests/it/completeness.rs:incompleteness_is_the_complete_flag_and_never_an_operation_outcomeapp/ferrofed-server/tests/it/e2e/track4.rs(e2e):a_node_answering_an_error_fails_the_query_424_and_is_named_node_error,a_slow_node_is_abandoned_at_the_per_node_timeout_and_named_time_out,an_unreachable_node_fails_the_query_504_and_is_named_offline,found_nowhere_and_consent_denied_are_200_and_never_424app/ferrofed-server/tests/it/errors.rs:a_node_error_under_all_or_nothing_is_a_424_carrying_the_envelope,a_node_not_found_inside_a_fan_out_is_a_node_error_and_a_424,a_node_timing_out_under_all_or_nothing_is_a_504_carrying_the_envelope,a_node_timing_out_under_best_effort_is_a_200_reporting_it,an_invalid_request_is_a_400_with_its_code_and_quotes_nothing,an_unreachable_node_under_all_or_nothing_is_a_504_carrying_the_envelopeapp/ferrofed-server/tests/it/fhir/unresolved.rs:a_list_for_a_patient_no_member_holds_is_empty,a_prescription_search_for_a_patient_no_member_holds_is_emptyapp/ferrofed-server/tests/it/pdqm/flow.rs:an_identifier_the_supplier_knows_no_patient_for_is_not_resolved_anywhereapp/ferrofed-server/tests/it/resolution.rs:a_patient_known_nowhere_is_200_with_no_rows_and_every_member_not_resolved,an_empty_cross_reference_is_200_with_no_rows_and_every_member_not_resolved
CP-13
19 tests.
app/ferrofed-registry/tests/it/creating_system/learned.rs:a_sighting_at_a_second_node_raises_the_incident_and_withdraws_the_mapping,an_imported_version_resolves_to_its_creating_node_through_a_registered_mapping,one_sighting_learns_a_route_to_the_endpoint_it_was_seen_atapp/ferrofed-registry/tests/it/creating_system/load.rs:a_registered_mapping_loads_and_is_listedapp/ferrofed-registry/tests/it/load.rs:a_system_id_routes_to_its_node,an_endpoint_carries_its_url_connection_type_and_managing_organisation,every_member_is_answered_by_its_idapp/ferrofed-server/tests/it/contribution_write.rs:a_contribution_amending_a_registered_mapping_s_version_reaches_its_nodeapp/ferrofed-server/tests/it/e2e/track6.rs(e2e):a_follow_up_read_of_a_row_reaches_the_cdr_that_holds_it_with_its_uid_unchangedapp/ferrofed-server/tests/it/follow_up.rs:a_fan_out_answer_teaches_the_table_a_system_seen_at_one_node,a_learned_route_never_routes_a_write,a_read_names_the_version_its_path_identifier_classes_carry,a_routed_answer_teaches_the_table_the_version_its_etag_names,a_routed_read_teaches_the_table_the_version_it_named,a_system_seen_at_two_nodes_is_an_integrity_incidentapp/ferrofed-server/tests/it/registry_mcsd/routing.rs:a_registry_from_a_harness_directory_routes_identically_to_the_bootstrap_document,the_directory_materialises_the_snapshot_of_the_bootstrap_documentapp/ferrofed-server/tests/it/versioned_write.rs:a_learned_holder_is_never_the_controlling_cdr_of_a_write,a_registered_creating_system_mapping_makes_its_node_the_controlling_cdr
CP-14
5 tests.
app/ferrofed-server/tests/it/follow_up.rs:a_version_read_whose_creator_the_registry_does_not_map_falls_to_the_probe,an_imported_copy_is_read_where_the_index_places_the_path_ehr_id_never_at_its_creator,with_no_target_binding_or_index_a_version_read_reaches_its_creator_by_step_4app/ferrofed-server/tests/it/path_ehr_id/order.rs:step_4_names_no_member_for_a_creating_system_id_the_registry_does_not_route,step_4_names_the_member_whose_system_id_is_the_creating_system_id
CP-15
33 tests.
app/ferrofed-server/tests/it/contribution_write.rs:a_body_that_is_no_contribution_naming_object_version_ids_is_a_400_before_any_node,a_contribution_amending_a_registered_mapping_s_version_reaches_its_node,a_contribution_amending_a_version_no_member_is_known_to_control_is_409,a_contribution_in_canonical_xml_is_refused_415_before_routing,a_contribution_its_path_node_controls_reaches_it_once_byte_identical,a_contribution_of_creations_alone_routes_by_its_path_ehr_id,a_contribution_with_one_version_another_member_controls_is_409_and_reaches_no_node,a_contribution_without_a_target_is_never_probed_for,a_malformed_flat_contribution_is_a_400_before_any_node,a_simplified_contribution_its_path_node_controls_reaches_it_once_byte_identical,a_simplified_contribution_of_creations_alone_routes_by_its_path_ehr_id,a_simplified_contribution_with_one_version_another_member_controls_is_409app/ferrofed-server/tests/it/created_ehr_id.rs:a_binding_placing_the_ehr_id_at_a_refuses_a_create_at_b,a_create_at_b_of_an_ehr_id_the_index_holds_at_a_is_refused_and_sends_nothing,a_create_at_the_member_holding_the_ehr_id_is_forwarded_for_its_own_409,an_unknown_ehr_id_is_created_where_named_and_then_indexed_thereapp/ferrofed-server/tests/it/e2e/commit.rs(e2e):a_versioned_write_reaches_its_controlling_node_and_never_anotherapp/ferrofed-server/tests/it/e2e/track6.rs(e2e):a_new_ehr_is_created_at_the_one_node_the_client_namesapp/ferrofed-server/tests/it/follow_up.rs:a_versioned_write_no_earlier_step_locates_reaches_its_creator_by_step_4,a_versioned_write_whose_path_carries_no_version_is_still_a_400_with_no_earlier_stepapp/ferrofed-server/tests/it/its_rest_areas/ehr.rs:the_ehr_declaration_states_that_a_new_ehr_goes_only_to_the_named_endpointapp/ferrofed-server/tests/it/versioned_write.rs:a_learned_holder_is_never_the_controlling_cdr_of_a_write,a_new_ehr_is_created_by_the_targeting_headers_alone_never_by_the_index,a_new_ehr_lands_byte_identical_at_the_one_named_node,a_new_ehr_naming_more_than_one_endpoint_is_refused_and_reaches_no_node,a_registered_creating_system_mapping_makes_its_node_the_controlling_cdr,a_versioned_write_lands_byte_identical_and_carries_no_identifier_outside_its_body,a_versioned_write_naming_no_single_preceding_version_is_a_400_before_any_node,a_versioned_write_reaches_its_controlling_cdr_and_no_other_node,a_versioned_write_the_index_routes_reaches_its_controlling_cdr_unprobed,a_write_its_path_node_does_not_control_is_refused_409_and_reaches_no_node,a_write_of_a_version_no_member_is_known_to_control_is_refused_409,no_write_without_a_target_is_ever_probed_for
CP-16
29 tests.
app/ferrofed-engine/tests/it/conveyance.rs:a_conveyance_header_from_the_client_never_reaches_the_node,a_node_the_gateway_has_a_grant_at_knows_it_by_its_client_id,a_probe_conveys_the_caller_to_every_member,a_query_conveys_the_caller_in_a_token_the_published_key_verifies,a_relayed_caller_conveys_every_annex_attribute_marked_as_asserted,a_routed_read_and_a_routed_write_convey_the_caller,a_stored_query_definition_conveys_the_caller,each_node_is_the_audience_of_its_own_token,the_admission_checks_ehr_create_and_read_convey_the_gatewayapp/ferrofed-engine/tests/it/exchange.rs:the_node_receives_a_token_exchanged_for_the_verified_callerapp/ferrofed-engine/tests/it/mtls.rs:token_exchange_composes_with_mutual_tlsapp/ferrofed-engine/tests/it/signing.rs:a_next_key_is_published_ahead_and_never_signs,a_rotation_across_curves_publishes_both_and_signs_with_the_new,the_conveyance_is_signed_with_the_current_keys_algorithmapp/ferrofed-server/tests/it/auth/patient.rs:a_member_other_than_the_token_s_own_is_told_its_own_ehr_id,each_node_is_told_its_own_ehr_id_and_the_covering_patient_scopeapp/ferrofed-server/tests/it/auth/professional.rs:the_node_is_told_who_acts_and_the_level_reachedapp/ferrofed-server/tests/it/contact_point/relayed.rs:each_node_is_told_the_asserted_attributes_and_no_nameapp/ferrofed-server/tests/it/conveyance.rs:a_local_caller_conveys_the_professional_and_the_provider_to_each_node,a_p256_signing_key_conveys_the_caller_es256_beside_the_previous_es384_key,a_query_conveys_the_verified_caller_to_each_node_signed_for_it,a_query_that_reaches_dispatch_with_no_verified_caller_reaches_no_node,a_registry_from_either_source_without_a_signing_key_does_not_load,a_routed_read_a_routed_write_and_a_definition_request_convey_the_caller,an_edge_asserted_caller_is_conveyed_as_edge_asserted,the_ask_all_probe_conveys_the_caller_to_every_memberapp/ferrofed-server/tests/it/e2e/national/served.rs(e2e):the_national_connector_reads_the_summary_and_prescriptions_and_dispensesapp/ferrofed-server/tests/it/e2e/track7.rs(e2e):each_node_is_reached_with_its_onward_token_and_told_the_callerapp/ferrofed-server/tests/it/onward_exchange.rs:the_callers_token_reaches_no_log_span_metric_or_conveyed_claim
CP-17
161 tests.
app/ferrofed-engine/src/dispatch/dpop.rs:a_proof_that_fails_on_the_first_send_is_a_node_error_with_nothing_sent,a_proof_that_fails_on_the_nonce_resend_is_a_node_error_of_a_node_that_was_askedapp/ferrofed-engine/tests/it/audience.rs:the_issuer_audience_names_the_issuer,the_token_endpoint_is_the_default_audienceapp/ferrofed-engine/tests/it/conveyance.rs:a_query_conveys_the_caller_in_a_token_the_published_key_verifiesapp/ferrofed-engine/tests/it/dispatch/mod.rs:a_missing_credential_is_a_node_error_and_sends_nothingapp/ferrofed-engine/tests/it/dpop/mod.rs:a_bearer_token_where_dpop_was_asked_fails_the_node,a_dpop_bound_token_reaches_the_node_with_a_proof_of_its_key,an_exchanged_token_is_dpop_bound,the_nodes_nonce_is_answered_once,the_token_endpoints_nonce_is_answered_no_more_than_once,the_token_endpoints_nonce_is_answered_onceapp/ferrofed-engine/tests/it/dpop/nonce.rs:a_deadline_before_the_nonce_resend_is_a_time_out_of_a_node_that_was_asked,a_nonce_resend_of_an_exchange_signs_new_assertions,a_nonce_resend_signs_a_new_client_assertion,the_token_endpoint_and_the_node_keep_their_own_noncesapp/ferrofed-engine/tests/it/exchange.rs:a_caller_without_its_token_is_refused_with_nothing_sent,a_node_401_drops_the_exchanged_token,a_refused_exchange_names_the_token_endpoints_code,an_edge_asserted_caller_is_refused_with_nothing_sent,an_exchange_answer_without_its_issued_token_type_fails_the_node,an_exchange_naming_no_resource_is_never_sent,an_exchanged_token_is_cached_per_caller,an_operation_no_scope_covers_is_never_exchanged,every_exchange_carries_its_scope_and_resource,the_gateways_own_request_uses_client_credentials,the_node_receives_a_token_exchanged_for_the_verified_callerapp/ferrofed-engine/tests/it/fapi2/mod.rs:a_callers_token_carrying_the_patient_is_never_sent,a_dpop_bound_token_reaches_the_node,a_refused_grant_fails_the_node_and_shows_no_credential,a_token_endpoint_past_the_timeout_fails_the_node,a_token_without_its_granted_details_fails_the_node,a_verified_callers_token_is_exchanged,refused_details_fail_the_node_naming_the_code,the_token_request_names_the_issuer_and_carries_the_detailsapp/ferrofed-engine/tests/it/fapi2/mtls.rs:a_fapi2_grant_authenticates_and_binds_its_tokens_by_certificate,an_alias_on_a_host_the_grant_names_is_takenapp/ferrofed-engine/tests/it/fapi2/rotation.rs:only_the_current_client_key_signs_while_the_previous_one_is_publishedapp/ferrofed-engine/tests/it/mtls.rs:a_bound_token_travels_only_over_connections_presenting_its_certificate,a_token_bound_to_another_certificate_never_reaches_the_node,the_gateway_authenticates_by_its_certificate_and_sends_no_assertion,token_exchange_composes_with_mutual_tlsapp/ferrofed-engine/tests/it/nuts.rs:a_refused_nuts_grant_fails_the_node,the_nuts_token_reaches_the_node_dpop_boundapp/ferrofed-engine/tests/it/onward.rs:a_401_drops_the_token_and_the_next_request_obtains_a_new_one,a_refused_token_request_is_a_node_error_and_nothing_reaches_the_node,a_rotation_publishes_both_keys_for_the_overlap_window,a_token_endpoint_description_never_reaches_the_answer,a_token_is_cached_until_thirty_seconds_before_it_expires,an_unreachable_token_endpoint_is_a_node_error_and_nothing_reaches_the_node,the_assertion_carries_the_rfc_7523_claims,the_assertion_verifies_against_the_published_jwks_and_the_token_reaches_the_node,the_token_request_carries_the_rfc_7523_parameters,the_token_request_carries_the_scope_as_writtenapp/ferrofed-engine/tests/it/signing.rs:an_es256_signing_key_signs_the_client_assertion_es256app/ferrofed-server/tests/it/access/emergency.rs:the_emergency_purpose_is_conveyed_to_the_nodeapp/ferrofed-server/tests/it/admission/onward.rs:beside_an_onward_lane_the_admission_check_uses_the_client_credentials_grant,the_admission_check_authenticates_with_the_onward_grantapp/ferrofed-server/tests/it/auth/attributes.rs:an_issuer_without_an_attributes_table_states_no_purposeapp/ferrofed-server/tests/it/auth/edge.rs:a_bearer_token_alone_is_401_at_the_edge,an_assertion_of_another_issuer_is_401,an_assertion_of_the_edge_admits_and_is_recordedapp/ferrofed-server/tests/it/auth/introspection.rs:an_active_token_for_another_audience_is_401,an_active_token_for_this_gateway_is_admitted,an_answer_past_its_expiry_is_401,an_endpoint_answering_an_error_is_503,an_inactive_token_is_401,an_unreachable_endpoint_is_503app/ferrofed-server/tests/it/auth/kept_token.rs:an_edge_assertion_is_never_kept_as_a_token,no_token_is_kept_where_no_node_exchanges_it,the_verified_token_is_kept_for_an_exchanging_node_and_never_shownapp/ferrofed-server/tests/it/auth/keys.rs:a_key_set_answered_with_an_error_is_503,a_key_set_file_verifies_and_a_missing_one_is_503,a_key_set_is_fetched_once_and_verifies_every_token,a_rotated_key_is_picked_up_by_one_refetch,an_es384_key_verifies_its_tokens,an_unknown_key_refetches_at_most_once_per_interval,an_unreachable_key_set_is_503app/ferrofed-server/tests/it/auth/other_patient.rs:a_query_for_another_patient_asks_the_bound_member_alone,a_read_by_subject_of_another_patient_asks_the_bound_member_alone,the_token_s_own_patient_is_localized_and_prefilteredapp/ferrofed-server/tests/it/auth/patient.rs:a_class_beside_the_scoped_ehr_under_and_is_refused,a_class_beside_the_scoped_ehr_under_or_is_refused,a_containment_nested_under_the_scoped_ehr_is_admitted,a_cross_reference_contradicting_the_token_is_a_424,a_patient_grant_creates_no_ehr,a_patient_grant_never_reaches_the_demographic_api,a_patient_query_with_a_class_beside_its_ehr_is_refused,a_patient_token_without_an_ehr_id_is_refused,a_population_query_is_refused,a_query_for_another_patient_is_refused,a_read_by_subject_reaches_the_patient_s_own_ehr_alone,a_refused_read_by_subject_records_nothing_of_the_other_patient,a_second_ehr_variable_is_refused,an_ehr_id_outside_the_patient_s_pairs_is_refused_with_no_probe,an_ehr_under_not_contains_is_refused,the_patient_s_ehr_id_routes_to_its_own_member,the_token_s_ehr_id_at_another_member_is_refused,the_token_s_own_patient_is_admitted_at_every_member,without_the_opt_in_a_patient_grant_still_admits_nothingapp/ferrofed-server/tests/it/auth/purpose.rs:a_deployment_that_declares_it_optional_admits_a_token_without_one,a_purpose_in_authorization_details_is_admitted,a_token_without_a_purpose_of_use_is_403,an_iua_coding_without_a_code_declares_noneapp/ferrofed-server/tests/it/auth/scope.rs:a_composition_read_needs_a_composition_read_over_every_template,a_named_aql_pattern_does_not_cover_an_ad_hoc_query,a_named_template_is_covered_by_its_own_pattern,a_patient_grant_admits_no_ehr_id_route,a_patient_grant_admits_no_query,a_query_without_an_aql_search_scope_is_403,a_scope_outside_the_grammar_and_no_scope_grant_nothing,a_stored_query_needs_a_pattern_over_its_name,a_system_wide_aql_grant_counts_only_for_a_listed_backend_client,an_admin_operation_is_refused_to_every_caller,an_ehr_status_update_needs_a_composition_update,the_demographic_api_admits_only_a_listed_clientapp/ferrofed-server/tests/it/auth/token.rs:a_basic_credential_is_401,a_bearer_value_outside_b64token_is_401,a_query_without_a_token_is_401_and_reaches_no_node,a_token_expired_within_the_clock_skew_is_admitted,a_token_for_another_audience_is_401,a_token_from_an_untrusted_issuer_is_401,a_token_naming_an_unpublished_key_is_401,a_token_not_typed_as_an_access_token_is_401,a_token_not_yet_valid_is_401,a_token_signed_by_another_key_under_a_trusted_name_is_401,a_token_with_a_blank_sub_is_401,a_token_without_its_client_id_is_401,an_admitted_token_reaches_no_node,an_expired_token_is_401,an_hmac_token_is_401,an_unsigned_token_is_401,options_root_is_behind_the_gate,two_authorization_fields_are_400app/ferrofed-server/tests/it/conveyance.rs:a_query_conveys_the_verified_caller_to_each_node_signed_for_itapp/ferrofed-server/tests/it/e2e/onward_cdr.rs(e2e):a_persons_query_reaches_the_cdr_under_the_exchanged_token_aloneapp/ferrofed-server/tests/it/e2e/track7.rs(e2e):a_caller_that_does_not_authenticate_reaches_no_node,each_node_is_reached_with_its_onward_token_and_told_the_callerapp/ferrofed-server/tests/it/next_key.rs:the_next_key_is_published_and_never_signsapp/ferrofed-server/tests/it/onward.rs:a_refusal_description_reaches_the_log_and_never_the_answer,a_rotation_publishes_the_current_and_the_previous_key,a_token_endpoint_down_fails_the_node_and_sends_it_nothing,options_declares_the_jwks_uri_and_validates_against_the_schema,the_node_receives_the_token_its_endpoint_issued_against_the_published_jwksapp/ferrofed-server/tests/it/onward_exchange.rs:a_dpop_bound_grant_proves_every_request_to_its_node,an_edge_asserted_caller_fails_an_exchanging_node_with_nothing_sent,node_a_receives_a_token_exchanged_for_the_verified_caller,the_callers_token_reaches_no_log_span_metric_or_conveyed_claim,the_exchange_asks_for_the_covering_scope_in_canonical_formapp/ferrofed-server/tests/it/onward_fapi2.rs:a_refused_fapi2_grant_fails_node_a_and_names_no_credential,a_rotated_client_key_is_published_beside_the_previous_one_and_alone_signs,node_a_receives_the_dpop_bound_fapi2_tokenapp/ferrofed-server/tests/it/onward_lane.rs:a_caller_the_issuer_does_not_carry_is_refused_before_anything_is_sent,a_person_reaches_the_node_under_the_exchanged_token,a_refused_exchange_fails_the_node_with_nothing_sent,no_inbound_request_reaches_the_node_under_the_client_credentials_tokenapp/ferrofed-server/tests/it/onward_mtls.rs:a_node_is_reached_with_a_token_bound_to_the_certificate_its_section_names,a_token_bound_to_another_certificate_never_reaches_the_nodeapp/ferrofed-server/tests/it/onward_nuts.rs:a_refused_nuts_grant_fails_node_a_and_names_no_credential,node_a_receives_the_dpop_bound_nuts_token,the_served_did_document_verifies_the_gateways_presentation
CP-18
4 tests.
tools/ferrofed-testkit/tests/it/e2e/node_profile.rs(e2e):ferroehr_is_checked_for_holding_its_own_access_decision,ferroehr_is_checked_for_passing_its_errors_throughtools/ferrofed-testkit/tests/it/e2e/node_profile/ehrbase.rs(e2e):ehrbase_is_checked_for_holding_its_own_access_decision,ehrbase_is_checked_for_passing_its_errors_through
CP-19
2 tests.
tools/ferrofed-testkit/tests/it/e2e/node_profile.rs(e2e):ferroehr_is_checked_for_withholding_a_restricted_compositiontools/ferrofed-testkit/tests/it/e2e/node_profile/ehrbase.rs(e2e):ehrbase_has_no_consent_refusal_arranged_in_the_harness
CP-21
10 tests.
app/ferrofed-server/tests/it/base_url.rs:a_read_by_subject_is_served_at_the_root_and_under_a_prefix,a_routed_read_is_served_at_the_root_and_under_a_prefix,a_routed_write_is_served_at_the_root_and_under_a_prefix_with_location_untouched,no_prefix_is_reserved_at_the_root,options_describes_the_surface_at_the_root_and_under_a_prefix,the_base_path_is_the_root_by_default_and_the_deployment_chooses_any_other,the_federated_query_is_served_at_the_root_and_under_a_prefix,under_a_prefix_nothing_is_served_outside_itapp/ferrofed-server/tests/it/e2e/track9.rs(e2e):an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefixapp/ferrofed-server/tests/it/fhir/summary.rs:an_its_rest_path_under_the_base_still_answers_as_it_did
CP-22
12 tests.
app/ferrofed-server/tests/it/e2e/track9.rs(e2e):an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefixapp/ferrofed-server/tests/it/ehr_scope.rs:a_scoped_query_reaches_only_the_member_that_holds_the_ehr_id,a_targeted_scoped_query_goes_to_the_named_endpoint_and_nothing_is_probed,both_forms_send_the_owner_the_same_query_and_answer_the_same_rows,the_index_routes_a_scoped_query_and_nothing_is_probed,two_members_holding_the_ehr_id_are_a_409_and_neither_is_queriedcrates/openehr-federation/tests/it/aql/rules.rs:a_query_already_scoped_by_ehr_id_is_accepted_as_writtencrates/openehr-federation/tests/it/aql/scope.rs:a_bound_parameter_scopes_the_query_in_either_form,a_patient_query_in_the_from_form_dispatches_the_canonical_scope_and_no_identifier,an_unnamed_ehr_in_the_from_form_is_scoped_through_a_fresh_variable,both_forms_name_the_same_ehr_id_and_reach_a_node_as_the_same_query,the_patient_identifier_written_as_an_ehr_id_is_refused_in_either_form
CP-23
39 tests.
app/ferrofed-server/tests/it/auth/token.rs:options_root_is_behind_the_gateapp/ferrofed-server/tests/it/consent_withheld/mod.rs:by_default_the_exclusion_is_disclosed_and_declared,the_configured_setting_withholds_the_exclusion_and_is_declaredapp/ferrofed-server/tests/it/contact_point/declared.rs:a_declared_contact_point_is_described_with_how_its_requests_are_served,a_deployment_with_no_contact_point_declares_none,a_tightened_emergency_limit_is_declared_as_the_one_in_effectapp/ferrofed-server/tests/it/e2e/track9_surface.rs(e2e):the_self_description_lists_the_members_and_demographic_answers_as_declaredapp/ferrofed-server/tests/it/fhir/native.rs:the_native_endpoint_is_absent_from_the_self_descriptionapp/ferrofed-server/tests/it/its_rest_areas/definition.rs:the_definition_declaration_states_that_a_request_goes_to_the_one_named_node,the_definition_declaration_states_the_distribution_only_where_offered,the_definition_declaration_states_the_template_fan_out_only_where_offered,the_definition_declaration_states_where_stored_queries_are_heldapp/ferrofed-server/tests/it/its_rest_areas/demographic.rs:the_demographic_declaration_matches_the_demographic_area_in_each_modeapp/ferrofed-server/tests/it/its_rest_areas/ehr.rs:the_ehr_declaration_states_how_a_path_ehr_id_finds_its_owner,the_ehr_declaration_states_that_a_new_ehr_goes_only_to_the_named_endpoint,the_ehr_declaration_states_that_a_versioned_write_needs_the_controlling_node,the_ehr_declaration_states_the_read_by_subjectapp/ferrofed-server/tests/it/its_rest_areas/modes.rs:every_mode_declares_all_four_areas_and_each_follows_only_its_own_settingsapp/ferrofed-server/tests/it/its_rest_areas/query.rs:the_query_declaration_names_stored_query_execution_only_where_the_registry_is_offered,the_query_declaration_states_that_both_forms_of_an_adhoc_query_fan_outapp/ferrofed-server/tests/it/onward.rs:options_declares_the_jwks_uri_and_validates_against_the_schemaapp/ferrofed-server/tests/it/options.rs:an_endpoint_sent_requests_reports_its_median_and_95th_percentile,no_targeting_mechanism_no_carrier_and_no_async_member_is_declared,options_on_a_path_the_gateway_does_not_serve_is_not_implemented,options_on_a_sub_path_names_the_methods_served_there,the_body_validates_against_the_vendored_schema,the_completeness_modes_follow_the_configuration,the_decomposable_aggregates_follow_the_configuration,the_dedup_policy_names_the_default_the_modes_and_the_header,the_definition_area_declares_nothing_offered,the_description_needs_no_patient_identifier_and_holds_none,the_member_endpoints_follow_the_registry,the_offset_strategy_follows_the_configuration,the_options_root_answer_names_the_methods_of_the_base,the_row_ceiling_follows_the_configuration,the_template_fan_out_upload_follows_the_configuration,the_timeout_budget_follows_the_configurationapp/ferrofed-server/tests/it/stored/declaration.rs:options_declares_the_registry_and_the_methods_it_servescrates/openehr-federation/tests/it/examples.rs:the_7a_2_example_round_trips_unchanged
CP-24
23 tests.
app/ferrofed-engine/tests/it/forward.rs:the_body_arrives_byte_for_byte_and_only_the_declared_headers_travelapp/ferrofed-server/tests/it/contribution_write.rs:a_contribution_its_path_node_controls_reaches_it_once_byte_identicalapp/ferrofed-server/tests/it/declared.rs:a_body_sent_without_a_content_type_travels_with_the_one_its_operation_declaresapp/ferrofed-server/tests/it/e2e/commit.rs(e2e):a_composition_committed_through_the_gateway_lands_byte_identical_at_one_node,a_versioned_write_reaches_its_controlling_node_and_never_anotherapp/ferrofed-server/tests/it/e2e/track9.rs(e2e):an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefixapp/ferrofed-server/tests/it/ehr_by_subject.rs:a_subject_one_member_holds_is_that_members_ehr_read_by_its_ehr_idapp/ferrofed-server/tests/it/ehr_scope.rs:a_scoped_query_reaches_only_the_member_that_holds_the_ehr_idapp/ferrofed-server/tests/it/provenance.rs:a_directed_query_names_the_node_that_failed_it_once_the_node_was_asked,a_directed_query_that_asked_no_node_names_none,a_fan_out_lists_the_endpoints_that_contributed_rows_in_registry_order,a_query_directed_at_one_endpoint_names_it_and_its_system_id,an_all_or_nothing_failure_lists_no_endpoint,an_undirected_query_the_patient_resolves_for_at_one_member_names_it,the_provenance_headers_carry_registry_ids_and_nothing_the_request_sent,under_partial_a_failed_node_and_an_empty_one_are_not_listedapp/ferrofed-server/tests/it/routing/pass_through.rs:a_committed_composition_lands_byte_identical_and_location_and_etag_pass_through,a_query_parameter_its_rest_defines_is_forwarded_as_received,a_redirect_is_the_nodes_answer_and_is_never_followed,every_routed_answer_names_the_acting_endpoint_and_its_system_id,the_nodes_own_404_and_500_pass_through_as_the_node_sent_themapp/ferrofed-server/tests/it/versioned_write.rs:a_new_ehr_lands_byte_identical_at_the_one_named_node,a_versioned_write_lands_byte_identical_and_carries_no_identifier_outside_its_body
CP-25
14 tests.
app/ferrofed-server/tests/it/demographic.rs:a_header_naming_another_endpoint_several_or_a_star_is_refused,a_header_naming_the_declared_endpoint_is_accepted,by_default_a_read_and_a_create_are_501_and_no_node_is_asked,options_declares_each_mode_and_the_behaviour_matches_it,options_on_a_party_names_its_methods_only_when_routed,the_conflict_names_both_endpoints,with_the_setting_a_create_body_is_forwarded_unchanged,with_the_setting_a_party_read_reaches_only_that_node_byte_identical,with_the_setting_a_path_naming_no_its_rest_operation_is_still_501,with_the_setting_a_request_naming_no_endpoint_goes_to_the_declared_one_aloneapp/ferrofed-server/tests/it/e2e/track9_surface.rs(e2e):the_self_description_lists_the_members_and_demographic_answers_as_declaredapp/ferrofed-server/tests/it/errors.rs:an_unexposed_its_rest_area_is_a_501_not_implementedapp/ferrofed-server/tests/it/its_rest_areas/demographic.rs:the_demographic_declaration_matches_the_demographic_area_in_each_modeapp/ferrofed-server/tests/it/its_rest_areas/modes.rs:every_mode_declares_all_four_areas_and_each_follows_only_its_own_settings
CP-26
165 tests.
app/ferrofed-engine/src/declared/headers.rs:a_utf_8_charset_passes_and_is_dropped,an_accept_list_reaches_the_node_as_its_best_listed_match,an_accept_that_admits_nothing_listed_is_not_acceptable,an_unlisted_content_type_is_unsupported,any_media_type_reaches_the_node_as_the_first_listed,prefer_reaches_the_node_as_its_listed_preferences_onlyapp/ferrofed-engine/src/declared/mod.rs:a_free_text_parameter_passes_unclassified,a_malformed_date_time_is_refused_by_position_never_by_value,a_well_formed_date_time_is_heldapp/ferrofed-engine/src/declared/path.rs:a_uid_based_id_parses_as_a_version_or_an_object_id,a_uuid_path_parameter_is_a_canonical_uuid,a_version_uid_parses_as_an_object_version_id,the_uid_based_id_of_a_delete_parses_as_an_object_version_idapp/ferrofed-engine/src/hygiene/forwarded.rs:every_federation_header_is_withheld_even_where_an_operation_declares_itapp/ferrofed-engine/src/hygiene/mod.rs:the_authority_of_the_url_is_never_read,the_identifier_in_a_conveyed_claim_names_the_conveyance_header,the_path_query_and_fragment_are_read_under_any_authorityapp/ferrofed-engine/src/outbound_id.rs:a_withheld_value_inside_the_minted_id_is_sent_with_that_id,the_same_value_in_the_aql_is_still_never_sentapp/ferrofed-engine/tests/it/conveyance.rs:a_caller_claim_carrying_the_withheld_identifier_stops_a_query_unsent,a_caller_claim_carrying_the_withheld_identifier_stops_a_routed_request_unsent,a_caller_claim_carrying_the_withheld_identifier_stops_an_ehr_call_and_a_definitionapp/ferrofed-engine/tests/it/dispatch/sent.rs:every_header_a_node_receives_is_in_the_inventory,the_request_id_a_node_receives_is_the_minted_outbound_id_byte_for_byteapp/ferrofed-engine/tests/it/exchange.rs:a_caller_token_carrying_the_withheld_identifier_is_never_exchangedapp/ferrofed-engine/tests/it/fanout/all_or_nothing.rs:the_one_outbound_id_reaches_every_nodeapp/ferrofed-engine/tests/it/fapi2/mod.rs:a_callers_token_carrying_the_patient_is_never_sent,no_carrier_to_the_server_or_the_node_names_the_patientapp/ferrofed-engine/tests/it/forward.rs:a_free_text_parameter_passes_unclassified,a_header_the_operation_declares_travels_and_one_it_does_not_is_stripped,a_malformed_date_time_is_refused_and_no_node_is_asked,a_query_parameter_the_operation_does_not_declare_is_refused_unsent,a_well_formed_date_time_is_forwarded_byte_identical,a_withheld_identifier_in_the_body_of_a_commit_is_sent_unchanged,a_withheld_identifier_in_the_path_or_a_forwarded_header_is_never_sent,a_withheld_value_in_a_client_header_is_refused_though_the_registry_authority_holds_it,a_withheld_value_in_the_registry_path_is_never_forwarded,an_accept_list_reaches_the_node_as_its_best_listed_match,an_accept_that_admits_nothing_listed_is_not_acceptable_unsent,an_identifier_inside_the_composed_ehr_id_is_forwarded,any_media_type_reaches_the_node_as_the_first_listed,the_host_a_node_receives_is_the_registry_authority_never_the_client_host,the_same_identifier_in_a_part_the_client_wrote_is_still_withheldapp/ferrofed-engine/tests/it/gate.rs:a_clean_request_is_sent_with_identifiers_withheld,a_fan_out_with_a_leaking_query_fails_closed_and_asks_nobody,a_short_identifier_inside_the_scope_ehr_id_is_sent,a_withheld_value_in_the_registry_authority_is_sent,a_withheld_value_in_the_registry_path_is_never_sent,a_withheld_value_inside_the_minted_id_is_sent_with_that_id,an_identifier_in_the_aql_text_is_never_sent,the_header_the_gateway_adds_is_its_minted_id_and_carries_no_identifier,the_same_short_identifier_elsewhere_is_never_sentapp/ferrofed-engine/tests/it/masking.rs:a_short_identifier_in_the_base_path_is_found,a_short_identifier_inside_the_composed_ehr_id_passes,an_ehr_id_segment_the_client_wrote_is_never_masked,the_same_short_identifier_in_a_client_part_is_still_foundapp/ferrofed-engine/tests/it/native.rs:a_request_that_would_carry_a_withheld_identifier_is_never_sent,the_member_is_asked_by_its_ehr_id_alone_and_its_document_comes_back_unchangedapp/ferrofed-engine/tests/it/native_create.rs:an_update_leaves_once_by_the_members_id_with_its_body_byte_for_byte,the_body_leaves_byte_for_byte_once_and_nothing_else_carries_the_patientapp/ferrofed-engine/tests/it/nuts_service.rs:the_pseudonym_reaches_neither_the_authorization_server_nor_a_proofapp/ferrofed-engine/tests/it/onward.rs:a_token_endpoint_description_never_reaches_the_answerapp/ferrofed-identity/tests/it/mitz.rs:a_patient_named_by_a_pseudonym_is_never_sent_to_mitzapp/ferrofed-identity/tests/it/nvi_localizer.rs:a_bsn_system_never_stands_for_the_pseudonymapp/ferrofed-server/tests/it/collision_clear.rs:the_clear_is_recorded_with_its_operator_listed_and_names_no_patient_identifierapp/ferrofed-server/tests/it/contact_point/relayed.rs:no_iua_person_id_reaches_a_nodeapp/ferrofed-server/tests/it/contribution_write.rs:a_contribution_its_path_node_controls_reaches_it_once_byte_identical,a_simplified_contribution_its_path_node_controls_reaches_it_once_byte_identicalapp/ferrofed-server/tests/it/conveyance.rs:a_caller_claim_carrying_the_patient_stops_the_query_before_any_node,a_professional_claim_carrying_the_patient_stops_the_query_before_any_nodeapp/ferrofed-server/tests/it/declared.rs:a_free_text_parameter_passes_unclassified,a_malformed_path_uid_is_refused_by_position_and_no_node_is_asked,a_malformed_version_at_time_is_refused_and_no_node_is_asked,a_well_formed_version_at_time_reaches_the_node_byte_identical,an_accept_that_admits_nothing_listed_is_a_406_that_asks_no_node,an_unlisted_content_type_is_a_415_that_asks_no_node,the_listed_values_of_a_commit_reach_the_node_and_no_client_textapp/ferrofed-server/tests/it/definition.rs:a_definition_request_carries_only_what_its_operation_declares,a_malformed_declared_value_is_refused_before_the_missing_targetapp/ferrofed-server/tests/it/directive.rs:no_node_receives_the_directive_and_each_receives_the_undirected_node_query,the_organisation_selector_asks_every_endpoint_the_organisation_managesapp/ferrofed-server/tests/it/e2e/national/restricted.rs(e2e):a_patient_every_member_restricts_is_looked_up_as_one_no_member_holds,a_restricting_member_leaves_no_trace_in_any_answerapp/ferrofed-server/tests/it/e2e/national/served.rs(e2e):the_national_connector_reads_the_summary_and_prescriptions_and_dispensesapp/ferrofed-server/tests/it/e2e/track10.rs(e2e):a_committed_dv_identifier_arrives_at_a_ferroehr_node_byte_identical,the_four_positions_reach_neither_ferroehr_node_on_any_pathapp/ferrofed-server/tests/it/ehr_by_subject.rs:a_plus_in_the_subject_is_a_literal_plus,a_short_subject_inside_the_registry_base_path_is_never_forwarded,a_short_subject_inside_the_routed_ehr_id_still_forwards,a_subject_not_given_once_or_an_undeclared_parameter_is_a_400_that_asks_nobody,a_subject_one_member_holds_is_that_members_ehr_read_by_its_ehr_id,a_subject_several_members_hold_is_a_400_listing_them_and_asks_nobody,a_subject_that_is_no_utf8_text_is_a_400_that_asks_no_resolver,the_targeting_header_names_which_of_several_holders_answersapp/ferrofed-server/tests/it/ehr_id_collision.rs:an_ehr_id_that_is_no_uuid_is_never_named_by_an_incident,no_incident_or_log_line_names_a_patient_identifierapp/ferrofed-server/tests/it/ehr_scope.rs:an_ehr_id_that_is_no_uuid_is_never_probed_nor_sent,neither_form_carries_the_patient_identifier_to_a_nodeapp/ferrofed-server/tests/it/fhir/binary.rs:a_read_asks_the_member_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/header.rs:the_header_is_the_supplier_s_and_no_header_value_reaches_a_nodeapp/ferrofed-server/tests/it/fhir/list/mod.rs:a_native_member_is_asked_for_the_list_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/list/own_laboratory.rs:a_members_own_report_is_listed_unchanged_but_for_its_url_and_read_unchangedapp/ferrofed-server/tests/it/fhir/native.rs:a_native_member_is_asked_by_its_ehr_id_alone_and_left_out_of_the_mapped_documentapp/ferrofed-server/tests/it/fhir/patient_grant.rs:a_persons_own_grant_gets_their_summary_and_no_identifier_reaches_a_node_or_a_logapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs:a_dispensation_naming_its_prescription_by_the_faces_reference_alone_is_refused,a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchangedapp/ferrofed-server/tests/it/fhir/prescriptions/dispensed.rs:the_dispensation_a_receipt_located_is_read_at_its_member_by_its_ehr_idapp/ferrofed-server/tests/it/fhir/prescriptions/mod.rs:each_member_is_asked_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/prescriptions/read.rs:the_member_is_asked_by_its_ehr_id_and_its_own_id_aloneapp/ferrofed-server/tests/it/fhir/prescriptions/vread.rs:the_location_a_receipt_answered_reads_back_the_dispensation_it_locatedapp/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs:a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_idapp/ferrofed-server/tests/it/fhir/receive/inserted.rs:a_persons_own_document_is_marked_as_inserted_by_them_with_no_identifier_addedapp/ferrofed-server/tests/it/fhir/receive/representative.rs:a_representatives_document_names_them_as_every_entrys_providerapp/ferrofed-server/tests/it/fhir/receive/write.rs:a_document_is_written_to_its_member_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/summary.rs:the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_nodeapp/ferrofed-server/tests/it/follow_up.rs:a_version_read_reaches_the_node_byte_identical_and_carries_no_identifierapp/ferrofed-server/tests/it/identity_export.rs:the_export_names_no_patient_identifier_while_bindings_exist_and_is_recordedapp/ferrofed-server/tests/it/mitz/config.rs:the_pseudonym_listed_as_the_bsn_is_refusedapp/ferrofed-server/tests/it/mitz/decision.rs:the_bsn_reaches_mitz_only_and_no_node_log_or_errorapp/ferrofed-server/tests/it/nl_gf.rs:a_bsn_system_listed_as_the_pseudonym_is_refused_at_load,the_pseudonymised_walkthrough_runs_end_to_end_and_no_node_sees_the_pseudonymapp/ferrofed-server/tests/it/nvi_nuts.rs:the_nuts_token_reaches_the_nvi_aloneapp/ferrofed-server/tests/it/outbound.rs:a_clinician_predicate_reaches_the_node_unchanged,a_short_identifier_inside_the_nodes_ehr_id_is_answered,an_identifier_in_the_clients_query_string_and_headers_is_never_forwarded,either_carrier_and_a_projection_reach_a_node_as_its_ehr_id_alone,the_identifier_on_another_path_is_a_400_that_asks_nobodyapp/ferrofed-server/tests/it/outbound_id.rs:a_client_request_id_never_reaches_a_node_on_the_fan_out,a_failed_query_is_logged_under_the_id_its_request_line_records,two_requests_reach_the_nodes_under_two_ids,with_no_client_id_the_response_and_every_node_share_the_minted_idapp/ferrofed-server/tests/it/path_ehr_id/probe.rs:neither_the_probe_nor_the_routed_read_carries_an_identifier_or_the_clients_credentialapp/ferrofed-server/tests/it/pdqm/flow.rs:neither_identifier_reaches_a_log_line_a_metric_or_a_nodeapp/ferrofed-server/tests/it/probed_ehr_id.rs:a_read_whose_ehr_id_is_a_uuid_is_still_resolved_by_the_probe,a_read_whose_ehr_id_is_no_uuid_and_that_nothing_routes_is_refused_and_probes_nobody,an_ehr_id_that_is_no_uuid_is_forwarded_to_the_named_node_alone_and_then_indexedapp/ferrofed-server/tests/it/query_get.rs:a_client_ehr_id_in_the_query_string_is_dropped,a_quoted_identifier_bound_as_the_subject_resolves_as_the_posted_string,a_quoted_identifier_in_a_get_query_parameter_is_refused_before_dispatch,a_quoted_identifier_in_a_stored_get_query_parameter_is_refused_before_dispatchapp/ferrofed-server/tests/it/routing/hygiene.rs:an_identifier_in_a_client_header_never_reaches_the_node_nor_does_its_authorization,an_identifier_in_a_query_parameter_is_refused_before_anything_is_sentapp/ferrofed-server/tests/it/routing_log.rs:no_event_of_a_routed_request_names_the_clients_idapp/ferrofed-server/tests/it/traces/traceparent.rs:an_identifier_hidden_in_the_clients_trace_id_reaches_no_node_and_no_spanapp/ferrofed-server/tests/it/track10/mock.rs:a_committed_dv_identifier_arrives_at_the_node_byte_identical,a_panicking_handler_holding_the_identifier_leaves_it_on_neither_stderr_nor_the_log,the_identifier_in_a_party_identified_predicate_reaches_no_node,the_identifier_in_a_projection_reaches_no_node,the_identifier_in_an_external_ref_predicate_reaches_no_node,the_identifier_in_the_ehr_id_slot_is_never_probed_at_a_node,the_identifier_in_the_query_string_or_a_header_reaches_no_node,the_identifier_on_the_single_node_route_reaches_no_nodeapp/ferrofed-server/tests/it/versioned_write.rs:a_new_ehr_lands_byte_identical_at_the_one_named_node,a_versioned_write_lands_byte_identical_and_carries_no_identifier_outside_its_bodycrates/openehr-federation/tests/it/aql/directive.rs:a_directed_patient_query_reaches_the_node_as_the_undirected_one_does,the_organisation_selector_never_reaches_a_nodecrates/openehr-federation/tests/it/aql/golden.rs:golden_case_02_keeps_the_directive_and_its_projections_out_of_the_node_querycrates/openehr-federation/tests/it/aql/guard.rs:a_clinician_or_facility_predicate_is_dispatched_unchanged,the_same_path_compared_with_the_patient_identifier_is_refusedcrates/openehr-federation/tests/it/aql/hygiene.rs:a_seated_identifier_is_either_kept_from_the_node_or_refused,no_pushed_row_key_carries_the_patient,no_recombined_aggregate_query_carries_the_patient_identifier,the_avg_rewrite_reaches_the_node_without_the_patientcrates/openehr-federation/tests/it/aql/scope.rs:a_patient_query_in_the_from_form_dispatches_the_canonical_scope_and_no_identifier,the_patient_identifier_written_as_an_ehr_id_is_refused_in_either_form
CP-27
6 tests.
tools/ferrofed-testkit/tests/it/e2e/node_profile.rs(e2e):ferroehr_is_checked_against_the_identifier_integrity_conditions,ferroehr_is_checked_for_an_ehr_created_without_a_subject,ferroehr_is_invocable_on_its_local_ehr_id_alonetools/ferrofed-testkit/tests/it/e2e/node_profile/ehrbase.rs(e2e):ehrbase_is_checked_against_the_identifier_integrity_conditions,ehrbase_is_checked_for_an_ehr_created_without_a_subject,ehrbase_is_invocable_on_its_local_ehr_id_alone
CP-28
18 tests.
app/ferrofed-engine/src/hygiene/forwarded.rs:the_targeting_headers_are_withheld_from_every_operationapp/ferrofed-server/src/facade/target.rs:a_conflict_names_both_sets_and_answers_its_own_codeapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):the_endpoint_header_selects_what_the_directive_selectsapp/ferrofed-server/tests/it/routing/selection.rs:a_node_named_in_a_query_parameter_is_refused_and_never_routed,the_organisation_header_routes_to_the_one_endpoint_it_manages,the_routing_headers_select_one_endpoint_and_never_two_setsapp/ferrofed-server/tests/it/stored/invocation.rs:a_stored_query_is_targeted_by_the_header_as_by_the_directiveapp/ferrofed-server/tests/it/targeting.rs:a_header_and_a_directive_that_differ_are_refused_400_naming_both_sets,a_header_and_a_directive_that_select_the_same_set_proceed,a_query_parameter_targets_nothing,an_aggregate_the_header_directs_at_one_endpoint_reaches_it_unchanged,an_identifier_a_header_names_that_the_registry_does_not_know_is_refused_400,an_unknown_header_identifier_is_refused_even_beside_a_valid_directive,no_node_receives_a_targeting_header_or_its_value,the_endpoint_header_selects_the_node_set_as_the_directive_does,the_organisation_header_asks_every_endpoint_the_organisation_manages,the_two_headers_that_differ_are_refused_400app/ferrofed-server/tests/it/template_fan_out.rs:a_star_beside_an_organisation_naming_fewer_members_is_a_conflict
CP-29
5 tests.
app/ferrofed-server/tests/it/dedup.rs:under_version_identity_one_row_comes_back_and_the_copy_is_namedapp/ferrofed-server/tests/it/dedup_write.rs:a_write_against_the_surviving_row_while_its_owner_is_down_is_a_409_and_reaches_no_node,a_write_through_the_owners_ehr_id_reaches_the_owner_alone_and_claims_no_copy,every_refusal_names_the_controlling_system_and_quotes_no_other_part_of_the_request,with_the_owner_up_a_write_through_the_copys_ehr_id_is_still_a_409
CP-30
34 tests.
app/ferrofed-engine/tests/it/fanout/all_or_nothing.rs:a_time_out_and_a_node_error_together_are_a_504,an_unreachable_node_is_offline_and_a_504,one_node_error_fails_the_query_424_with_the_nodes_error,one_node_timing_out_fails_the_query_504_with_the_envelopeapp/ferrofed-engine/tests/it/fanout/best_effort.rs:a_consent_denied_node_is_a_200_with_the_rest_in_either_mode,a_node_error_under_best_effort_is_a_200_naming_the_nodes_error,a_time_out_under_best_effort_is_a_200_with_the_answering_rows,an_offline_node_under_best_effort_is_a_200_reporting_it,every_node_not_resolved_is_a_200_with_no_rows_in_either_mode,members_out_of_scope_never_clear_complete_in_either_modeapp/ferrofed-engine/tests/it/fanout/consent.rs:a_403_carrying_a_listed_code_is_consent_denied_and_fails_nothingapp/ferrofed-engine/tests/it/oversized.rs:an_answer_past_the_bound_is_node_error_and_fails_the_query_424app/ferrofed-server/tests/it/completeness.rs:all_stated_explicitly_is_accepted_and_fails_closed,partial_where_best_effort_is_not_offered_is_refused,partial_where_offered_is_a_200_with_the_answering_nodes_rowsapp/ferrofed-server/tests/it/consent.rs:a_member_the_prefilter_leaves_in_is_asked_and_its_refusal_still_reported,a_node_the_localizer_names_still_refuses_and_the_query_succeeds,with_no_consent_service_a_node_refusal_is_reported_and_the_query_succeedsapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):an_undirected_query_asks_only_the_members_the_localizer_namesapp/ferrofed-server/tests/it/e2e/track4.rs(e2e):a_node_answering_an_error_fails_the_query_424_and_is_named_node_error,a_slow_node_is_abandoned_at_the_per_node_timeout_and_named_time_out,an_unreachable_node_fails_the_query_504_and_is_named_offline,found_nowhere_and_consent_denied_are_200_and_never_424,partial_returns_the_answering_nodes_rows_only_where_offeredapp/ferrofed-server/tests/it/e2e/track7.rs(e2e):a_member_the_consent_service_admits_still_refuses_and_the_query_succeeds,with_no_consent_service_a_node_refusal_is_reported_and_the_query_succeedsapp/ferrofed-server/tests/it/errors.rs:a_424_and_a_504_echo_the_clients_q,a_node_error_under_all_or_nothing_is_a_424_carrying_the_envelope,a_node_not_found_inside_a_fan_out_is_a_node_error_and_a_424,a_node_timing_out_under_all_or_nothing_is_a_504_carrying_the_envelope,a_node_timing_out_under_best_effort_is_a_200_reporting_it,an_unreachable_node_under_all_or_nothing_is_a_504_carrying_the_envelopeapp/ferrofed-server/tests/it/facade/query.rs:a_node_error_carries_the_nodes_message_with_the_subject_maskedapp/ferrofed-server/tests/it/overload.rs:a_member_answer_past_the_read_bound_is_node_error_and_fails_the_query_424
CP-31
19 tests.
app/ferrofed-engine/tests/it/fanout/all_or_nothing.rs:a_node_answering_after_the_overall_budget_contributes_nothing,one_node_timing_out_fails_the_query_504_with_the_envelopeapp/ferrofed-engine/tests/it/fanout/budget.rs:a_longer_wait_leaves_the_configured_budget_in_force,a_shortened_budget_abandons_a_node_the_configured_one_would_wait_for,a_zero_wait_asks_no_node_and_reports_every_node_time_out,abandoning_one_node_never_aborts_another_in_flight,time_spent_before_the_dispatch_comes_out_of_the_overall_budgetapp/ferrofed-engine/tests/it/fanout/decision.rs:a_client_wait_shortens_the_budget_and_never_extends_itapp/ferrofed-server/tests/it/e2e/track4.rs(e2e):a_client_wait_shortens_the_budget_and_never_extends_it,a_slow_node_is_abandoned_at_the_per_node_timeout_and_named_time_outapp/ferrofed-server/tests/it/endpoint_report.rs:latency_is_reported_exactly_for_the_endpoints_dispatched_toapp/ferrofed-server/tests/it/timeouts.rs:a_longer_wait_never_extends_the_configured_budget,a_shorter_wait_shortens_the_budget_and_is_reported,a_shorter_wait_under_partial_returns_the_answering_rows,a_slow_node_under_the_default_timeouts_answers_the_504_envelope,a_zero_wait_asks_no_node_and_reports_every_node_time_out,respond_async_beside_a_shorter_wait_still_shortens_the_budget,respond_async_never_exempts_a_request_from_the_overall_budget,the_overall_budget_fires_before_the_tightest_accepted_request_timeout
CP-32
159 tests.
app/ferrofed-engine/tests/it/fanout/aggregate.rs:a_count_past_what_a_json_number_holds_is_an_error,a_node_that_does_not_answer_fails_the_aggregate_504,a_recombining_plan_is_refused_best_effort_before_dispatch,a_value_the_recombination_cannot_use_fails_the_query_424,an_avg_over_integers_answers_an_integer,the_node_answers_recombine_into_one_rowapp/ferrofed-engine/tests/it/fanout/distinct.rs:a_row_two_nodes_return_takes_one_slot_of_the_limitapp/ferrofed-engine/tests/it/fanout/order.rs:a_bounded_offset_page_is_sliced_from_k_plus_n_rows_per_node,a_bounded_window_out_of_the_federation_order_fails_the_query_424,a_cut_out_of_the_federation_order_fails_the_query_424,a_node_answering_past_its_limit_fails_the_query_424,interleaved_node_answers_merge_into_the_global_top_n,tied_keys_merge_identically_whichever_node_answers_first,under_best_effort_a_disagreeing_node_leaves_the_others_top_napp/ferrofed-server/tests/it/aggregate.rs:a_failing_node_fails_the_aggregate_424_with_no_row,a_partial_request_for_a_recombined_aggregate_is_refused_400,the_client_receives_one_recombined_row_in_its_own_columns,with_nothing_declared_the_aggregate_is_refused_400app/ferrofed-server/tests/it/distinct.rs:a_duplicate_straddling_the_page_edge_is_answered_onceapp/ferrofed-server/tests/it/e2e/aggregate.rs(e2e):an_undirected_avg_is_the_mean_weighted_by_the_counts_each_node_returnsapp/ferrofed-server/tests/it/e2e/track5.rs(e2e):an_undirected_aggregate_is_recombined_or_refused_and_a_directed_one_answered,order_by_and_limit_return_the_global_top_rows_deterministicallyapp/ferrofed-server/tests/it/node_fetch.rs:a_bounded_page_sends_every_node_k_plus_n_as_fetch,a_fetch_member_larger_than_the_aql_limit_keeps_the_limit,a_fetch_member_smaller_than_the_aql_limit_is_the_count,a_query_with_a_limit_sends_every_node_that_limit_as_fetch,an_offset_member_past_the_aql_limit_answers_no_row,the_composed_window_is_held_to_the_configured_bound,the_offset_member_starts_past_the_aql_offsetapp/ferrofed-server/tests/it/order.rs:a_bounded_offset_page_asks_each_node_for_k_plus_n_rows,a_bounded_offset_page_is_computed_afresh_and_carries_no_cursor,a_distinct_function_of_an_unselected_path_under_a_limit_is_refused_400,a_distinct_function_of_selected_paths_returns_the_same_rows_on_every_repeat,a_distinct_whole_object_under_a_limit_is_refused_400,a_node_out_of_the_federation_order_fails_the_query_424,a_page_past_the_configured_window_is_refused_400_naming_the_bound,an_ehr_only_query_returns_the_same_rows_on_every_repeat,bounded_pages_tied_across_their_edges_repeat_and_tile,the_client_gets_the_global_top_n_without_the_hidden_column,the_reject_strategy_refuses_any_offset_past_zero_400app/ferrofed-server/tests/it/order_key.rs:a_limit_on_a_dv_text_key_is_refused_400_and_asks_no_node,top_on_a_dv_text_key_is_refused_400_and_asks_no_node,with_no_limit_a_dv_text_key_is_answered_in_one_order_on_every_repeatcrates/openehr-federation/tests/it/aql/aggregate.rs:a_distinct_count_does_not_decompose,a_function_left_out_of_the_declaration_is_refused,a_plain_column_beside_an_aggregate_is_refused,a_recombined_aggregate_is_never_answered_best_effort,an_aggregate_directed_to_two_endpoints_is_recombined,an_indecomposable_aggregate_suggests_both_alternatives,avg_is_asked_of_each_node_as_its_sum_and_count,distinct_breaks_the_decomposition,every_declared_function_but_avg_is_dispatched_as_written,several_aggregates_map_to_their_node_columns,the_columns_are_the_clients_query_not_the_dispatched_one,with_nothing_declared_every_undirected_aggregate_is_refusedcrates/openehr-federation/tests/it/aql/comparable.rs:a_bounded_page_on_a_comparable_key_is_answered,a_bounded_page_on_an_incomparable_key_is_refused,a_later_incomparable_key_is_refused_and_located,a_limit_on_a_collection_is_refused,a_limit_on_a_dv_text_order_key_is_refused,a_limit_on_a_path_the_rm_does_not_resolve_is_refused,a_limit_on_a_whole_rm_object_is_refused,a_limit_on_an_element_value_is_refused,a_limit_on_primitives_and_ordered_data_values_is_answered,an_unscoped_limit_on_an_incomparable_key_is_refused,the_fetch_member_on_an_incomparable_key_is_refused,top_on_an_incomparable_key_is_refused,under_distinct_a_bounded_page_with_a_whole_rm_object_is_refused,under_distinct_a_limit_on_an_incomparable_order_by_key_is_refused,under_distinct_a_limit_with_a_collection_is_refused,under_distinct_a_limit_with_a_dv_text_is_refused,under_distinct_a_limit_with_a_path_the_rm_does_not_resolve_is_refused,under_distinct_a_limit_with_a_whole_rm_object_is_refused,under_distinct_a_limit_with_an_element_value_is_refused,under_distinct_a_limit_with_ordered_data_values_and_primitives_is_pinned,under_distinct_with_no_limit_an_incomparable_path_is_not_a_key,with_no_limit_an_incomparable_key_is_sent_as_written_and_ordered_at_the_tiercrates/openehr-federation/tests/it/aql/dedup.rs:a_limit_without_order_by_is_ordered_on_the_uid,under_order_by_the_row_key_is_the_dedup_keycrates/openehr-federation/tests/it/aql/function.rs:a_date_and_time_function_is_forwarded_across_a_fan_out,a_function_name_is_read_in_any_case,a_function_outside_aql_directed_to_two_endpoints_is_refused,a_function_outside_aql_is_refused_before_the_aggregate_beside_it,a_function_outside_aql_is_refused_in_a_patient_query_across_nodes,a_function_outside_aql_nested_in_an_aql_function_is_refused,a_numeric_function_is_forwarded_across_a_fan_out,a_string_function_is_forwarded_across_a_fan_out,an_aggregate_follows_the_decomposition_rules,an_undirected_function_outside_aql_in_select_is_refused,an_undirected_function_outside_aql_in_where_is_refused,the_refusal_suggests_both_alternatives,the_terminology_function_is_forwarded_across_a_fan_outcrates/openehr-federation/tests/it/aql/golden.rs:golden_case_05_is_refused_under_reject_and_paged_under_bounded,golden_case_06_is_refused_with_nothing_declared_and_recombined_under_the_default_setcrates/openehr-federation/tests/it/aql/hygiene.rs:no_pushed_row_key_carries_the_patientcrates/openehr-federation/tests/it/aql/offset.rs:a_fetch_member_larger_than_the_aql_limit_keeps_the_rows_the_limit_leaves,a_fetch_member_smaller_than_the_aql_limit_is_the_count,a_member_offset_that_overflows_the_skip_is_past_the_bound,a_page_past_the_bound_is_refused_naming_the_bound,a_page_whose_window_is_the_bound_is_accepted,a_window_that_overflows_is_past_the_bound,an_ehr_only_page_asks_each_node_for_k_plus_n_rows_tie_broken_on_the_ehr_id,an_offset_member_adds_to_the_aql_offset,an_offset_member_beside_the_aql_limit_is_refused_under_the_reject_strategy,an_offset_member_past_the_aql_limit_selects_no_row,an_offset_with_no_limit_is_refused_under_the_bounded_strategy,an_offset_with_no_order_by_is_refused_under_the_bounded_strategy,an_unscoped_query_is_paged_the_same_way,the_bounded_strategy_asks_each_node_for_k_plus_n_rows_and_never_pushes_offset,the_composed_window_is_held_to_the_bound,the_offset_and_fetch_members_page_like_the_clauses_under_the_bounded_strategy,the_reject_strategy_refuses_an_offset_past_zerocrates/openehr-federation/tests/it/aql/order.rs:a_containment_with_no_versioned_object_is_keyed_on_its_ehr,a_folder_is_never_a_key_and_a_query_with_no_key_is_dispatched_as_written,a_patient_query_scoped_to_one_ehr_pushes_no_ehr_key,a_selected_ehr_id_is_the_tie_break_where_it_is,a_version_outranks_the_ehr_and_the_ehr_outranks_its_status,an_ehr_only_query_is_tie_broken_on_the_ehr_id,an_ehr_status_with_no_ehr_class_is_keyed_on_its_uid,under_distinct_a_bounded_page_with_an_unpinned_function_is_refused,under_distinct_a_function_of_selected_paths_is_pinned_by_them,under_distinct_a_limit_with_a_function_of_an_unselected_path_is_refused,under_distinct_a_limit_with_a_value_from_outside_the_row_is_refused,under_distinct_an_ehr_only_query_gets_no_ehr_id_column,under_distinct_an_unpinned_function_with_no_limit_is_answered,without_distinct_a_function_column_is_pinned_by_the_row_keycrates/openehr-federation/tests/it/aql/rules.rs:an_undirected_aggregate_is_refused_with_its_reasoncrates/openehr-federation/tests/it/combine.rs:a_node_that_does_not_answer_one_row_is_refused,a_value_of_the_wrong_kind_refuses_its_node_and_leaves_no_row,a_value_the_gateway_cannot_write_exactly_is_an_error_never_a_rounding,an_aggregate_over_no_value_is_null_and_a_count_is_zero,an_avg_whose_sum_and_count_disagree_refuses_its_node,avg_over_integers_is_the_nearest_integer_a_tie_to_the_even_one,avg_over_integers_rounds_once_over_the_federation_sum_and_count,avg_with_a_real_node_sum_is_the_decimal_mean,every_recombined_function_is_the_aggregate_over_the_union,min_and_max_over_date_times_compare_instants_and_keep_the_written_value,reals_add_in_decimal_arithmetic,the_recombination_does_not_depend_on_the_order_the_nodes_answered_in,the_recombined_row_is_cut_at_the_limit_and_offset,values_no_one_order_compares_refuse_every_node_that_holds_one,with_no_answer_there_is_no_rowcrates/openehr-federation/tests/it/dedup/distinct.rs:under_dedup_a_node_cut_on_two_case_variants_is_refusedcrates/openehr-federation/tests/it/dedup/order.rs:a_case_variant_copy_at_a_page_edge_ranks_as_its_version_and_keeps_its_text,a_node_that_orders_uids_byte_for_byte_across_case_is_refused_at_its_cut,a_suppressed_copy_does_not_take_a_slot_of_the_limit,without_dedup_a_uid_orders_by_code_point_as_sentcrates/openehr-federation/tests/it/dedup/properties.rs:dedup_with_order_by_limit_and_a_bounded_page_is_the_page_of_the_deduplicated_unioncrates/openehr-federation/tests/it/distinct.rs:a_duplicate_straddling_a_page_edge_takes_one_slot,distinct_runs_before_the_limit,distinct_with_order_by_and_a_bounded_page_is_the_page_of_the_distinct_union,distinct_without_order_by_keeps_limit_distinct_values_of_the_unioncrates/openehr-federation/tests/it/merge.rs:a_cut_node_out_of_the_federation_order_is_refused,a_node_whose_k_plus_n_rows_disagree_with_the_federation_order_is_refused,an_uncut_node_may_answer_in_any_order,the_bounded_offset_page_is_the_page_of_the_full_merged_set,the_bounded_offset_page_matches_the_full_merge_for_every_split,the_merge_does_not_depend_on_the_order_the_nodes_answered_in,the_pushed_down_merge_is_the_order_and_limit_of_the_union
CP-33
53 tests.
app/ferrofed-server/tests/it/caller_bindings.rs:a_caller_with_no_query_has_no_binding,another_caller_never_sees_the_binding,the_caller_who_queried_is_routed_by_its_binding_firstapp/ferrofed-server/tests/it/collision_clear.rs:a_cleared_collision_that_still_stands_is_probed_and_refused_409_again,a_remedied_collision_reads_from_the_claimant_left_once_cleared,a_write_after_the_clear_is_never_probed_for,the_clear_forgets_the_binding_of_every_session,the_clear_is_recorded_with_its_operator_listed_and_names_no_patient_identifierapp/ferrofed-server/tests/it/contribution_write.rs:a_contribution_without_a_target_is_never_probed_forapp/ferrofed-server/tests/it/created_ehr_id.rs:a_binding_placing_the_ehr_id_at_a_refuses_a_create_at_b,a_create_at_b_of_an_ehr_id_the_index_holds_at_a_is_refused_and_sends_nothing,an_unknown_ehr_id_is_created_where_named_and_then_indexed_there,two_creates_racing_for_one_ehr_id_at_two_members_raise_the_index_insert_alarmapp/ferrofed-server/tests/it/e2e/track11.rs(e2e):a_read_and_a_query_of_the_duplicate_are_refused_and_only_the_probe_reaches_a_node,a_versioned_write_no_earlier_step_routes_is_refused_and_no_node_is_asked,once_the_index_holds_both_claimants_no_read_or_write_reaches_either_nodeapp/ferrofed-server/tests/it/e2e/track6.rs(e2e):a_follow_up_read_of_a_row_reaches_the_cdr_that_holds_it_with_its_uid_unchanged,an_unseen_ehr_id_is_probed_read_only_and_a_write_to_it_is_never_probedapp/ferrofed-server/tests/it/ehr_id_collision.rs:a_binding_collision_names_both_claimants_and_raises_one_incident,a_probe_collision_is_a_409_naming_both_with_one_incident_and_neither_is_read,a_write_in_collision_is_refused_409_and_reaches_no_node,an_ehr_id_that_is_no_uuid_is_never_named_by_an_incident,an_explicit_target_routes_an_ehr_id_in_collision_to_the_named_node,no_incident_or_log_line_names_a_patient_identifier,the_index_insert_alarm_fires_once_and_the_index_then_routes_neither,the_querying_callers_binding_finds_the_collision_first_and_routes_neitherapp/ferrofed-server/tests/it/ehr_scope.rs:two_members_holding_the_ehr_id_are_a_409_and_neither_is_queriedapp/ferrofed-server/tests/it/fhir/prescriptions/read.rs:an_ehr_id_two_members_claim_refuses_the_read_with_one_incidentapp/ferrofed-server/tests/it/follow_up.rs:a_version_read_reaches_the_node_byte_identical_and_carries_no_identifier,a_version_read_whose_creator_the_registry_does_not_map_falls_to_the_probe,a_versioned_write_no_earlier_step_locates_reaches_its_creator_by_step_4,a_versioned_write_whose_path_carries_no_version_is_still_a_400_with_no_earlier_step,an_imported_copy_is_read_at_the_endpoint_the_client_names,an_imported_copy_is_read_where_the_index_places_the_path_ehr_id_never_at_its_creator,with_no_target_binding_or_index_a_version_read_reaches_its_creator_by_step_4app/ferrofed-server/tests/it/path_ehr_id/order.rs:a_held_binding_wins_over_the_index,a_step_naming_two_members_names_no_owner_and_no_later_step_picks_one,step_4_names_no_member_for_a_creating_system_id_the_registry_does_not_route,step_4_names_the_member_whose_system_id_is_the_creating_system_id,the_explicit_target_wins_over_a_binding_and_the_index,the_index_answers_when_no_target_and_no_binding_doesapp/ferrofed-server/tests/it/path_ehr_id/probe.rs:a_read_no_earlier_step_routes_goes_to_the_one_member_the_probe_finds,a_read_of_the_ehr_itself_is_answered_by_the_owners_probe_answer,a_resolution_teaches_the_index_and_a_follow_up_goes_to_the_resolving_member,the_index_never_overrides_an_explicit_target,the_probe_teaches_the_index_and_a_later_write_is_routed_by_it_unprobedapp/ferrofed-server/tests/it/path_ehr_id/refusals.rs:a_member_answering_the_probe_with_an_error_leaves_the_owner_unknown,a_member_that_does_not_answer_the_probe_in_time_leaves_the_owner_unknown,a_read_no_member_holds_is_a_404_no_destination,a_write_no_earlier_step_routes_is_a_400_and_probes_nobody,two_members_holding_the_ehr_id_are_a_409_naming_both_and_neither_is_readapp/ferrofed-server/tests/it/versioned_write.rs:a_versioned_write_the_index_routes_reaches_its_controlling_cdr_unprobed,no_write_without_a_target_is_ever_probed_for
CP-33a
3 tests.
tools/ferrofed-testkit/tests/it/e2e/admission.rs(e2e):a_candidate_sharing_a_members_system_id_is_reported_and_never_admittedtools/ferrofed-testkit/tests/it/e2e/node_profile.rs(e2e):ferroehr_is_checked_against_the_identifier_integrity_conditionstools/ferrofed-testkit/tests/it/e2e/node_profile/ehrbase.rs(e2e):ehrbase_is_checked_against_the_identifier_integrity_conditions
CP-34
30 tests.
app/ferrofed-server/src/facade/route/fan_out.rs:a_failed_member_is_never_reported_as_overall_successapp/ferrofed-server/tests/it/definition.rs:a_definition_request_carries_only_what_its_operation_declares,a_definition_request_without_a_target_is_refused_and_no_node_is_asked,a_malformed_declared_value_is_refused_before_the_missing_target,a_nodes_own_error_passes_through_as_the_node_sent_it,a_star_an_unknown_endpoint_and_several_endpoints_are_refused,a_template_list_is_the_one_named_nodes_answer_and_never_a_union,a_template_upload_with_a_target_reaches_only_that_node_byte_identical,the_registry_keeps_its_stored_queries_and_templates_still_route_to_one_node,without_the_registry_a_stored_query_definition_is_the_named_nodes,without_the_registry_a_versioned_stored_query_put_in_another_media_type_is_refused,without_the_registry_a_versioned_stored_query_put_is_the_named_nodesapp/ferrofed-server/tests/it/e2e/track9_surface.rs(e2e):a_definition_request_routes_to_the_one_named_nodeapp/ferrofed-server/tests/it/its_rest_areas/definition.rs:the_definition_declaration_states_that_a_request_goes_to_the_one_named_node,the_definition_declaration_states_the_template_fan_out_only_where_offered,the_definition_declaration_states_where_stored_queries_are_heldapp/ferrofed-server/tests/it/options.rs:the_definition_area_declares_nothing_offered,the_template_fan_out_upload_follows_the_configurationapp/ferrofed-server/tests/it/stored/declaration.rs:without_the_registry_a_definition_is_routed_and_no_name_is_invokedapp/ferrofed-server/tests/it/template_fan_out.rs:a_definition_request_other_than_an_upload_still_routes_to_one_node,a_member_past_its_timeout_is_reported_and_the_others_keep_the_template,a_named_subset_reaches_only_its_members_and_reports_the_rest_excluded,a_plain_upload_naming_no_endpoint_never_fans_out,a_plain_upload_naming_one_endpoint_routes_to_that_node_alone,a_rejecting_nodes_message_is_carried_and_no_other_body_is,a_star_beside_an_organisation_naming_fewer_members_is_a_conflict,every_member_accepting_answers_success_with_every_member_named,every_member_rejecting_is_a_failed_dependency_naming_each,with_one_member_rejecting_the_answer_is_partial_and_names_it,with_the_setting_off_a_star_upload_is_refused_and_nothing_is_sent
CP-35
13 tests.
app/ferrofed-server/tests/it/e2e/attributes.rs(e2e):the_example_query_adds_each_nodes_attributes_over_two_cdr_nodesapp/ferrofed-server/tests/it/e2e/crossref.rs(e2e):one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/track1.rs(e2e):an_unmodified_client_gets_one_single_cdr_shaped_result_set,the_columns_are_the_same_whichever_node_answers_firstapp/ferrofed-server/tests/it/endpoint_attributes.rs:a_query_selecting_no_attribute_keeps_the_single_cdr_row_shape,an_alias_resolves_a_collision_and_both_columns_survive,an_alias_that_leaves_the_collision_standing_is_refused_400,the_example_query_answers_the_example_columns_and_rowsapp/ferrofed-server/tests/it/facade/query.rs:a_patient_query_is_one_result_set_over_both_nodescrates/openehr-federation/tests/it/aql/attribute.rs:a_query_selecting_no_attribute_keeps_the_single_cdr_shape,an_alias_resolves_the_collision_and_both_columns_survive,an_alias_that_leaves_the_collision_standing_is_refused,the_example_query_names_its_attributes_and_asks_no_node_for_them
CP-36
43 tests.
app/ferrofed-server/tests/it/consent.rs:a_directed_query_reaches_the_node_that_checks_consent_itself,a_member_the_prefilter_denies_is_reported_and_never_contacted,a_member_the_prefilter_leaves_in_is_asked_and_its_refusal_still_reported,a_node_the_localizer_names_still_refuses_and_the_query_succeeds,a_prefilter_that_cannot_answer_leaves_every_candidate_to_its_node,the_prefilter_runs_after_localization_over_its_candidates_only,with_no_consent_service_a_node_refusal_is_reported_and_the_query_succeedsapp/ferrofed-server/tests/it/consent_everywhere.rs:the_read_by_subject_never_contacts_a_member_the_prefilter_denies,the_read_by_subject_whose_holders_are_all_denied_is_consent_deniedapp/ferrofed-server/tests/it/consent_withheld/mod.rs:a_read_by_subject_another_member_holds_is_served_with_the_exclusion_withheld,a_read_by_subject_only_a_withheld_member_holds_answers_as_one_no_member_holds,a_withheld_exclusion_is_reported_as_a_member_without_the_patient,a_withheld_exclusion_reads_as_the_others_when_the_crossref_cannot_answer,a_withheld_exclusion_reads_exactly_as_a_member_that_does_not_know_the_patient,by_default_the_exclusion_is_disclosed_and_declared,the_configured_setting_withholds_the_exclusion_and_is_declared,with_disclosure_the_exclusion_stays_consent_deniedapp/ferrofed-server/tests/it/consent_withheld/node.rs:a_node_refusal_is_reported_as_a_member_without_the_patient,a_node_refusal_reads_exactly_as_a_member_that_does_not_know_the_patient,a_read_by_subject_the_holder_refuses_answers_as_one_no_member_holds,a_routed_read_the_node_refuses_answers_subject_unavailable,an_ask_all_probe_the_holder_refuses_answers_as_one_no_member_holds,with_disclosure_a_node_refusal_stays_consent_deniedapp/ferrofed-server/tests/it/consent_withheld/not_asked.rs:a_withheld_deployment_answers_a_prefilter_that_did_not_ask_as_one_that_found_nothingapp/ferrofed-server/tests/it/consent_withheld/routed.rs:a_read_of_an_ehr_id_the_prefilter_excluded_is_left_to_its_node,a_targeted_read_the_node_refuses_reads_exactly_as_one_the_node_cannot_find,an_ask_all_probe_a_member_refuses_reads_exactly_as_one_no_member_answersapp/ferrofed-server/tests/it/contact_point/withheld.rs:a_restricted_patient_and_an_unknown_one_answer_alike,an_excluded_member_and_one_without_the_patient_answer_alike,the_deployments_own_caller_still_sees_the_exclusion_disclosedapp/ferrofed-server/tests/it/e2e/pixm.rs(e2e):a_pix_resolved_query_asks_only_the_member_that_knows_the_patientapp/ferrofed-server/tests/it/e2e/restriction.rs(e2e):a_restriction_at_one_node_is_honoured_by_the_federated_queryapp/ferrofed-server/tests/it/e2e/track7.rs(e2e):a_directed_query_is_consent_checked_at_the_node,a_member_the_consent_service_admits_still_refuses_and_the_query_succeeds,a_member_the_consent_service_denies_is_reported_and_never_dispatched_to,with_no_consent_service_a_node_refusal_is_reported_and_the_query_succeedsapp/ferrofed-server/tests/it/mitz/decision.rs:a_denial_and_a_failure_together_deny_one_and_leave_the_other_to_its_node,a_member_mitz_denies_is_consent_denied_and_never_asked,a_member_mitz_permits_is_asked_and_its_node_still_decidesapp/ferrofed-server/tests/it/mitz/grant.rs:mitz_is_asked_with_the_token_the_grant_obtained_and_reusedapp/ferrofed-server/tests/it/pdqm/flow.rs:an_identifier_the_supplier_knows_no_patient_for_is_not_resolved_anywhereapp/ferrofed-server/tests/it/resolution.rs:a_member_the_patient_is_not_known_at_is_not_resolved_and_never_asked,a_patient_known_nowhere_is_200_with_no_rows_and_every_member_not_resolved
CP-37
29 tests.
app/ferrofed-server/tests/it/directive.rs:a_selected_endpoint_attribute_is_added_to_the_rows_and_asked_of_no_nodeapp/ferrofed-server/tests/it/e2e/attributes.rs(e2e):the_example_query_adds_each_nodes_attributes_over_two_cdr_nodesapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):a_directive_selects_the_node_set_and_leaves_the_row_shape_aloneapp/ferrofed-server/tests/it/endpoint_attributes.rs:a_query_selecting_no_attribute_keeps_the_single_cdr_row_shape,an_attribute_key_among_node_keys_keeps_its_place,every_attribute_reads_the_registry_entry_of_its_endpoint,ordering_on_an_attribute_alone_orders_the_endpoints_at_the_tier,the_attribute_elsewhere_than_select_and_order_by_is_still_refused_400,the_example_query_answers_the_example_columns_and_rows,under_distinct_a_shared_organisation_collapses_the_rows,under_distinct_an_endpoint_id_keeps_the_rows_of_two_endpoints_apartcrates/openehr-federation/tests/it/aql/attribute.rs:a_query_selecting_no_attribute_keeps_the_single_cdr_shape,every_attribute_the_table_lists_is_selectable,ordering_on_an_attribute_is_accepted_with_or_without_a_limit,the_example_query_names_its_attributes_and_asks_no_node_for_them,under_distinct_the_node_is_asked_only_its_own_columnscrates/openehr-federation/tests/it/aql/endpoint_order.rs:an_attribute_key_alone_is_the_tier_key_and_the_node_keeps_the_tie_break_and_limit,an_attribute_key_keeps_its_place_among_the_node_keys,an_attribute_ordered_on_and_not_selected_follows_the_selected_ones,an_unscoped_query_ordered_on_an_attribute_sends_no_directivecrates/openehr-federation/tests/it/attributes.rs:distinct_on_attributes_alone_keeps_one_row_per_value_and_refuses_no_cut_node,each_row_keeps_the_attributes_of_its_endpoint_in_the_tier_order,under_distinct_a_shared_attribute_value_collapses_the_rows,under_distinct_rows_of_two_endpoints_are_two_values,without_an_order_each_row_keeps_its_endpoint_attributescrates/openehr-federation/tests/it/endpoint_order.rs:an_attribute_key_after_a_node_key_breaks_its_ties,an_attribute_key_alone_orders_the_endpoints_and_the_uid_breaks_the_tie,an_attribute_key_before_a_node_key_orders_first_and_a_tie_falls_to_the_node_key,an_attribute_value_compares_as_a_string_cell_does
CP-40
62 tests.
app/ferrofed-server/tests/it/definition.rs:the_registry_keeps_its_stored_queries_and_templates_still_route_to_one_nodeapp/ferrofed-server/tests/it/e2e/store.rs(e2e):a_stored_query_database_of_the_earlier_release_boots_with_every_definition_and_refusal_keptapp/ferrofed-server/tests/it/e2e/stored_postgres.rs(e2e):a_row_naming_its_patient_by_a_literal_is_never_served_or_run,a_version_one_instance_stored_is_run_by_name_at_the_other,two_gateways_racing_one_new_version_store_exactly_one,two_gateways_racing_unversioned_puts_store_two_versions_or_refuse_oneapp/ferrofed-server/tests/it/e2e/track9_surface.rs(e2e):a_definition_fan_out_reports_a_one_node_rejection_as_partial,a_stored_query_is_held_at_the_gateway_and_runs_by_name_over_both_membersapp/ferrofed-server/tests/it/its_rest_areas/definition.rs:the_definition_declaration_states_the_distribution_only_where_offeredapp/ferrofed-server/tests/it/query_get.rs:a_stored_get_reads_a_q_key_as_a_query_parameter,a_stored_query_runs_by_get_with_and_without_a_versionapp/ferrofed-server/tests/it/stored/assigned.rs:a_distribution_sends_the_version_the_registry_assigned,a_literal_patient_is_refused_before_any_version_is_assigned,a_new_name_is_stored_at_one_zero_zero_and_named_in_location,an_unversioned_put_in_the_reserved_namespace_is_refused,concurrent_unversioned_puts_each_store_a_version_of_their_own,the_next_patch_follows_the_newest_version_by_number_and_none_is_replacedapp/ferrofed-server/tests/it/stored/declaration.rs:options_declares_the_registry_and_the_methods_it_servesapp/ferrofed-server/tests/it/stored/files.rs:a_definition_file_is_read_listed_and_run_by_name,a_held_version_put_naming_members_at_a_read_only_registry_is_a_405,a_put_at_a_read_only_registry_is_a_405_naming_the_read_methods,the_admin_distribution_at_a_read_only_registry_is_a_405_allowing_nothingapp/ferrofed-server/tests/it/stored/held.rs:a_held_definition_naming_its_patient_by_a_literal_refuses_the_start,a_literal_a_shared_store_gains_after_the_start_is_never_servedapp/ferrofed-server/tests/it/stored/invocation.rs:a_failing_member_fails_the_stored_query_and_the_envelope_still_names_it,a_stored_query_invoked_by_name_fans_out_and_names_the_gateways_definition,no_version_runs_the_latest_and_a_prefix_runs_the_highest_it_matches,query_parameters_bind_into_the_stored_query_as_if_submitted_inlineapp/ferrofed-server/tests/it/stored/reserved.rs:a_put_into_the_reserved_namespace_is_refused_and_the_held_text_stands,a_store_holding_a_definition_in_the_reserved_namespace_refuses_the_start,each_section_query_runs_by_name_and_no_patient_identifier_reaches_a_node,the_section_queries_are_listed_and_read_at_their_immutable_versionapp/ferrofed-server/tests/it/stored/storage.rs:a_definition_naming_the_patient_by_a_literal_is_refused_at_put,a_definition_the_rewrite_refuses_whatever_is_bound_is_refused_at_put,a_second_put_of_a_held_name_and_version_is_refused_and_the_text_stands,a_stored_definition_reads_back_as_an_its_rest_stored_query_and_lists,a_stored_version_survives_a_restart_and_stays_immutable,the_store_holds_no_identifier_after_a_definition_is_stored_and_invokedapp/ferrofed-server/tests/it/stored_fan_out.rs:a_targeted_definition_is_refused_for_distribution_and_stays_executable,an_invocation_runs_the_registry_aql_even_where_a_nodes_copy_differs,config_check_refuses_definition_fan_out_without_the_registry,options_declares_definition_fan_out_only_where_offered_beside_the_registry,the_drift_report_names_matching_differing_and_missing_members,with_every_member_failing_the_registry_still_holds_the_definition,with_one_member_failing_the_distribution_is_partial_and_the_registry_holds_it,with_the_setting_off_a_get_naming_members_is_refused_and_reads_nothing,with_the_setting_off_a_put_naming_members_is_refused_and_stores_nothingapp/ferrofed-server/tests/it/stored_location.rs:a_versioned_put_names_the_stored_query_relative_to_its_request,an_unversioned_put_names_the_stored_query_relative_to_its_request,with_a_public_url_both_puts_name_the_absolute_url_of_the_stored_query,with_a_public_url_the_admin_distribution_names_the_stored_query,without_a_public_url_the_admin_distribution_names_no_locationapp/ferrofed-server/tests/it/stored_redistribution.rs:a_redistribution_every_member_rejects_is_a_424_and_the_registry_is_unchanged,a_redistribution_one_member_rejects_is_partial_and_the_registry_is_unchanged,a_second_put_of_a_held_version_is_refused_in_every_form,the_admin_action_exists_only_on_the_admin_listener,the_admin_action_reaches_a_member_admitted_after_the_distribution,the_admin_action_refuses_a_held_targeted_definition,the_admin_action_refuses_what_it_cannot_distribute_and_sends_nothing,the_admin_action_sends_the_held_version_to_the_member_that_missed_it,the_admin_listener_is_refused_off_loopback_without_allow_remote,without_distribution_offered_the_admin_action_is_refused
Track 1
3 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/track1.rs(e2e):an_unmodified_client_gets_one_single_cdr_shaped_result_set,the_columns_are_the_same_whichever_node_answers_first
Track 2
7 tests.
app/ferrofed-server/tests/it/e2e/crossref.rs(e2e):both_patient_carriers_resolve_to_the_same_rows_over_two_cdr_nodes,one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wireapp/ferrofed-server/tests/it/e2e/pixm.rs(e2e):a_patient_fed_at_both_members_resolves_through_pix_at_both,a_pix_resolved_query_asks_only_the_member_that_knows_the_patientapp/ferrofed-server/tests/it/e2e/santempi.rs(e2e):a_patient_each_member_fed_to_santempi_is_answered_by_bothapp/ferrofed-server/tests/it/e2e/track2.rs(e2e):a_selected_subject_column_is_the_input_and_never_asked_of_a_node,the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_id
Track 3
6 tests.
app/ferrofed-server/tests/it/e2e/attributes.rs(e2e):the_example_query_adds_each_nodes_attributes_over_two_cdr_nodesapp/ferrofed-server/tests/it/e2e/track3.rs(e2e):a_directive_selects_the_node_set_and_leaves_the_row_shape_alone,a_named_member_where_the_patient_does_not_resolve_is_reported_not_errored,an_undirected_query_asks_only_the_members_the_localizer_names,the_endpoint_header_selects_what_the_directive_selectsapp/ferrofed-server/tests/it/e2e/xcpd.rs(e2e):an_undirected_query_asks_only_the_member_ferropix_names_over_iti_55
Track 4
7 tests.
app/ferrofed-server/tests/it/e2e/track3.rs(e2e):an_undirected_query_asks_only_the_members_the_localizer_namesapp/ferrofed-server/tests/it/e2e/track4.rs(e2e):a_client_wait_shortens_the_budget_and_never_extends_it,a_node_answering_an_error_fails_the_query_424_and_is_named_node_error,a_slow_node_is_abandoned_at_the_per_node_timeout_and_named_time_out,an_unreachable_node_fails_the_query_504_and_is_named_offline,found_nowhere_and_consent_denied_are_200_and_never_424,partial_returns_the_answering_nodes_rows_only_where_offered
Track 5
4 tests.
app/ferrofed-server/tests/it/e2e/aggregate.rs(e2e):an_undirected_avg_is_the_mean_weighted_by_the_counts_each_node_returnsapp/ferrofed-server/tests/it/e2e/track5.rs(e2e):an_undirected_aggregate_is_recombined_or_refused_and_a_directed_one_answered,duplicates_pass_through_by_default_and_distinct_folds_them_at_the_tier,order_by_and_limit_return_the_global_top_rows_deterministically
Track 6
4 tests.
app/ferrofed-server/tests/it/e2e/commit.rs(e2e):a_versioned_write_reaches_its_controlling_node_and_never_anotherapp/ferrofed-server/tests/it/e2e/track6.rs(e2e):a_follow_up_read_of_a_row_reaches_the_cdr_that_holds_it_with_its_uid_unchanged,a_new_ehr_is_created_at_the_one_node_the_client_names,an_unseen_ehr_id_is_probed_read_only_and_a_write_to_it_is_never_probed
Track 7
6 tests.
app/ferrofed-server/tests/it/e2e/track7.rs(e2e):a_caller_that_does_not_authenticate_reaches_no_node,a_directed_query_is_consent_checked_at_the_node,a_member_the_consent_service_admits_still_refuses_and_the_query_succeeds,a_member_the_consent_service_denies_is_reported_and_never_dispatched_to,each_node_is_reached_with_its_onward_token_and_told_the_caller,with_no_consent_service_a_node_refusal_is_reported_and_the_query_succeeds
Track 9
7 tests.
app/ferrofed-server/tests/it/e2e/commit.rs(e2e):a_composition_committed_through_the_gateway_lands_byte_identical_at_one_node,a_versioned_write_reaches_its_controlling_node_and_never_anotherapp/ferrofed-server/tests/it/e2e/track9.rs(e2e):an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefixapp/ferrofed-server/tests/it/e2e/track9_surface.rs(e2e):a_definition_fan_out_reports_a_one_node_rejection_as_partial,a_definition_request_routes_to_the_one_named_node,a_stored_query_is_held_at_the_gateway_and_runs_by_name_over_both_members,the_self_description_lists_the_members_and_demographic_answers_as_declared
Track 10
45 tests.
app/ferrofed-engine/tests/it/fapi2/mod.rs:a_callers_token_carrying_the_patient_is_never_sent,no_carrier_to_the_server_or_the_node_names_the_patientapp/ferrofed-engine/tests/it/native.rs:a_request_that_would_carry_a_withheld_identifier_is_never_sent,the_member_is_asked_by_its_ehr_id_alone_and_its_document_comes_back_unchangedapp/ferrofed-engine/tests/it/native_create.rs:an_update_leaves_once_by_the_members_id_with_its_body_byte_for_byte,the_body_leaves_byte_for_byte_once_and_nothing_else_carries_the_patientapp/ferrofed-server/tests/it/access/accessor.rs:the_professional_and_the_patient_reach_no_log_or_metricapp/ferrofed-server/tests/it/access/emergency.rs:the_mark_carries_no_patient_identifier_to_a_log_or_a_nodeapp/ferrofed-server/tests/it/access/query.rs:no_patient_template_or_caller_reaches_the_log_a_metric_or_a_nodeapp/ferrofed-server/tests/it/contact_point/relayed.rs:no_iua_person_id_reaches_a_nodeapp/ferrofed-server/tests/it/e2e/track10.rs(e2e):a_committed_dv_identifier_arrives_at_a_ferroehr_node_byte_identical,the_four_positions_reach_neither_ferroehr_node_on_any_pathapp/ferrofed-server/tests/it/fhir/binary.rs:a_read_asks_the_member_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/binary_trc.rs:a_document_read_under_another_patients_trc_is_no_data_recorded_and_fetched_nowhereapp/ferrofed-server/tests/it/fhir/header.rs:the_header_is_the_supplier_s_and_no_header_value_reaches_a_nodeapp/ferrofed-server/tests/it/fhir/list/mod.rs:a_native_member_is_asked_for_the_list_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/list/own_laboratory.rs:a_members_own_report_is_listed_unchanged_but_for_its_url_and_read_unchangedapp/ferrofed-server/tests/it/fhir/native.rs:a_native_member_is_asked_by_its_ehr_id_alone_and_left_out_of_the_mapped_documentapp/ferrofed-server/tests/it/fhir/patient_grant.rs:a_persons_own_grant_gets_their_summary_and_no_identifier_reaches_a_node_or_a_logapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs:a_dispensation_naming_its_prescription_by_the_faces_reference_alone_is_refused,a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchangedapp/ferrofed-server/tests/it/fhir/prescriptions/dispensed.rs:the_dispensation_a_receipt_located_is_read_at_its_member_by_its_ehr_idapp/ferrofed-server/tests/it/fhir/prescriptions/mod.rs:each_member_is_asked_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/prescriptions/read.rs:the_member_is_asked_by_its_ehr_id_and_its_own_id_aloneapp/ferrofed-server/tests/it/fhir/prescriptions/trc.rs:a_read_by_id_of_another_patients_prescription_is_no_data_recorded_and_sent_nowhereapp/ferrofed-server/tests/it/fhir/prescriptions/vread.rs:the_location_a_receipt_answered_reads_back_the_dispensation_it_locatedapp/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs:a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_idapp/ferrofed-server/tests/it/fhir/receive/inserted.rs:a_persons_own_document_is_marked_as_inserted_by_them_with_no_identifier_addedapp/ferrofed-server/tests/it/fhir/receive/representative.rs:a_representatives_document_names_them_as_every_entrys_providerapp/ferrofed-server/tests/it/fhir/receive/write.rs:a_document_is_written_to_its_member_by_its_ehr_id_aloneapp/ferrofed-server/tests/it/fhir/summary.rs:the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_nodeapp/ferrofed-server/tests/it/pdqm/flow.rs:neither_identifier_reaches_a_log_line_a_metric_or_a_nodeapp/ferrofed-server/tests/it/probed_ehr_id.rs:a_read_whose_ehr_id_is_a_uuid_is_still_resolved_by_the_probe,a_read_whose_ehr_id_is_no_uuid_and_that_nothing_routes_is_refused_and_probes_nobody,an_ehr_id_that_is_no_uuid_is_forwarded_to_the_named_node_alone_and_then_indexedapp/ferrofed-server/tests/it/stored/reserved.rs:each_section_query_runs_by_name_and_no_patient_identifier_reaches_a_nodeapp/ferrofed-server/tests/it/traces/traceparent.rs:an_identifier_hidden_in_the_clients_trace_id_reaches_no_node_and_no_spanapp/ferrofed-server/tests/it/track10/mock.rs:a_committed_dv_identifier_arrives_at_the_node_byte_identical,a_panicking_handler_holding_the_identifier_leaves_it_on_neither_stderr_nor_the_log,the_identifier_in_a_party_identified_predicate_reaches_no_node,the_identifier_in_a_projection_reaches_no_node,the_identifier_in_an_external_ref_predicate_reaches_no_node,the_identifier_in_the_ehr_id_slot_is_never_probed_at_a_node,the_identifier_in_the_query_string_or_a_header_reaches_no_node,the_identifier_on_the_single_node_route_reaches_no_node
Track 11
5 tests.
app/ferrofed-server/tests/it/e2e/track11.rs(e2e):a_read_and_a_query_of_the_duplicate_are_refused_and_only_the_probe_reaches_a_node,a_versioned_write_no_earlier_step_routes_is_refused_and_no_node_is_asked,once_the_index_holds_both_claimants_no_read_or_write_reaches_either_nodeapp/ferrofed-server/tests/it/fhir/prescriptions/read.rs:an_ehr_id_two_members_claim_refuses_the_read_with_one_incidenttools/ferrofed-testkit/tests/it/e2e/admission.rs(e2e):a_candidate_sharing_a_members_system_id_is_reported_and_never_admitted
Conformance matrix
Every conformance point of the Federation Tier with AQL specification (section 17), the test tracks of section 16.3 and every requirement, with the status FerroFED holds for each. The actor, requirement and track columns are derived from the vendored specification; the status, issue and reason columns are kept by FerroFED. A point is covered only when a test carries its marker and CI runs it.
Gateway points: 35 of 35 covered, 0 planned, 0 deferred.
The other 6 points belong to a member node or to the federation operator, and a gateway is never marked down for them (section 17).
Conformance points
| Point | Actor | Requirements | Tracks | Status | Issues | Reason |
|---|---|---|---|---|---|---|
| CP-1 | Gateway | N1 | 1 | covered | #38, #287 | scored over two mock nodes: POST {base}/v1/query/aql and its ITS-REST GET form, and the GET forms of stored query execution with and without a version, send each node the same request and answer the same RESULT_SET; the query string is decoded by the generated openehr-its parameters, and one it refuses is a 400 that asks nobody |
| CP-2 | Gateway | N2, N5 | 1, 2 | covered | #35, #38 | - |
| CP-38 | Gateway | N33 | 2, 10 | covered | #44 | - |
| CP-3 | Gateway | N3 | 2 | covered | #42, #43 | - |
| CP-4 | Gateway | N7 | 2 | covered | #35, #38 | - |
| CP-5 | Gateway | N4, N10 | 3, 4 | covered | #46, #85 | scored over three mock nodes with a localizer stub and the test kit’s fixed cross-reference: a member the localizer does not name is not-localized and never asked, a localizer that is down or silent past its budget fails closed with its error on every member and in meta.federation, and ask-all on failure holds only where declared and is declared in OPTIONS |
| CP-6 | Gateway | N11 | 3 | covered | #70 | - |
| CP-7 | Gateway | N5 | 2 | covered | #35, #38 | - |
| CP-8 | Gateway | N13 | 5 | covered | #52, #55 | - |
| CP-9 | Gateway | N15 | 5 | covered | #56 | - |
| CP-10 | Gateway | N14 | 5 | covered | #54, #187 | - |
| CP-11 | Gateway | N16 | 4 | covered | #49, #85 | - |
| CP-12 | Gateway | N6, N16 | 4 | covered | #43, #50, #58 | the status codes are scored (section 11.2, section 11.3); the OperationOutcome clause has no FHIR-facing consumer to apply to on an ITS-REST gateway, whose answer is a RESULT_SET with its additions under meta.federation only (N17, section 9.1, section 11.4) |
| CP-13 | Gateway | N19, N20, N21 | 6 | covered | #36, #64, #67, #74 | - |
| CP-14 | Gateway | N22 | 6 | covered | #64, #1478 | scored over two mock nodes on the 1.0.1 order of section 12.5.1: a version read whose path ehr_id the explicit target or the index places at the holder of an imported copy is answered by the holder, with openEHR-federation-system-id naming it, and the creator is never sent that ehr_id; with no target, binding or index the read reaches the member whose system_id is the uid creating_system_id (step 4) and nothing is probed; a creating_system_id the registry does not route falls to the ask-all probe; no uid is rewritten |
| CP-15 | Gateway | N23 | 6 | covered | #65, #286, #289 | scored over two mock nodes: a versioned write naming its preceding version in If-Match, or in the path of a composition DELETE, reaches only the node its path ehr_id routes to, and only when the registry routes that version’s creating_system_id there as the member’s own system_id or a registered mapping; otherwise it is refused 409 and no node is sent it, a holder the map learned included; a versioned write naming no single version is refused 400; a new EHR with no target, or naming two endpoints, is refused 400 and never probed; a CONTRIBUTION is held to the same rule for every preceding_version_uid its body names, read with the ITS-REST NewContribution type and forwarded byte-identical, so one version another member controls refuses it 409 and no node is sent it, a CONTRIBUTION of creations alone routes by its path ehr_id, and a body that is no canonical-JSON CONTRIBUTION is refused 400; a PUT of an ehr_id a resolution binding or the ehr_id index places at another member than the targeted one is refused 409 ehr-id-held and no node is sent it, one at the member that holds it is forwarded for that node’s own 409, and an ehr_id nothing places is forwarded and then indexed at the targeted member |
| CP-16 | Gateway | N24 | 7 | covered | #82 | scored over mock nodes through the real configuration path: a federated query, a routed read and write, a definition request, the ask-all probe, a stored-query definition and the admission check’s EHR calls each reach their node with one openEHR-federation-client token, an ES384 JWS signed with the key the gateway publishes as federation.auth.jwks_uri, its aud the node’s endpoint_id, exp at most 60 s after iat, naming the caller the guard verified (sub, iss_upstream, verified_by, the organisation, the purpose of use and the scopes as granted) and never the caller’s own token; a caller claim carrying the patient identifier stops the request unsent, and a request with no verified caller reaches no node |
| CP-17 | Gateway | N25 | 7 | covered | #80, #81, #439 | the onward half is scored against the harness token endpoint and mock nodes: the gateway obtains a token with the client-credentials grant and an ES384 RFC 7523 client assertion the endpoint verifies against the JWK Set the gateway publishes and declares as federation.auth.jwks_uri, the node receives that token and never the caller token, a rotation publishes both keys for its overlap window, a token is cached until 30 s before it expires and dropped on a 401, and a token that cannot be obtained fails that node as node-error with nothing sent; the inbound half is scored over mock nodes: every caller presents an RFC 9068 token verified by key set or introspection, each failure (signature, issuer, audience, expiry, clock skew, algorithm, type, key, scope, purpose of use, an unreachable issuer) is refused before any node is asked, and the caller token never reaches a node; under token exchange the node receives a token exchanged for the verified caller, the gateway an RFC 7523 actor, the scope the caller scopes that cover the operation and the node the resource, an edge-asserted caller fails that node with nothing sent, and a DPoP-bound grant proves every request to its node and token endpoint and answers a demanded nonce once |
| CP-18 | Node | N26 | 7 | node-profile | #83, #93, #549, #573 | a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the gateway’s node profile checks assist it, which the harness runs against its products and conformance run –node-profile against each member of a deployment: they ask FerroEHR and EHRbase for an EHR they do not hold and for an unparsable query and read the ITS-REST error status, and read whether an EHR the node withholds from one principal is refused on GET /ehr/{ehr_id} and on both ehr_id-scoped query forms while it is served to another: FerroEHR started with Basic authentication and authz.rbac.ehr_access_default restricted withholds every EHR from a clinician, and EHRbase started with Basic authentication withholds the request path of one EHR from its user role through security.additionalAuthorizations, its narrowest access control; FerroEHR from 4.3.3 refuses that EHR on the read and on both query forms (FerroEHR #3562, #566), and EHRbase refuses the read and releases the EHR’s rows on both query forms; the verdicts are the report node class per product, never a gateway pass, and no specification governs the choice of products: our own design |
| CP-19 | Node | N27, N27a | 7 | node-profile | #83, #93, #549, #573, #1389 | a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the node profile consent check reads a consent refusal an operator arranges at the node, on the read and on both ehr_id-scoped query forms; at FerroEHR the harness arranges a restriction of processing (GDPR Art. 18) through the node’s ADMIN API, outside ITS-REST, and the restriction check reads the restricted composition withheld on the read and the composition query, and the federated query through the gateway leaves it out (#1389); EHRbase records no consent decision, and ITS-REST defines no consent resource, so the point reads node-not-observable there; the gateway half is CP-36, and no specification governs the choice of products: our own design |
| CP-20 | Operator | N19 | 6 | operator | #74 | verified at admission or in the registry, not on a request (section 16.2); the gateway assists: its registry load refuses a Query API endpoint whose connectionType is hl7-fhir-rest or no defined code |
| CP-21 | Gateway | N28 | 9 | covered | #69 | - |
| CP-22 | Gateway | N29 | 9 | covered | #69 | - |
| CP-23 | Gateway | N30 | 9 | covered | #73, #290, #80 | the body, the endpoint list without a patient identifier, the sub-path Allow, and each its_rest area declaration held to the behaviour of that area in every mode are scored, and so is the clause that the endpoint list is subject to the gateway normal authentication (section 7a.2, section 13): OPTIONS {base}/ answers 401 without a token |
| CP-24 | Gateway | N31 | 9 | covered | #61, #319, #288 | scored over mock nodes on the routed path and on the federated AQL answer: a request routed or dispatched to one endpoint, by a directive, a header or a patient resolving at one member alone, names it and its system_id whatever it answered; a fan-out lists the endpoints that contributed rows and their system_ids in registry order, comma-separated as the section 8.4 request header, and a failing answer or a node with no rows is not listed |
| CP-25 | Gateway | N32 | 9 | covered | #68, #311 | scored over two mock nodes in both modes: by default a read and a create answer 501 and no node is asked; with federation.demographic_endpoint set a request naming that endpoint, or naming none, reaches it alone, byte-identical and with the N31 provenance headers, while one naming another endpoint, several endpoints or * is refused 400, and no node is asked; an endpoint the registry does not hold refuses the configuration; OPTIONS its_rest.demographic declares each mode and the behaviour matches it |
| CP-26 | Gateway | N33 | 10 | covered | #45, #90, #217 | - |
| CP-27 | Node | N34 | 10 | node-profile | #93, #549, #573 | a Node obligation, scored against the harness CDR products and never the gateway (section 16.2); the gateway’s node profile checks assist it, which the harness runs against its products and conformance run –node-profile against each member of a deployment, against FerroEHR and against EHRbase: whether the node serves an EHR, its EHR_STATUS and its compositions by the ehr_id alone, with no patient identifier in any request the capturing proxy journals, whether an EHR created with no EHR_STATUS is read and queried by its ehr_id, and the ehr_id exchange the admission check reports over a PIXm Manager stub; the verdicts are the report node class per product, never a gateway pass, and no specification governs the choice of products: our own design |
| CP-28 | Gateway | N35 | 3 | covered | #70, #71, #77 | the stored-query clause is scored on a query stored at the gateway and invoked by name, targeted by the header and by the directive with the same answer, and refused 400 when the two select different node sets (section 8.4, section 12.7) |
| CP-29 | Gateway | N36 | 5, 6 | covered | #56, #66, #1478 | scored over two mock nodes with node A behind the capturing proxy: a version node A created and node B holds as an import is one row under version-identity, read from node A with node B suppressed in meta.federation.dedup; a write against it through node B’s ehr_id, routed by the ehr_id index or the targeting header, is refused 409 naming the creating_system_id and node A’s endpoint whether node A refuses connections or answers, node B is sent nothing and node A is never tried, so the write is never redirected to the owner under the holder ehr_id; a write through node A’s own ehr_id reaches node A alone and its answer names node A and no copy |
| CP-30 | Gateway | N37 | 4 | covered | #37, #50, #343 | - |
| CP-31 | Gateway | N38, N40 | 4 | covered | #49, #51 | - |
| CP-32 | Gateway | N39, N14, N9 | 5 | covered | #52, #53, #54, #55, #157, #187 | - |
| CP-33 | Gateway | N41, N42 | 6, 11 | covered | #62, #63, #91, #289, #1478 | the routing clause is scored in the 1.0.1 order (explicit target, binding, index, the member a path OBJECT_VERSION_ID creating_system_id names, then a read-only ask-all, and a write no earlier step locates refused 400 and never probed), and so is the integrity clause over two mock nodes: an ehr_id a binding, the index or the probe finds at two members is a 409 listing them on a read as on a write, neither is sent the request, one integrity incident is logged per refusal, and the index-insert alarm of section 12b.2 fires once; a binding collision is scored on the routing order alone until client sessions exist (#80), and track 11 repeats the integrity clause over the two FerroEHR nodes of the harness (#91); two creates racing for one ehr_id at two members raise the index-insert alarm once |
| CP-33a | Operator | N42a | 11 | operator | #79, #91, #93, #549 | verified at admission or in the registry, not on a request (section 16.2); the gateway assists: ferrofed admission check reports each identifier-integrity condition of section 12b.2 as pass, fail or cannot-check with its evidence, and the registry load refuses a second member with an existing system_id; the harness applies the check as the operator of track 11 to a FerroEHR candidate sharing a member’s system_id (#91); the node profile runs the same check against a FerroEHR member and an EHRbase member, each under a system_id of its own, and records each condition with its evidence (#93, #549) |
| CP-34 | Gateway | N43 | 9 | covered | #75, #76, #298, #343 | scored over two mock nodes: every template and stored-query definition request reaches the one endpoint the targeting headers name, byte-identical, and is answered as that node answered, its 4xx included; without a target, with *, an unknown id or several ids it is refused 400 and no node is asked; a template list is one node’s answer, never a union; with the registry offered, stored queries stay at the gateway while templates route; without the registry both stored-query PUTs route, the versioned one included, and every forwarded body carries a Content-Type its operation declares (#298); the fan-out upload clauses are scored over three mock nodes with federation.fan_out_template_upload on (off by default, declared in OPTIONS): a template upload naming * or several endpoints reaches each member byte-identical and is answered per node in the meta.federation shape, 207 with complete false when one member rejects or times out, the accepting members keep the template and are never sent a rollback, 200 only when every named member accepted, 424 when none did, a failing member reported with its HTTP status and an excerpt of its message, no other node body copied; a plain upload still names its one node, and * stays a 400 on every other definition request and whenever the setting is off (#76) |
| CP-35 | Gateway | N17, N18 | 1 | covered | #33, #38, #72 | - |
| CP-36 | Gateway | N8 | 2, 7 | covered | #43, #83 | - |
| CP-37 | Gateway | N12 | 3 | covered | #72 | - |
| CP-39 | Operator | N25 | 7 | operator | #84 | verified at admission or in the registry, not on a request (section 16.2); FerroFED’s own answers to the five section 13.4 obligations, each naming the configuration that changes it, and the operator’s template are the book page website/book/src/operate/deployment-decisions.md, held to one section per obligation by app/ferrofed-server/tests/it/deployment_decisions.rs |
| CP-40 | Gateway | N44 | 9 | covered | #77, #78, #268, #342, #343 | the registry clauses are scored: invocation by name over more than one member with name and endpoints[], the refused second PUT, also between two gateways racing one new version over one shared PostgreSQL store with exactly one stored (e2e, #268), a read-only registry answering PUT 405, and a directed definition stored and executed federated; the definition fan-out clauses are scored over three mock nodes with federation.fan_out_stored_queries on (off by default, refused by config check without the registry, declared in OPTIONS only beside it; with it off, a registry PUT or version GET carrying a targeting header is a 400 storing and reading nothing): a PUT naming * is stored at the registry first and reaches each member as the registry’s canonical AQL, 207 with complete false when one member fails and 424 when all do, nothing rolled back and the registry’s version standing, a failing member reported with its HTTP status and an excerpt of its message, no other node body copied; a FROM ENDPOINT or ORGANISATION definition is a 400 before anything is stored and stays storable and executable federated; an invocation dispatches the registry’s AQL inline even where a member holds a differing copy; a GET of a version naming * reports per member a matching copy as active and a differing or missing one as node-error with a code (#78); every second PUT of a held version is 409 and reaches no node, the same query naming members included, and drift is repaired by the operator on the admin listener (present only with metrics.listen, loopback unless allow_remote), whose POST sends the held copy to the named members on the same statuses with meta.registry held and leaves the registry’s copy unchanged, reaching a member that missed the first distribution or was admitted after it (#342) |
Test tracks
| Track | Title | Requirements | Points | Status | Issues | Reason |
|---|---|---|---|---|---|---|
| 1 | Transparency | N1, N2, N17, N18 | CP-1, CP-2, CP-35 | covered | #38, #72, #92 | scenario over the two FerroEHR nodes of the harness: a plain patient AQL by POST and by GET answers one RESULT_SET validated against the vendored schema, with the client columns only, every row an ordered array, the additions nested under meta.federation, and columns[] the same whichever node answers first |
| 2 | subject → ehrId resolution | N3, N5, N7, N33 | CP-2, CP-38, CP-3, CP-4, CP-7, CP-36 | covered | #42, #44, #45, #92 | scenario over the two FerroEHR nodes with FerroPIX as resolver: both carriers resolve to the same rows, each node is asked by its own ehr_id and its AQL carries no subject, a selected subject column is the re-injected input, and a member where the patient does not resolve is never asked |
| 3 | Directed / endpoint pin | N10, N11 | CP-5, CP-6, CP-28, CP-37 | covered | #70, #71, #72, #92 | scenario over the two FerroEHR nodes: ENDPOINT and ORGANISATION directives select the node set and report the rest excluded, a named member where the patient does not resolve is not-resolved, the endpoint header selects what the directive selects and a conflict is a 400, ?endpoint= targets nothing, and the PIXm localizer names the members asked and fails closed when it cannot answer |
| 4 | Partial results | N6, N16 | CP-5, CP-11, CP-12, CP-30, CP-31 | covered | #49, #50, #51, #92 | scenario over the two FerroEHR nodes with faults at the capturing proxy: an error is 424 node-error, an unreachable node 504 offline, a slow node 504 time-out at the per-node timeout with latency_ms reported, a client wait shortens and never extends the budget, partial answers 200 only where offered, and found-nowhere and consent-denied are 200 |
| 5 | Dedup + DISTINCT | N13, N15 | CP-8, CP-9, CP-10, CP-29, CP-32 | covered | #52, #53, #54, #55, #56, #92, #187 | scenario over the two FerroEHR nodes holding the same composition: duplicates pass through by default, DISTINCT, ORDER BY and LIMIT apply over the union with OFFSET never pushed down, and an undirected COUNT is recombined or refused 400 while a directed one is answered; the opt-in object_id dedup of an imported copy is scored over mock nodes |
| 6 | Follow-up read/write routing | N21, N22, N23, N36, N41 | CP-13, CP-14, CP-15, CP-20, CP-29, CP-33 | covered | #64, #65, #66, #67, #92 | scenario over the two FerroEHR nodes: a follow-up read of a row reaches the CDR that holds it with its uid unchanged, an unseen ehr_id is probed read-only while a write to it is refused 400 and never probed, a new EHR is created at the one node named, and a versioned write reaches only its controlling node; CP-14 is deferred on the contradiction recorded on #212 (#354), CP-20 is an operator point, and CP-29 is scored over mock nodes |
| 7 | Auth conveyance + consent-deny | N24, N25, N26, N27, N27a | CP-16, CP-17, CP-18, CP-19, CP-36, CP-39 | covered | #80, #81, #82, #83, #92 | scenario over the two FerroEHR nodes: the caller authenticates, each node receives the token its token endpoint issued for a client assertion verified against the published JWK Set and a signed token naming the caller, on the query and the follow-up, and consent is exercised with no consent service, with the development pre-filter denying and admitting, and on a directed query, with the refusal answered at node B proxy; CP-18 and CP-19 are node points and CP-39 an operator point |
| 8 | PMIR merge/split (ITI-93/94) (provisional) | N3 | - | deferred | #48, #147 | provisional in section 16.3, and section 18 lets its subject matter be deferred; the binding lifetime, the invalidation hook and the PMIR ITI-94 subscription that drives it from ITI-93 merges are built (#48, #147), and propagation to a later resolution is not claimed |
| 9 | REST surface & self-description | N28, N43, N44 | CP-21, CP-22, CP-23, CP-24, CP-25, CP-34, CP-40 | covered | #61, #68, #73, #75, #77, #92 | scenario over the two FerroEHR nodes: a plain client given only a prefixed base reads and writes, both addressing forms answer the same rows, OPTIONS lists the members, DEMOGRAPHIC answers 501 as declared, a template list routes to the one named node, and a stored query is held at the gateway, runs by name over both members, refuses a second PUT, and a definition fan-out reports a one-node failure as 207 |
| 10 | Identifier leakage (adversarial) | N33, N34, N5 | CP-38, CP-26, CP-27 | covered | #45, #61, #90 | the gateway half is scored: the four positions on the fan-out, a directed query and the single-node route, and the converse write, judged on node-side capture over two mock nodes and over the two FerroEHR nodes of the harness; CP-27, the N34 obligation of the node itself, is scored by the node profile (#93) |
| 11 | Integrity: ehr_id collision & admission conditions | N41, N42, N42a | CP-33, CP-33a | covered | #63, #79, #91 | the gateway half is scored over the two FerroEHR nodes of the harness: one ehr_id seeded at both for two patients is a 409 listing both claimants with one integrity incident, on a read and a scoped query the ask-all probe finds, and on a read and two writes the ehr_id index finds after its insert alarm, with no row served and no write applied; a versioned write no earlier step routes is a 400 and probes nobody; the admission half (CP-33a) is applied by the harness as the federation operator: a FerroEHR candidate sharing a member’s system_id is refused by the registry load as a duplicate, and failed by the admission check under a system_id of its own |
Requirements
46 requirements: 46 reached by a conformance point, 0 by a test track only, 0 by neither.
| Requirement | Points | Tracks | Reachability |
|---|---|---|---|
| N1 | CP-1 | 1 | direct |
| N2 | CP-2 | 1 | direct |
| N3 | CP-3 | 2, 8 | direct |
| N4 | CP-5 | - | direct |
| N5 | CP-2, CP-7 | 2, 10 | direct |
| N6 | CP-12 | 4 | direct |
| N7 | CP-4 | 2 | direct |
| N8 | CP-36 | - | direct |
| N9 | CP-32 | - | direct |
| N10 | CP-5 | 3 | direct |
| N11 | CP-6 | 3 | direct |
| N12 | CP-37 | - | direct |
| N13 | CP-8 | 5 | direct |
| N14 | CP-10, CP-32 | - | direct |
| N15 | CP-9 | 5 | direct |
| N16 | CP-11, CP-12 | 4 | direct |
| N17 | CP-35 | 1 | direct |
| N18 | CP-35 | 1 | direct |
| N19 | CP-13, CP-20 | - | direct |
| N20 | CP-13 | - | direct |
| N21 | CP-13 | 6 | direct |
| N22 | CP-14 | 6 | direct |
| N23 | CP-15 | 6 | direct |
| N24 | CP-16 | 7 | direct |
| N25 | CP-17, CP-39 | 7 | direct |
| N26 | CP-18 | 7 | direct |
| N27 | CP-19 | 7 | direct |
| N27a | CP-19 | 7 | direct |
| N28 | CP-21 | 9 | direct |
| N29 | CP-22 | - | direct |
| N30 | CP-23 | - | direct |
| N31 | CP-24 | - | direct |
| N32 | CP-25 | - | direct |
| N33 | CP-38, CP-26 | 2, 10 | direct |
| N34 | CP-27 | 10 | direct |
| N35 | CP-28 | - | direct |
| N36 | CP-29 | 6 | direct |
| N37 | CP-30 | - | direct |
| N38 | CP-31 | - | direct |
| N39 | CP-32 | - | direct |
| N40 | CP-31 | - | direct |
| N41 | CP-33 | 6, 11 | direct |
| N42 | CP-33 | 11 | direct |
| N42a | CP-33a | 11 | direct |
| N43 | CP-34 | 9 | direct |
| N44 | CP-40 | 9 | direct |
Obligations checklist
Every normative statement of the Federation Tier with AQL specification,
on every page and in both JSON schemas, with the status FerroFED holds for
it. The conformance matrix scores the section 17 points;
this checklist also holds the obligations in the section prose that are no
point of their own. Each row of
conformance/obligations.tsv
quotes the statement and names the test, the code, the issue or the
report behind its status.
Statements: 445, of which 374 fall on the gateway.
Counts per status
| Status | Statements | Gateway statements | Meaning |
|---|---|---|---|
| tested | 353 | 348 | a test asserts it |
| built-untested | 0 | 0 | the code does it and no test asserts it yet |
| planned | 0 | 0 | not built yet; an open issue holds the work |
| missing | 0 | 0 | not built, found missing by the audit; an issue holds the work |
| deferred | 5 | 4 | not built, by a decision of the owner |
| new-gap | 7 | 7 | the text contradicts itself or is silent, found by the audit and reported on #212 |
| contradiction | 5 | 4 | the text contradicts itself, reported on #212 before the audit |
| node | 15 | 0 | a member node must meet it |
| operator | 24 | 0 | the federation operator must meet it |
| n/a | 36 | 11 | no gateway obligation: a client or an editor must meet it, or it is a permission the gateway does not take |
Gaps
Every statement FerroFED does not yet meet as the text reads, and every statement where the text disagrees with itself, by status.
Missing
None.
Built, not yet tested
None.
Contradictions and silences found by the audit
| Statement | Actor | Keyword | Requirement | Point | Statement text | Evidence |
|---|---|---|---|---|---|---|
| requirements#n23.3 | Gateway | MUST | N23 | CP-15 | New-object creation MUST target an explicitly chosen node, via FROM ENDPOINT or the openEHR-federation-endpoint header | #212 comment 5966718241 item 1; #65; N23 names only FROM ENDPOINT or the header, while §12.4 admits the request path and §12.5.1 lets a write route by binding or index (app/ferrofed-server/tests/it/path_ehr_id/probe.rs::the_probe_teaches_the_index_and_a_later_write_is_routed_by_it_unprobed) |
| rest-facade#7a.1.3 | Gateway | MUST NOT | N43 | CP-34 | It MUST NOT silently pick a node, and MUST NOT present a merged view of templates across nodes | #212 comment 5966718241 item 8; #75; §7a.1 lets a gateway not expose templates while N43 and CP-34 require single-node routing of every definition request; whether 501 satisfies CP-34 is unstated |
| follow-up-routing#template-fanout.2 | Gateway | RECOMMENDED | N43, N35 | CP-34 | a distinct request (RECOMMENDED: openEHR-federation-endpoint: *, or an explicit multi-endpoint list) | #212 comment 5966718241 item 7; #76; * is accepted on a template upload alone and only where fan-out is offered, every other request keeps the 400 of section 8.4.1 (app/ferrofed-server/tests/it/template_fan_out.rs::with_the_setting_off_a_star_upload_is_refused_and_nothing_is_sent; ::a_definition_request_other_than_an_upload_still_routes_to_one_node; ::a_plain_upload_naming_no_endpoint_never_fans_out) |
| federated-result-set.schema#/$defs/federationMeta/properties/endpoints | Gateway | SHOULD | N16 | CP-11 | Every in-scope node appears …, as do nodes reported ‘excluded’ or ‘not-localized’ | #212 comment 5966718241 item 13; the description says out-of-scope members appear unconditionally (“as do nodes reported excluded or not-localized”) while §11.1 and N16 make it a SHOULD; FerroFED reports them (app/ferrofed-server/tests/it/endpoint_report.rs::a_member_a_directed_query_did_not_name_is_reported_excluded_and_out_of_scope) |
| federated-result-set.schema#/$defs/federationMeta/properties/timeout | Gateway | SHOULD | N38 | CP-31 | The effective timeout budget in force for this request. SHOULD be present | #212 comment 5966718241 item 10; schema and §9.5 say SHOULD, §11.5 [[client-deadline]] says the effective budget MUST be reported; FerroFED always reports it (app/ferrofed-server/tests/it/timeouts.rs::without_prefer_the_configured_budget_is_reported) |
| federated-result-set.schema#/$defs/federationMeta/properties/dedup | Gateway | MUST | N15 | CP-9 | ‘none’ is the default; a gateway that suppressed rows records the mode it used | #212 comment 5966718241 item 11; the schema neither requires dedup nor mode, while §10.2 says the mode applied MUST be recorded; FerroFED always records it (app/ferrofed-server/tests/it/dedup.rs::without_the_header_both_copies_come_back_and_none_is_recorded) |
| federated-result-set.schema#/$defs/federationMeta/properties/dedup/properties/suppressed_endpoints | Gateway | SHOULD | N36 | CP-29 | SHOULD, when rows were suppressed (§10.3, N36). The endpoint_ids whose copies were dropped | #212 comment 5966718241 item 12; SHOULD here, MUST in §10.3 [[suppressed-visible]] and N36; FerroFED records it (crates/openehr-federation/tests/it/dedup/mod.rs::the_record_carries_the_mode_and_under_version_identity_what_was_suppressed) |
Contradictions already reported
| Statement | Actor | Keyword | Requirement | Point | Statement text | Evidence |
|---|---|---|---|---|---|---|
| requirements#n19.3 | Operator | MUST | N19 | CP-20 | implementers MUST register a real code such as openehr-rest-query, or bind a specified system value | no code system exists to register the code in: #212 comments 5962774224 and T166; FerroFED binds its own system (app/ferrofed-identity/tests/it/mcsd/refusal.rs::a_code_in_an_unbound_system_is_refused) |
| requirements#n27a.2 | Gateway | MUST | N27a | CP-36 | MUST then report each excluded node as consent-denied (the non-disclosure variant a deployment under Regulation (EU) 2025/327 Art 8 declares) | Regulation (EU) 2025/327 Art 8 forbids showing the restriction: #212 comment 5980008967 (T182); with [federation.consent] disclose = false (#493) a pre-filtered member and a node’s listed consent refusal are not-resolved, and an unservable read is 404 subject-unavailable: app/ferrofed-server/tests/it/consent_withheld/mod.rs::a_withheld_exclusion_is_reported_as_a_member_without_the_patient; ::a_withheld_exclusion_reads_exactly_as_a_member_that_does_not_know_the_patient; ::a_read_by_subject_only_a_withheld_member_holds_answers_as_one_no_member_holds; app/ferrofed-server/tests/it/consent_withheld/node.rs::a_node_refusal_is_reported_as_a_member_without_the_patient; ::a_routed_read_the_node_refuses_answers_subject_unavailable; ::an_ask_all_probe_the_holder_refuses_answers_as_one_no_member_holds; app/ferrofed-server/tests/it/consent_withheld/routed.rs::a_targeted_read_the_node_refuses_reads_exactly_as_one_the_node_cannot_find; ::an_ask_all_probe_a_member_refuses_reads_exactly_as_one_no_member_answers; the default stays consent-denied (app/ferrofed-server/tests/it/consent_withheld/node.rs::with_disclosure_a_node_refusal_stays_consent_denied) |
| requirements#n38.1 | Gateway | MUST | N38 | CP-31 | A gateway MUST apply both a per-node timeout and an overall query budget, abandoning and marking time-out instead of failing the query | both budgets are tested (app/ferrofed-engine/tests/it/fanout/decision.rs::a_budget_applies_both_timeouts); “instead of failing the query” contradicts N37, FerroFED follows N37: T152 on #212 |
| deduplication#10.2.1 | Gateway | SHOULD | N15 | CP-9 | The Tier SHOULD offer an opt-in dedup mode keyed on the openEHR object_id | offered as version-identity keyed on the full OBJECT_VERSION_ID, not object_id (owner decision on #16; T168 on #212): app/ferrofed-server/tests/it/dedup.rs::under_version_identity_one_row_comes_back_and_the_copy_is_named |
| options-root.schema#/properties/federation/properties/auth | Gateway | MAY | N25, N30 | CP-23 | OPTIONAL as a member: a gateway with none configured omits the key rather than inventing a value | schema makes auth OPTIONAL while §13.1 [[jwks-discovery]] says MUST: #212 comment 5965275720 item 3; FerroFED declares jwks_uri since #81 (app/ferrofed-server/tests/it/onward.rs::options_declares_the_jwks_uri_and_validates_against_the_schema), and the member stays optional in the type (crates/openehr-federation/tests/it/options.rs::auth_is_optional_and_a_jwks_uri_must_be_a_uri) |
Planned
None.
Deferred
| Statement | Actor | Keyword | Requirement | Point | Statement text | Evidence |
|---|---|---|---|---|---|---|
| partial-results#11.6.4.1 | Gateway | MAY | N39 | CP-32 | A gateway MAY hold an ordered, merged result set for a bounded period and serve OFFSET-based pages from it | owner decision on #16 (2026-10-01): no cursor; pinned by app/ferrofed-server/tests/it/order.rs::a_bounded_offset_page_is_computed_afresh_and_carries_no_cursor (#60 closed) |
| partial-results#11.7.1 | Gateway | MAY | - | - | the Tier MAY support the Exchange-Routing async pattern | owner decision on #16: Prefer: respond-async is ignored and answered synchronously: app/ferrofed-server/tests/it/timeouts.rs::respond_async_is_ignored_and_answered_synchronously; app/ferrofed-server/tests/it/timeouts.rs::respond_async_never_exempts_a_request_from_the_overall_budget (#59 closed) |
| testing#track-8.1 | Gateway | MAY | N3 | - | Hooks reserved; full propagation MAY be deferred | owner decision on #16; conformance/tracks.tsv track 8 deferred; hooks built (#48), intake #147 |
| testing#track-11.1 | Gateway | MAY | N3 | - | Its subject matter, full propagation of PMIR identity merges and splits, MAY be deferred to a future release | owner decision on #16; track 8 deferred |
| future#18.4 | Editor | MAY | N3 | - | full propagation of identity merges/splits across the federation MAY be deferred to a later release | owner decision on #16; track 8 |
Pinned versions
Every version the repository depends on is pinned once, in
docs/VERSIONS.md,
and scripts/checks/versions.sh fails a change that lets a repeated pin drift
from it, this page included. This page summarises the pins that shape the
design. Each Item names its rows in docs/VERSIONS.md by their exact names,
so the guard can hold every row here to its source.
| Item | Pin | Why |
|---|---|---|
| Federation Tier with AQL | 1.0.1, the erratum to the release the openEHR SEC approved | the governing specification |
| Federation Tier with AQL specification | commit b4242c2, untagged upstream | the vendored source of that release |
| openEHR ITS-REST | 1.1.0 | the façade a client sees and the API each node exposes |
| openEHR AQL | 1.1.0 | the query language on both sides of the gateway |
openehr-query, openehr-its, openehr-base, openehr-rm | 0.0.136 | the published crates the gateway builds on, moved together as one family |
| IHE PIXm FHIR package | ihe.iti.pixm, version 3.1.0 | the identifier cross-reference binding (ITI-83) |
| IHE PDQm FHIR package | ihe.iti.pdqm, version 3.2.0 | the demographics query capability of ihe-iti (ITI-78) |
| IHE mCSD FHIR package | ihe.iti.mcsd, version 4.0.0 | the addressing binding: the registry read from a care services directory (ITI-90, ITI-91) |
| IHE PMIR FHIR package | ihe.iti.pmir, version 1.6.0 | the identity lifecycle: the subscription and the identity feed (ITI-94, ITI-93) |
| IHE IUA supplement | tag 2.5 | the access token claims and the bearer presentation client authentication reads (ITI-71, ITI-72), Revision 2.5, Trial Implementation |
| Rust toolchain | 1.99.1, edition 2024 | the toolchain the workspace builds with |
The other identity bindings (XCPD and the Dutch Generic Functions) are listed in docs/VERSIONS.md with the version each
binding uses, and each package is vendored by the issue that first reads it.
Vendored specifications
The specification, its reference implementation, the ITS-REST OpenAPI
documents with the SMART on openEHR source of the same release (a
DEVELOPMENT-status document in ITS-REST 1.1.0), the AQL source, the IHE
FHIR packages and the IHE IUA supplement are vendored verbatim under
docs/specs/,
each fetched by a committed script and stamped with a PROVENANCE.md that
records the source, the pin, the licence and a tree digest.
Licensing
FerroFED is source-available under the Business Source License 1.1: free for
every non-production use and for non-commercial production use, with a
commercial licence from Cadasto B.V., the Licensor, for any other production
use. The authoritative text is
LICENSE, with
the notice in
NOTICE.
Every FerroHEALTH product shares these terms, and the family book’s
Licensing and trademarks page
states them once: what is free, when you need a commercial licence, how to
arrange one, the brand terms of the FerroHEALTH names and marks, and the
terms under which contributions are accepted. Where that page and LICENSE
differ, LICENSE wins.
What is particular to FerroFED
- The hosted service. The Additional Use Grant excludes offering FerroFED, or a work derived from it, to third parties as a hosted, managed or embedded service through which others discover, query or exchange health records across organisations. That needs a commercial licence in every case (what counts as a hosted service).
- The change date. Each version becomes available under the Apache License 2.0 four years after that version is published. The licence calls that the Change License.
- Contributions. A contribution is licensed under the same licence. You keep your copyright, and you grant the Licensor the relicensing right in CONTRIBUTING.md § Licensing of contributions, accepted by opening the pull request.
- Third-party material. Vendored specifications and third-party material
keep their upstream terms, recorded in a
PROVENANCE.mdbeside each vendored tree. - The brand assets. The logos and icons under
assets/brand/are part of the Licensed Work.
Technical questions go to the maintainer named in
MAINTAINERS.md.
Regulatory status
This page states FerroFED’s intended purpose and how it is classified under Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation). The classification was decided on 2026-10-04 (#519): FerroFED is built and documented as an EHR system, and its harmonised software components are to be delivered before the dates the Regulation applies to it. The page also states FerroFED’s position under the Cyber Resilience Act, decided on 2026-10-06 (#654): each release is a product that Cadasto B.V. places on the market (The Cyber Resilience Act).
Every quotation from the EHDS Regulation is from the Official Journal text
(OJ L, 2025/327, 5.3.2025,
http://data.europa.eu/eli/reg/2025/327/oj), vendored verbatim at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
This page is not legal advice; the last section names
what a deployment’s counsel should confirm.
Intended purpose
FerroFED is a federation gateway intended by its manufacturer to be used by healthcare providers when providing patient care. A clinician’s application sends it an ordinary AQL query about a patient. FerroFED resolves the patient to a record at each openEHR clinical data repository (CDR) of a federation, asks each CDR for its part of the answer, and returns one answer that names the CDR each row came from. It routes the follow-up reads and writes of that answer to the CDR that holds the record.
- Users. Healthcare providers and their health professionals, through the client applications they use in patient care. Every caller is authenticated at the gateway (client authentication). The operator of a deployment configures the member CDRs and the identity services. FerroFED offers patients no access service of their own, the “electronic health data access service” of Art 2(2)(h).
- Data. Personal electronic health data, “data concerning health and genetic data, processed in an electronic form” (Art 2(2)(a)), held in the member CDRs as openEHR records. FerroFED holds no clinical data of its own: it passes each CDR’s answer through, merges the answers, and forwards a write to the CDR that owns the record. What it keeps is the registry of members, routing state in memory, the stored-query definitions and the IHE audit records waiting for delivery; Data protection lists each.
- Priority categories. Art 14(1) lists six: “(a) patient summaries; (b) electronic prescriptions; (c) electronic dispensations; (d) medical imaging studies and related imaging reports; (e) medical test results, including laboratory and other diagnostic results and related reports; and (f) discharge reports”, with their main characteristics in Annex I. FerroFED does not select data by category: a query reaches whatever the member CDRs hold. Its intended purpose therefore covers every priority category a member CDR holds, all six of Art 14(1)(a) to (f).
Classification: an EHR system
Art 2(2)(k) defines an EHR system as:
any system whereby the software, or a combination of the hardware and the software of that system, allows personal electronic health data that belong to the priority categories of personal electronic health data established under this Regulation to be stored, intermediated, exported, imported, converted, edited or viewed, and intended by the manufacturer to be used by healthcare providers when providing patient care or by patients when accessing their electronic health data
FerroFED meets each part of that definition:
- It is software that allows priority-category data to be intermediated. Answering a clinician’s query from many CDRs, and routing a read or a write to the CDR that owns the record, is intermediation. The Regulation expects an EHR system that intermediates without storing: Annex II, point 2.1, begins “Where an EHR system is designed to store or intermediate personal electronic health data”.
- The data belong to the priority categories. Data of each of the six categories can be held in an openEHR CDR, and the intended purpose covers every category the member CDRs hold.
- The manufacturer intends it for healthcare providers providing patient care. That is the intended purpose above. Defining the purpose to exclude the priority categories would cut FerroFED off from its own use case, so the purpose includes them.
The general-purpose exclusion
Art 25(2) reads: “This Chapter shall not apply to general purpose software used in a healthcare environment.” Recital 38 gives the examples it has in mind: “text-processing software used for writing reports that would then become part of written electronic health records, general-purpose middleware, or database management software that is used as part of data storage solutions.”
FerroFED is middleware, so the recital’s second example needs an answer. It is built for one clinical purpose. It speaks openEHR ITS-REST and AQL to openEHR CDRs, it resolves the patient through an identity cross-reference, it rewrites the patient’s query for each CDR, and its intended purpose names healthcare providers providing patient care. The same recital contrasts general-purpose software with “EHR systems specifically intended by the manufacturer to be used for processing one or more specific categories of electronic health data”, and FerroFED is the second kind.
The obligations and their dates
Art 25(1) sets the core obligation: “EHR systems shall include a European interoperability software component for EHR systems and a European logging software component for EHR systems (the ‘harmonised software components of EHR systems’)”. The interoperability component “provides and receives personal electronic health data under a priority category for primary use … in the European electronic health record exchange format” (Art 2(2)(n)). The logging component “provides logging information related to access by health professionals or other individuals to priority categories of personal electronic health data … in the format defined in point 3.2. of Annex II” (Art 2(2)(o)).
Art 105 sets the dates. The Regulation “shall apply from 26 March 2027”, and Articles 25, 26, 27, 47, 48 and 49 apply later: “from 26 March 2029 to … EHR systems intended by the manufacturer to process” patient summaries, prescriptions or dispensations, and “from 26 March 2031” to those intended to process imaging, test results or discharge reports. FerroFED’s intended purpose covers all six categories, so both dates apply to it: 26 March 2029 for patient summaries, prescriptions and dispensations (Art 14(1)(a) to (c)), and 26 March 2031 for imaging, test results and discharge reports (Art 14(1)(d) to (f)).
| Obligation | Article | Applies to FerroFED from |
|---|---|---|
| Include the two harmonised software components | Art 25(1) | 26 March 2029 for (a) to (c); 26 March 2031 for (d) to (f) |
| Be placed on the market or put into service “only if they comply with the provisions laid down in this Chapter” | Art 26(1) | 26 March 2029 for (a) to (c); 26 March 2031 for (d) to (f) |
| Registration in the EU database before placing on the market or putting into service, with the results of the testing environment | Art 49(2), Art 30(1)(h) | 26 March 2029 for (a) to (c); 26 March 2031 for (d) to (f) |
| Conformity of the harmonised components with the essential requirements of Annex II and the common specifications | Art 30(1)(a), Art 36 | 26 March 2027 (see below) |
| Technical documentation with at least the elements of Annex III and the results of the European digital testing environment | Art 37, Art 40(3), Art 30(1)(c) | 26 March 2027 (see below) |
| An information sheet naming the intended purpose and the data categories, and instructions for use | Art 38, Art 30(1)(d) | 26 March 2027 (see below) |
| The EU declaration of conformity, with the content of Annex IV | Art 39, Art 30(1)(e) | 26 March 2027 (see below) |
| The CE marking of conformity, affixed “before placing the EHR system on the market” | Art 41, Art 30(1)(f) | 26 March 2027 (see below) |
| Corrective action, complaint channels, and the registers of complaints and non-conforming systems | Art 30(1)(i) to (o) | 26 March 2027 (see below) |
| Keeping the technical documentation and the declaration “for 10 years” | Art 30(3) | 26 March 2027 (see below) |
| Reporting a serious incident “not later than three days” after becoming aware of it | Art 44(7) | 26 March 2027 (see below) |
| Identifying the economic operators supplied, on request, “for 10 years” | Art 35 | 26 March 2027 (see below) |
| No claim that ascribes “functions and properties to the EHR system which it does not have” | Art 28 | 26 March 2027 (see below) |
Art 105 names no later date for Articles 28, 30, 35 to 41 and 44, so the general date of 26 March 2027 applies to them. Each of them attaches to an EHR system placed on the market or put into service, and Art 26(1), which makes compliance the condition for either, applies to FerroFED from 26 March 2029 for categories (a) to (c) and from 26 March 2031 for (d) to (f). That reading is one counsel should confirm.
The technical content comes from implementing acts the Commission has not
yet adopted. The exchange format (Art 15(1)) and the common specifications
for the essential requirements (Art 36(1)) are both due “By 26 March 2027”,
and Art 105 applies them from the same 2029 and 2031 dates by category.
A weekly job, scripts/checks/ehds-acts.sh, reads EUR-Lex and the
Commission’s “Have your say” register and files an issue for each act
adopted; the re-checks against the exchange format and the common
specifications wait in FerroHEALTH/FerroBRIDGE#1327 and #799.
Two implementing acts are adopted. Both bind the national contact points and the cross-border authentication chain, and neither sets the content of the harmonised components:
| Act | Adopted under | Applies from |
|---|---|---|
| Commission Implementing Regulation (EU) 2026/2083 of 18 September 2026 on MyHealth@EU (OJ L, 21.9.2026) | Art 23(4) and (8) | 26 March 2027 |
| Commission Implementing Regulation (EU) 2026/2099 of 21 September 2026 on the cross-border identification and authentication mechanism (OJ L, 22.9.2026) | Art 16(2) | 26 March 2027, and its Art 3(3) and 5(2) from 26 March 2029 |
Implementing Regulation 2026/2083 has the national contact points exchange data “in accordance with the requirements catalogue” and “the technical specifications” the MyHealth@EU steering group approves (its Art 4(1)). Implementing Regulation 2026/2099 has the entity a Member State lists authenticate a health professional at eIDAS assurance level “substantial”, and at level “high” from 26 March 2032 (its Art 6(3)). Both are vendored beside the Regulation, with Commission Recommendation (EU) 2019/243 on a European Electronic Health Record exchange format, which recital 26 names as the format’s foundation. A weekly check reads EUR-Lex and the Commission’s “Have your say” register for the acts still pending, and files an issue when one is adopted.
A deployment a health institution runs for itself
Art 26(2) reads: “EHR systems that are manufactured and used within health institutions established in the Union, as well as EHR systems offered as a service … to a natural or legal person established in the Union, shall be considered as having been put into service.” Putting into service is “the first use, for its intended purpose, in the Union of an EHR system covered by this Regulation” (Art 2(2)(l)). Recital 44 explains the intent: a healthcare provider “developing and using an EHR system ‘in-house’” should “comply with all requirements applicable to manufacturers”, after “an extended transitional period”. Art 105 sets that period: “Chapter III shall apply to EHR systems put into service in the Union referred to in Article 26(2) from 26 March 2031.”
For FerroFED this means:
- A health institution that builds and runs its own FerroFED may be the one that manufactured and used it within the institution. It would then carry the manufacturer’s obligations for that system, from 26 March 2031.
- A deployment that changes FerroFED can make its operator a manufacturer whatever the answer to the first point. Art 34 treats a user as a manufacturer where it modifies an EHR system “in such a way that conformity with the applicable requirements might be affected”, or “in such a way that it leads to changes in the intended purpose declared by the manufacturer”. FerroFED’s source is open to modification (licensing), so this applies to any deployment that runs changed code.
- An operator that hosts FerroFED for other organisations offers an EHR system as a service, which Art 26(2) also counts as putting into service. Such a hosted service also needs a commercial licence (licensing).
FerroFED plans its harmonised components for before the dates that apply to each category (26 March 2029 for (a) to (c)) whichever reading holds, so a deployment can use them under either. Who carries the manufacturer’s obligations for a given deployment is a question for that deployment’s counsel.
Cross-border access
Art 11(2): where the Member State of affiliation and the Member State of treatment differ, “cross-border access to the personal electronic health data of the natural person under treatment shall be provided through the cross-border infrastructure referred to in Article 23”. That infrastructure is MyHealth@EU and each Member State’s national contact point for digital health, “an organisational and technical gateway for the provision of services linked to the cross-border exchange of personal electronic health data” (Art 23(2)). The exchange between contact points “shall be based on the European electronic health record exchange format” (Art 23(3)), and “Member States shall ensure the connection of all healthcare providers to their national contact points for digital health” (Art 23(5)). Art 105 applies Art 23(2) to (6) from the same 2029 and 2031 dates.
FerroFED does not reach another Member State itself and does not build the contact point’s cross-border protocols. Its route to cross-border care is the national one: a national contact point asks its national infrastructure for a patient’s documents, and FerroFED can answer that request from federated openEHR data through its interoperability component. It does so on its FHIR face, where a national connector reads the patient summary, the document list and the prescriptions and posts a dispensation (A national contact point, #524).
The Federation Tier specification does not say whether a federation whose member CDRs sit in different Member States is in scope. FerroFED neither detects nor refuses one; whether such a deployment is lawful, and for which data, is the deployment’s to answer.
The Cyber Resilience Act
Regulation (EU) 2024/2847, the Cyber Resilience Act (CRA), “applies to
products with digital elements made available on the market” whose intended
purpose includes a data connection to a device or network (Art 2(1)).
FerroFED is software whose purpose is that connection. The quotations in this
section are from the Official Journal text (OJ L, 2024/2847, 20.11.2024),
vendored with Regulation (EU) 2019/1020 at
docs/specs/eu-cra/,
and Art 104 of the EHDS Regulation amends three of its articles.
The manufacturer’s side of the CRA is the same for every FerroHEALTH product and is in the family book: who the manufacturer is, which duties apply from when, the vulnerability-handling policy, the Annex II information every product shares, and the conformity route of each product (The Cyber Resilience Act, the manufacturer). This section is FerroFED’s own reading: why a FerroFED release is a CRA product, what that asks of it, and its support period.
Why the CRA reaches a release
- A commercial activity. Art 3(22) defines making available on the market as “the supply of a product with digital elements for distribution or use on the Union market in the course of a commercial activity, whether in return for payment or free of charge”. Recital 15 names “an intention to monetise” as one mark of a commercial activity. FerroFED’s licence is free for non-production use and for non-commercial production use, and sells a licence for any other production use (Licensing). A release is therefore supplied in the course of a commercial activity, a free download included.
- Not free and open-source software. Art 3(48) asks for “a free and open-source licence which provides for all rights to make it freely accessible, usable, modifiable and redistributable”. The Business Source License 1.1 withholds production use outside its Additional Use Grant, so FerroFED is not free and open-source software within the CRA, and the open-source software steward of Art 3(14) and Art 24 does not apply to it.
- The same test under the EHDS. EHDS Art 2(1)(d) takes “placing on the market”, “manufacturer” and “economic operator” from Regulation (EU) 2019/1020, whose Art 3(1) has the same words: “in the course of a commercial activity, whether in return for payment or free of charge”.
What the manufacturer does
- One product per release. Every tagged release, meaning the source tag,
the binary tarballs, the gateway image and the console image of one
version, is one product with digital elements. Cadasto B.V. places it on
the market as its manufacturer (Art 3(13)). The
mainbranch is development code and is not supplied for use. - Reporting applies now. Art 14 has the manufacturer notify an actively
exploited vulnerability and a severe incident, with an early warning
within 24 hours. Art 71(2) applies Art 14 “from 11 September 2026”, and
Art 69(3) applies it to “all products with digital elements … placed on
the market before 11 December 2027”, the v0.0.x releases among them. The
written reporting procedure is in
SECURITY.mdand on the Complaints and incidents page (#762). - The rest from 11 December 2027. Art 71(2) applies the rest of the CRA from 11 December 2027. Each release placed on the market from that date meets the essential requirements of Annex I and has the support period below. Art 69(2) holds a product placed before that date to those requirements only once it is “subject to a substantial modification”.
- One file, one declaration, one CE marking. EHDS Art 104 inserts CRA Art 32(5a): the manufacturer of an EHR system shows conformity with CRA Annex I “using the relevant conformity assessment procedure provided for in Chapter III” of the EHDS Regulation. It also replaces CRA Art 31(3), so “a single set of technical documentation shall be drawn up” for both acts. CRA Art 28(3) and EHDS Art 39(2) each require a single EU declaration of conformity. A release placed from 11 December 2027 therefore carries one technical documentation set, one declaration and one CE marking for both acts, all planned under #525.
- The economic operators supplied. EHDS Art 35 has an economic operator identify, for 10 years, “any economic operator to which they have supplied an EHR system”. CRA Art 23(1)(b) asks the same “where available”; EHDS Art 35 has no such qualifier. The manufacturer’s answer is a register, kept for 10 years, of every economic operator it supplies under a contract: commercial licensees, resellers, integrators and hosts. The procedure is in place (The register of economic operators): the register’s format is public and checked in CI, and its rows are kept by the manufacturer outside the public repository and are not published. The public release is not gated, and it is supplied for non-production use and for non-commercial production use under the licence. If counsel holds that an anonymous production user is an economic operator, for example one that puts FerroFED into service under EHDS Art 26(2), downloads move behind a registration.
A FerroFED that someone hosts as a service, or that a health institution builds and runs for itself, is outside the CRA for that service: EHDS recital 112 says that “EHR systems offered through the SaaS licensing and delivery model do not fall within the scope of” the CRA, nor do “EHR systems that are developed and used in-house”. EHDS Art 26(2) still counts both as put into service (A deployment a health institution runs for itself). The release that was downloaded to run it stays a CRA product.
Two delegated acts are adopted under the CRA, both vendored beside it. Commission Delegated Regulation (EU) 2025/1535 excludes from the CRA the products within the scope of Regulation (EU) No 168/2013, on two- or three-wheel vehicles and quadricycles (its Art 1); FerroFED is not one of them, so the act changes nothing here. Commission Delegated Regulation (EU) 2026/881, adopted under Art 14(9), sets when the CSIRT that first receives a notification may delay passing it on (Art 16(2)). It binds that CSIRT and puts no duty on the manufacturer; the Complaints and incidents page says what it means for a notification.
The support period
Art 13(8) has the manufacturer handle a product’s vulnerabilities for its
support period, and “the support period shall be at least five years”. Art
13(19) has the end date of that period, “including at least the month and
the year”, specified “at the time of purchase”, and Annex II, point 7, puts
“the end-date of the support period” in the information to the user.
Cadasto B.V. meets this as a commitment for the whole product: for every
release placed on the market from 11 December 2027 it keeps publishing
security fixes, in new releases, for at least five years, with the end date
published, and ships them only in the newest release, as Art 13(10) allows.
Only the newest release is supported: when a new release is published,
every earlier release stops receiving fixes, and upgrading is free. There
are no maintenance branches, no long-term-support line and no backports.
SECURITY.md
lists every release with the date until which fixes are published, five
years from its release date
(#763). The gateway
knows the end date of its own release from its build: the startup banner and
OPTIONS {base}/ name it, and once it has passed the banner, the log and
config check say so
(#782).
The basis of the five years
Art 13(8), second subparagraph, has the manufacturer “determine the support period so that it reflects the length of time during which the product is expected to be in use, taking into account, in particular, reasonable user expectations, the nature of the product, including its intended purpose, as well as relevant Union law determining the lifetime of products with digital elements”. Its fifth subparagraph puts “the information that was taken into account” in the technical documentation, which Annex VII, point 4, repeats. Cadasto B.V. decided on 2026-10-16 to keep five years from each release date (#1088). It took these matters into account:
- The length of time in use. A gateway sits between the hospital platforms it federates: the CDRs behind it and the clients in front of it. The manufacturer expects one release to stay in use until those platforms are next upgraded, when the operator moves the gateway to a newer release as part of the same change, and expects that interval to fall within five years.
- Reasonable user expectations. The users are the health institutions
and the operators that run the gateway for them
(Intended purpose). They can expect every
vulnerability found to be fixed in a new release they can take free of
charge: a security fix ships as a patch release of the newest release,
with the fix alone, or with the reason a functional change ships with it
stated in the release notes
(
SECURITY.md). - The nature of the product and its intended purpose. FerroFED is software with no hardware part, and it holds no clinical data of its own, so an operator replaces a release by installing a newer one (Upgrading).
- Union law determining the lifetime. Art 13(8), third subparagraph, sets the floor: “the support period shall be at least five years”. The acts adopted under the CRA that the manufacturer holds, Regulations (EU) 2025/1535, 2025/2392 and 2026/881, set no support period. EHDS Art 39(4) keeps a digital declaration of conformity online “for the expected lifetime of the EHR system and, in any event, for at least 10 years”; that rule sets how long the declaration stays available, and sets no support period.
- The release cadence. Releases come days or weeks apart: seven between
2026-10-01 and 2026-10-15, in the table of
SECURITY.md. Only the newest release is supported, and each release starts its own five years, so fixes keep coming for at least five years after the newest release.
Art 13(8) also lets the manufacturer take into account “the support periods of products with digital elements offering a similar functionality placed on the market by other manufacturers, the availability of the operating environment, the support periods of integrated components that provide core functions and are sourced from third parties as well as relevant guidance provided by the dedicated administrative cooperation group (ADCO)”. Of these:
- Similar products. The manufacturer compared no other gateway’s support period, and the period does not rest on one.
- The operating environment and integrated components. A release runs as its container image or binary tarball and reaches each node over openEHR ITS-REST. A vulnerability in a component the release carries, which its SBOMs list, is fixed in a new release like one in the gateway’s own code, so no supplier’s end of support ends the commitment.
- ADCO and Commission guidance. The manufacturer holds no ADCO or Commission guidance on support periods (Art 52(16)) for this category of product, and reads the period against such guidance once it is published.
Already in place
None of these is a conformity claim; each answers a CRA requirement in part:
- Each release attaches a CycloneDX SBOM of the source dependency graph and an SPDX SBOM of the shipped binary, with signed provenance (Annex I, Part II, points 1 and 7).
- Vulnerabilities are reported privately, with an acknowledgement within about 5 business days and an initial assessment within about 10, and a fixed release follows within 7, 30 or 90 days of the confirmed assessment for a Critical, High or Medium vulnerability, and with the next release for a Low one (Annex I, Part II, points 5 and 6; Art 13(17)).
- The running system names the manufacturer, its postal address and its single point of contact (Art 13(16)).
- A published release cannot be changed or deleted, so every update stays available (Art 13(9)).
- The cybersecurity risk assessment reads the threat model and the clinical safety risk file against Annex I (Art 13(2), (3)), and CRA information to the user gives the points of Annex II (Art 13(18)).
What is built and what is planned
Neither harmonised software component is complete yet. Three built features relate to the essential requirements, and none has been assessed against them:
- Every caller is authenticated at the gateway (client authentication). Annex II, point 3.1, asks for “reliable mechanisms for the identification and authentication of health professionals”. For patient data the gateway requires a natural person behind the token, or a client acting for the professional the token names, and the assurance level the deployment sets per issuer (Professionals and assurance).
- Every access to patient data the gateway intermediates is recorded with
the verified caller, the patient, each endpoint asked and the categories
of the data, in the record Annex II, point 3.2, sets for the European
logging component (the access log).
The library that builds it is
ehds-logging, published from the FerroHEALTH audit library (FerroAUDIT). The records are reviewed at the Audit Record Repository with ITI-81, the connection of external software point 3.3 admits, and each states how long it is kept by its origins and categories, never under three years (point 3.4, Art 9(2); Reading the log). A request addressed byehr_idnames the patient the identity binding holds under it, so a search by the person’s identifier finds it (Art 9(1)); a query over many patients’ data names the patient behind eachehr_idits delivered rows project, up to a bound, and otherwise records its scope, which a person’s search finds through the members that hold their EHR (A query over many patients’ data). Each record carries a security label per category and per origin, which the repository applies as access rights by origin and category (point 3.4; Labelling the access records). - The European interoperability component is the
eehrxfcrate, published from FerroHEALTH/FerroBRIDGE and pinned exactly (its pages): the EHDS dataset model, read from the Xt-EHR logical models, and the mapping of an openEHR composition to a FHIR R4Bundlethrough the mapping files you supply. It also reads a received document, checks it against its profiles and maps it into one openEHR composition that keeps the original document (Receiving a document). The gateway holds the stored queries that select each patient summary section’s compositions (the section queries), and serves the patient summary built from them on a FHIR R4 face of its own, the interface of Annex II, point 2.1 (The patient summary over FHIR). The same face lists the summaries that exist for a patient, the members’ own ones included (The document list). It also searches and reads a patient’s electronic prescriptions and receives a dispensation into the member that owns its prescription, passed through in the HL7 Europe MPD profiles from the members that serve them on their own FHIR endpoint, or mapped from a member’s openEHR medication data in the map mode (Prescriptions and dispensations over FHIR). It lists, reads and receives laboratory reports in the HL7 Europe Laboratory Report profiles (Laboratory reports over FHIR), lists, reads and receives discharge reports in the Hospital Discharge Report profiles (Discharge reports over FHIR), and lists and reads imaging reports in the Imaging Report profiles (Imaging reports over FHIR).
Seven of the manufacturer’s obligations have a first answer:
- The running system names its manufacturer, with the postal address and the
single point of contact (Art 30(1)(g)):
GET {base}/,OPTIONS {base}/, the startup banner,ferrofed --version, the operator console’s About page, which the footer of every console page links to, and the image labels. - The complaint channels, the two registers, corrective action and the serious-incident report (Art 30(1)(i) to (o), Art 44(7)) are written procedures (Complaints and incidents).
- The economic operators supplied under a contract are identified from a register kept for 10 years outside the public repository (Art 35, CRA Art 23), with its procedure written and its public format checked in CI (The register of economic operators).
- Every public text is reviewed against Art 28 at each release, and the completeness default, the budgets and the consent pre-filter are assessed against Annex II, point 2.5 (Claims review).
- The information sheet gives every item of Art 38(2), held to the pin matrix by a guard, and the instructions for use cover installation, the configuration of a deployment in the EU, maintenance and the limitations (Art 30(1)(d), Art 32(2)(d), Annex III, point 1(j)).
- The clinical safety risk file names each hazard of the two harmonised components with its control and the test that holds it, for Annex II, point 1.1.
- The technical documentation indexes every element of Annex III, a release that changes a harmonised component is not cut without a review row (Art 30(2)), and each release’s documentation is kept for ten years (Art 30(3)).
The planned work is filed under #519. What remains before the Regulation applies on 26 March 2027:
- #799: the European logging software component is built (#521), and is re-checked against the Art 36(1) common specifications once they are adopted. None is adopted, so the issue waits upstream with no milestone.
- FerroHEALTH/FerroBRIDGE#1327: the European interoperability software component for the patient summary is built from federated openEHR data (#522), and is re-checked against the Art 15(1) implementing act once it is adopted. None is adopted.
- #1794: the rating of what a received discharge or imaging report can lose. The component moved to FerroBRIDGE (#813), where prescriptions from openEHR medication data, laboratory reports, discharge reports and imaging were built (issues FerroHEALTH/FerroBRIDGE#1321, FerroHEALTH/FerroBRIDGE#1322, FerroHEALTH/FerroBRIDGE#1324, FerroHEALTH/FerroBRIDGE#1325 and FerroHEALTH/FerroBRIDGE#1326, now closed).
- #525: the rest of the conformity work: the points the Annex II checklist leaves open, the EU declaration of conformity, which each release writes once the manufacturer issues it, the CE marking and the registration.
No EU declaration of conformity has been drawn up and no FerroFED release
carries the CE marking. The release process says when, in a release, the
manufacturer affixes the CE marking (Art 41) and makes or updates the EU
database entry (Art 49), and that the entry waits on the list of data the
Art 49(4) delegated act will set
(docs/release.md,
Before the tag, step 8).
The crosswalk of each category, which ties the eHealth Network dataset, the Xt-EHR logical model and the HL7 Europe profiles together, and the section queries that select the openEHR content, are on The category crosswalks.
Not legal advice
This page is the manufacturer’s reading of the EHDS Regulation and the CRA, written to plan the engineering work. It is not legal advice, and it does not decide any deployment’s obligations. The manufacturer’s own counsel is asked to confirm, now, that a release is supplied in the course of a commercial activity, that Cadasto B.V. is its manufacturer, and that CRA Art 14 applies to the v0.0.x releases; the rest of the CRA position is put to counsel before the first release placed on the market from 11 December 2027. A deployment’s counsel should confirm at least:
- The intended-purpose statement and the classification as an EHR system under Art 2(2)(k), including the answer to recital 38’s “general-purpose middleware” example.
- That the intended purpose covers all six priority categories, given that FerroFED passes through whatever the member CDRs hold.
- Whether the deployment is an EHR system “manufactured and used within health institutions” (Art 26(2)), and who then carries the manufacturer’s obligations of Art 30.
- Whether the deployment changes FerroFED in a way that makes its operator a manufacturer under Art 34.
- Whether hosting FerroFED for others is offering it as a service under Art 26(2). The manufacturer reads each tagged release as placed on the market by Cadasto B.V. (The Cyber Resilience Act; Art 2(1)(d) takes the definition from Regulation (EU) 2019/1020). What remains for a deployment is whether it is an economic operator that Cadasto B.V. supplied, which EHDS Art 35 has the manufacturer identify: 2019/1020 Art 3(13) counts whoever puts a product into service under the applicable legislation, and EHDS Art 26(2) counts a hosted or in-house system as put into service.
- The dates for the Chapter III articles Art 105 does not name, read together with Art 26(1).
- The national rules that apply beside the Regulation: national requirements for EHR systems (Art 42) and the Member State’s arrangements for connecting healthcare providers to its national contact point (Art 23(5)).
The category crosswalks
For each priority category, the interoperability component keeps a
crosswalk: the eHealth Network dataset, the Xt-EHR logical model and the HL7
Europe profiles element by element, held to the vendored packages by a test.
The component is the eehrxf crate, published from FerroHEALTH/FerroBRIDGE
and pinned exactly here, so its crosswalks are described on
FerroBRIDGE’s crosswalk page: the patient summary, the
prescription and dispensation, the discharge report, the laboratory report
and the imaging study and report. This page keeps what the gateway does with
them: its reading of the patient summary against the Xt-EHR D6.1 draft and
the section queries that select the openEHR content each document is written
from. Where the component stands as a whole is on
Regulatory status.
Two elements of the patient summary crosswalk have no openEHR content named to feed them, so they are open in the clinical safety risk file (hazard I-03) and the gateway writes them as unavailable: the medical alert (A.2.1.2), which a producer must be able to populate, and the functional status (A.2.3.4).
Against the Xt-EHR D6.1 draft
The Commission has adopted no Art 15(1) act and published no draft of one, so the patient summary is also read against the Xt-EHR deliverable D6.1 (Patient Summary: implementation guides on the EEHRxF, published in 2026-05), the closest published text of the specifications that act will lay down. It defers its datasets to the Xt-EHR logical models, which the crosswalk above is read from, and adds these points for an EHR system:
| D6.1 | What it asks | What the gateway does |
|---|---|---|
| §9.1 | a summary compiled from several sources is generated by a system connected to every relevant EHR system | the section queries go to every member that holds the patient, and a member that does not answer makes the summary incomplete (Completeness) |
| §9.1.2 | results from different sources say where and when they were documented | one Provenance per mapped composition names the composition and the member, and each member authors the sections it feeds |
| §9.3.2 | every section the summary supports has a narrative, and every content section keeps the guideline’s contents in a flat structure, with a travel history section of its own | every section carries a generated narrative; the travel history is a crosswalk row with no section query, because the International Patient Summary template has no section for it |
| §9.3.2, §9.3.4.1 | every section suggested to be supported carries an empty list reason, as the IPS 2.0.0 guide does, in place of the MyHealth@EU content-model null flavour | a section with no entry carries emptyReason, nilknown only when every member answered and none holds content for it, and unavailable otherwise |
| §9.2.1.2 | no code system or value set is mandatory; profiles may impose some | the terminology rules prefer the eHN code systems and keep a code in no preferred system as recorded (Terminology), and a received document is held to the bindings of its profiles (Receiving documents) |
D6.1 asks nothing the gateway does not do, and changes no decision. The summary is read again against the adopted act (FerroHEALTH/FerroBRIDGE#1327).
The patient summary’s section queries
Each section openEHR content feeds has a stored query the gateway holds
itself, read-only, under the namespace eu.ferrofed.eehrxf at the one
version 1.0.0 (§12.7, N44). The crate app/ferrofed-eehrxf, the
federation half of the interoperability component, builds them; eehrxf
links nothing of the gateway. The queries are:
eu.ferrofed.eehrxf::patient-summary-allergies-and-intoleranceseu.ferrofed.eehrxf::patient-summary-problemseu.ferrofed.eehrxf::patient-summary-medication-summaryeu.ferrofed.eehrxf::patient-summary-medical-devices-and-implantseu.ferrofed.eehrxf::patient-summary-procedureseu.ferrofed.eehrxf::patient-summary-immunisationseu.ferrofed.eehrxf::patient-summary-social-historyeu.ferrofed.eehrxf::patient-summary-pregnancy-historyeu.ferrofed.eehrxf::patient-summary-advance-directiveseu.ferrofed.eehrxf::patient-summary-observation-resultseu.ferrofed.eehrxf::patient-summary-care-plans
Each one selects, from every member, the whole compositions that contain one
of the archetypes the openEHR International Patient Summary template names
for its section, each composition once with its uid and its template id,
because a FHIRconnect mapping is chosen per template. The patient is the
$patient and $namespace parameters alone, so the query passes the
stored-query admission and the gateway’s rewrite asks each member by its own
ehr_id. The section-to-archetype table, and the four sections no query
feeds, are in the clinical safety risk file.
Vital signs, which the EPS composition carries in a section of its own, feed
the Xt-EHR observation results. Running a query by name and the reserved
namespace are described under
the gateway’s own queries.
The FHIR face of Annex II 2.1 assembles the patient summary from their
answers (The patient summary over FHIR,
#809); the document
list names it beside each member’s own summary
(#810).
The laboratory report’s section query
One stored query selects the laboratory reports,
eu.ferrofed.eehrxf::laboratory-report, held beside the patient summary’s
and in their shape: every whole composition that contains an
openEHR-EHR-OBSERVATION.laboratory_test_result.v1, the entry the openEHR
international Clinical Knowledge Manager’s template “Generic lab test result
example simple” names (CKM cid 1013.26.408, asset version 3). That
template is a DRAFT CKM has not reviewed or published, so a reviewed
template that names other entries would change the query; it is vendored
by scripts/vendor/openehr-ckm.sh with its status recorded. A test holds the
query to the template’s entries. The mapping of laboratory results is the
laboratory mapping eehrxf ships, built in FerroBRIDGE (issue
FerroHEALTH/FerroBRIDGE#1324,
now closed), and the FHIR face writes a member’s report with it
(Laboratory reports over FHIR).
The discharge report and the imaging report have no section query yet. Building each report from the members’ answers, and the FHIR face, are planned as the gateway half of each category (#1404, #1405, #1406), after the component work in FerroBRIDGE.
MyHealth@EU self-assessment
This page is FerroFED’s own assessment of the gateway against the MyHealth@EU Requirements Catalogue v10.0.0 (W10-Operation Ready, 2026-03-06). It is not a MyHealth@EU compliance check, and FerroFED holds no Operation Ready status: MyHealth@EU runs its own compliance checks for the national contact points in operation, and FerroFED is not a national contact point. The badge in the README counts the rows below and says self-assessed; it never says compliant, certified or operation ready.
What the catalogue binds
The catalogue’s requirements address the national contact points for digital health and MyHealth@EU. FerroFED is one of the national infrastructures a contact point calls through its national connector (A national contact point), so a requirement binds the gateway only where the contact point of the country of affiliation relies on what the national infrastructure answers: the patient lookup, the patient summary, the prescriptions and the dispensations it receives, the document categories, the quality of the data, and the record of every access.
Every row judges the gateway’s share of one requirement, and its conclusion names whose the rest is:
| Status | What it means |
|---|---|
| met | the gateway’s share is done, and the evidence shows it |
| partly-met | part of the gateway’s share is done; the conclusion names the rest and its issue |
| waits-art-15-1 | the share depends on the exchange format the Art 15(1) implementing act will fix |
| not-met | the gateway does not do its share yet; the issue tracks it where one is planned |
| not-assessed | the repository holds no page or test to judge the share by |
| not-applicable | the requirement binds the contact points, the country of treatment or MyHealth@EU, not the gateway |
The badge’s count is the rows met out of every row but the not-applicable ones. A row whose status the repository’s own pages and tests cannot show is never marked met.
The source and its terms
The catalogue is privileged-access: the Xt-EHR deliverable D6.1 (section 5)
says it is available only with authorisation, and Commission Decision
2011/833/EU Art 2(2)(c) excludes such documents from its reuse grant. The
owner read v10.0.0 under an EU Login reading grant, and FerroFED has no grant
to reuse it. This page, conformance/myhealth-eu.tsv and the badge therefore
carry requirement ids, section numbers, statuses and FerroFED’s own
conclusions, and none of the catalogue’s text. scripts/checks/myhealth-eu.sh
holds the mapping, this page and the badge in step, and refuses a copy of
the catalogue in the repository.
The implementing act on the exchange format (Regulation (EU) 2025/327 Art 15(1)) is not adopted. When it is, every row is checked again with the decisions it rests on (#1425).
The requirements
Of the 73 rows, 31 bind the gateway: 18 met, 5 partly met, 0 waiting on the Art 15(1) act, 8 not met and 0 not assessed. The other 42 do not apply to the gateway.
| Id | Section | Status | FerroFED’s conclusion | Evidence | Issue |
|---|---|---|---|---|---|
| 01 | §1.9 | met | The gateway holds the contact point’s token to the shared MyHealth@EU contact-point profile, records the professional and provider attributes the contact point asserts, and never refuses on role or point of care; identifying and authenticating the professional is the country of treatment’s. | website/book/src/operate/authentication.md::National contact pointswebsite/book/src/integrate/national-contact-point.md::What the connector callsapp/ferrofed-server/tests/it/contact_point/admission.rs::a_token_with_what_a_conforming_assertion_provides_is_admittedapp/ferrofed-server/tests/it/contact_point/admission.rs::a_token_missing_the_point_of_care_is_refused | - |
| 01.01 | §1.9.1 | not-applicable | The country of treatment identifies and authenticates the professional; the gateway receives the result as the contact point asserts it. | website/book/src/operate/authentication.md::National contact points | - |
| 01.02 | §1.9.2 | met | The gateway never refuses on the professional’s role or point of care; mapping the agreed roles to national ones is the contact point’s. | website/book/src/integrate/national-contact-point.md::What the connector calls | - |
| 02 | §1.10 | met | The gateway looks a patient up by the identifier the identity services confirmed and answers one patient or none; the identification itself belongs to the identity services and the contact point. | website/book/src/integrate/patient-summary.md::Looking a patient upapp/ferrofed-server/tests/it/fhir/lookup.rs::a_patient_a_member_holds_is_found_as_the_supplier_holds_it_and_no_member_is_askedapp/ferrofed-server/tests/it/fhir/lookup.rs::an_identifier_no_member_holds_and_a_restricted_patient_answer_alike | - |
| 02.01 | §1.10.1 | met | Same share as 02: the lookup by a confirmed identifier, never a choice among several patients. | website/book/src/integrate/patient-summary.md::Looking a patient upapp/ferrofed-server/tests/it/fhir/lookup.rs::a_search_by_demographics_or_without_an_identifier_is_refused_naming_identifier | - |
| 02.01.01 | §1.10.1.1 | not-applicable | Identification by demographics or eID belongs to the identity services and the contact point; the gateway searches no demographics. | website/book/src/integrate/patient-summary.md::Looking a patient upwebsite/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 02.01.02 | §1.10.1.2 | not-applicable | The national configuration file belongs to the contact point. | website/book/src/integrate/patient-summary.md::Looking a patient upwebsite/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 02.01.03 | §1.10.1.3 | not-applicable | Identification through the EU digital identity wallet belongs to the contact point. | website/book/src/integrate/patient-summary.md::Looking a patient upwebsite/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 03 | §1.11 | not-applicable | This is the contact point’s own operation and trust between countries; FerroFED keeps its own incident and support process for its users. | website/book/src/integrate/national-contact-point.md::What stays with the contact pointwebsite/book/src/evaluate/post-market.md | - |
| 03.01 | §1.11.1 | not-applicable | This is the contact point’s own operation. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 03.02 | §1.11.2 | not-applicable | This is the contact point’s own operation. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 03.03 | §1.11.3 | not-applicable | This is the contact point’s own operation. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 03.04 | §1.11.4 | not-applicable | This is the contact point’s own operation. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 04 | §1.12 | not-applicable | Data protection within MyHealth@EU belongs to the contact points as joint controllers; FerroFED records its own processing on its data protection page. | website/book/src/integrate/national-contact-point.md::What stays with the contact pointwebsite/book/src/evaluate/data-protection.md | - |
| 04.01 | §1.12.1 | not-applicable | This belongs to the contact points and the MyHealth@EU central services. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 04.02 | §1.12.2 | not-applicable | This belongs to the contact points and the MyHealth@EU central services. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 04.03 | §1.12.3 | not-applicable | This belongs to the contact points and the MyHealth@EU central services. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 04.04 | §1.12.4 | not-applicable | This belongs to the contact points and the MyHealth@EU central services. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 05 | §1.13 | met | The gateway serves the patient summary from every member that holds the patient on the FHIR face the contact point calls. | website/book/src/integrate/patient-summary.md::Asking for a summarywebsite/book/src/integrate/national-contact-point.md::What stays with the contact pointapp/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses | - |
| 05.01 | §1.13.1 | met | The gateway builds the document with its sections, empty reasons, nature and author. | website/book/src/integrate/patient-summary.md::The documentapp/ferrofed-server/tests/it/fhir/summary.rs::an_imported_copy_is_listed_once_from_the_member_that_created_it | - |
| 05.02 | §1.13.2 | met | The gateway writes the original information as the narrative of every section, from which the contact point renders the original; transcoding, translation and the cross-border exchange are the contact points’. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 05.03 | §1.13.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 06 | §1.14 | partly-met | The gateway serves the prescriptions each member serves at its own endpoint; prescriptions mapped from openEHR medication data wait on #1403, and which prescriptions are available abroad is each member’s selection, which the gateway does not check. | website/book/src/integrate/national-contact-point.md::What stays with the contact pointwebsite/book/src/integrate/prescriptions.md::Searching a patient's prescriptionsapp/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses | #1403 |
| 06.01 | §1.14.1 | partly-met | A member’s prescription passes through unchanged, so its content and missing-data reasons are the member’s; content mapped from openEHR waits on #1403. | website/book/src/integrate/prescriptions.md::The member contractapp/ferrofed-server/tests/it/fhir/prescriptions/mod.rs::each_members_prescriptions_are_answered_as_sent_with_a_provenance_naming_it | #1403 |
| 06.02 | §1.14.2 | partly-met | The gateway serves the list by search; the original-language source of the PDF is each member’s own resource narrative, passed through unchecked; transcoding and translation are the contact points’. | website/book/src/integrate/prescriptions.md::Searching a patient's prescriptionswebsite/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 06.03 | §1.14.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 07 | §1.15 | met | The gateway writes a dispensation to the member that owns the prescription and answers with its creation or relays its refusal; substitution belongs to the country of treatment and the owner. | website/book/src/integrate/prescriptions.md::Receiving a dispensationapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchangedapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::the_owners_refusal_is_a_conflict_naming_itapp/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses | - |
| 07.01 | §1.15.1 | met | The gateway checks a dispensation against its profile and requires its identifier, which the owner stores; its content is the country of treatment’s. | app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::a_dispensation_that_breaks_its_profile_asks_no_memberapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::a_dispensation_with_no_business_identifier_is_refused_naming_the_element | - |
| 07.02 | §1.15.2 | not-applicable | Transcoding the dispensation belongs to the contact point of the country of treatment. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 07.03 | §1.15.3 | met | The gateway confirms only after the owner created the dispensation and never sends it twice; updating the available prescriptions is the owner’s. | app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::an_owner_that_does_not_answer_is_asked_once_and_the_outcome_is_unknownwebsite/book/src/integrate/prescriptions.md::The member contract | - |
| 07.04 | §1.15.4 | met | The face takes a discard as an update with status entered-in-error by its own id, held to its profile and written once to the member that holds the dispensation; one sent again as a new dispensation is refused as a duplicate. | website/book/src/integrate/prescriptions.md::Withdrawing a dispensationapp/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs::a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_idapp/ferrofed-server/tests/it/fhir/prescriptions/dispense/duplicate.rs::a_withdrawal_sent_again_is_refused_as_a_duplicate_with_nothing_written | - |
| 08 | §1.16 | partly-met | The gateway passes the laboratory, discharge and imaging documents and images a member serves itself on byte for byte with their media type; a report it writes from an openEHR composition is a new document and no original, and the cross-border exchange is the contact points’. | website/book/src/integrate/laboratory-reports.md::A member's own laboratory reportswebsite/book/src/integrate/discharge-reports.md::A member's own discharge reportswebsite/book/src/integrate/imaging-reports.md::A member's own imaging documentsapp/ferrofed-server/tests/it/fhir/list/own_laboratory.rs::a_members_original_report_in_pdf_is_read_byte_for_byte_with_its_media_typeapp/ferrofed-server/tests/it/fhir/list/own_imaging.rs::a_members_original_image_in_png_is_read_byte_for_byte_with_its_media_typeapp/ferrofed-server/tests/it/fhir/list/own_discharge.rs::a_members_own_discharge_report_is_listed_unchanged_but_for_its_url_and_read_unchanged | - |
| 08.01 | §1.16.1 | partly-met | A member’s original keeps its bytes, its media type and its DocumentReference as the member holds them; the gateway checks neither its format nor its header data, which are the member’s, and wrapping it for the cross-border exchange is the contact point’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact pointapp/ferrofed-server/tests/it/fhir/list/own_laboratory.rs::a_members_original_report_in_pdf_is_read_byte_for_byte_with_its_media_typeapp/ferrofed-server/tests/it/fhir/list/own_imaging.rs::a_members_original_image_in_png_is_read_byte_for_byte_with_its_media_type | - |
| 08.02 | §1.16.2 | not-applicable | The cross-border exchange belongs to the contact points. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 08.03 | §1.16.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 09 | §1.17 | not-applicable | Patient access is the Member State’s access service, which FerroFED is not. | website/book/src/evaluate/natural-person-rights.md::Summary | - |
| 09.01 | §1.17.1 | not-applicable | Patient access is the Member State’s access service, which FerroFED is not. | website/book/src/evaluate/natural-person-rights.md::Summary | - |
| 09.01.01 | §1.17.1.1 | not-applicable | Patient access is the Member State’s access service, which FerroFED is not. | website/book/src/evaluate/natural-person-rights.md::Summary | - |
| 09.02 | §1.17.2 | not-applicable | Patient access is the Member State’s access service, which FerroFED is not. | website/book/src/evaluate/natural-person-rights.md::Summary | - |
| 10 | §1.18 | met | The gateway exchanges sectioned, coded data with codes as recorded, an equivalent coding only for an exact mapping, and a narrative in the original language. | website/book/src/integrate/patient-summary.md::Terminologywebsite/book/src/integrate/patient-summary.md::The document | - |
| 10.01 | §1.18.1 | met | The gateway’s share is the sectioned document with codes as recorded; transcoding to the Master Value Sets Catalogue is the contact point’s. | website/book/src/integrate/patient-summary.md::Terminologywebsite/book/src/integrate/national-contact-point.md::What stays with the contact pointwebsite/book/src/integrate/patient-summary.md::The document | - |
| 10.02 | §1.18.2 | met | The gateway adds an equivalent coding only for an exact term mapping and puts any other match in the narrative. | website/book/src/integrate/patient-summary.md::Terminology | - |
| 10.03 | §1.18.3 | met | The gateway lists an imported copy once and keeps two compositions of one fact. | website/book/src/integrate/patient-summary.md::The documentapp/ferrofed-server/tests/it/fhir/summary.rs::an_imported_copy_is_listed_once_from_the_member_that_created_itapp/ferrofed-server/tests/it/fhir/summary.rs::two_different_compositions_are_both_listed | - |
| 11 | §1.19 | not-applicable | Security, performance, traceability and auditability of the cross-border services are the contact points’; the gateway’s share is under 11.03 and 11.04. | website/book/src/operate/access-log.md::A request a national contact point relays | - |
| 11.01 | §1.19.1 | not-applicable | This is the contact point’s security management; FerroFED documents its own hardening. | website/book/src/operate/hardening.md | - |
| 11.01.01 | §1.19.1.1 | not-applicable | This belongs to the contact point; the gateway authenticates every caller and serves over TLS. | website/book/src/operate/hardening.md::TLSwebsite/book/src/operate/hardening.md::Callers and issuers | - |
| 11.01.02 | §1.19.1.2 | not-applicable | End-to-end integrity runs from the contact point onward; the gateway passes a member’s resource unchanged. | website/book/src/integrate/prescriptions.md::Searching a patient's prescriptions | - |
| 11.01.03 | §1.19.1.3 | not-applicable | This belongs to the contact point; the gateway’s health and readiness routes feed its monitoring. | website/book/src/operate/health.md::The routes | - |
| 11.01.04 | §1.19.1.4 | not-applicable | The gateway denies a contact point’s emergency access past the per-professional limit of eHDSI Section II 2.5 and records the denial with the emergency mark and the anomaly warning code; every other anomaly detection and the notice to the service desk belong to the contact point. | website/book/src/operate/access-log.md::A request a national contact point relayswebsite/book/src/integrate/national-contact-point.md::The emergency access limitapp/ferrofed-server/tests/it/e2e/emergency.rs::five_emergency_accesses_pass_and_the_sixth_is_denied_and_recorded | - |
| 11.01.04.01 | §1.19.1.4.1 | not-applicable | The detections by patient, by point of care and across all requests belong to the contact point; the gateway counts only emergency accesses per professional (Section II 2.5), and its denial record names EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS for the contact point and the national audit. | website/book/src/operate/access-log.md::A request a national contact point relayswebsite/book/src/integrate/national-contact-point.md::The emergency access limitapp/ferrofed-server/tests/it/e2e/emergency.rs::five_emergency_accesses_pass_and_the_sixth_is_denied_and_recorded | - |
| 11.01.04.02 | §1.19.1.4.2 | not-applicable | Anomaly detection belongs to the contact point; the access record and its correlation identifier feed it. | website/book/src/operate/access-log.md::A request a national contact point relays | - |
| 11.02 | §1.19.2 | not-applicable | Performance monitoring belongs to the contact point; the gateway’s metrics feed it. | website/book/src/operate/metrics.md::The metrics | - |
| 11.03 | §1.19.3 | met | The gateway’s access record names the foreign professional and provider, the time and the data, with the correlation identifier that joins it to the contact point’s log. | website/book/src/operate/access-log.md::A request a national contact point relaysapp/ferrofed-server/tests/it/access/relayed.rs::the_record_carries_the_correlation_identifier | - |
| 11.04 | §1.19.4 | met | The same record, with the emergency mark, kept and read through the log access interface. | website/book/src/operate/access-log.md::A request a national contact point relayswebsite/book/src/operate/access-log.md::Reading the logwebsite/book/src/operate/access-log.md::How long a record is keptwebsite/book/src/operate/access-log.md::Emergency accessapp/ferrofed-server/tests/it/access/accessor.rs::a_contact_point_caller_names_the_foreign_professional_and_provider | - |
| 12 | §1.20 | not-applicable | Non-repudiation is between the contact points; the record’s version uids show what the gateway served and wrote. | website/book/src/operate/access-log.md::A request a national contact point relays | - |
| 12.01 | §1.20.1 | not-applicable | As 12. | website/book/src/operate/access-log.md::A request a national contact point relays | - |
| 13 | §1.21 | not-met | The face serves and receives no document of this category yet. | website/book/src/evaluate/information-sheet.md::The categories received | #1404 |
| 13.01 | §1.21.1 | not-met | As 13. | website/book/src/evaluate/information-sheet.md::The categories received | #1404 |
| 13.02 | §1.21.2 | not-applicable | Transcoding, translation and exchange are the contact points’; FerroFED serves FHIR R4 on the HL7 Europe guides. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 13.03 | §1.21.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 14 | §1.22 | not-met | The face serves and receives no document of this category yet. | website/book/src/evaluate/information-sheet.md::The categories received | #1405 |
| 14.01 | §1.22.1 | not-met | As 14. | website/book/src/evaluate/information-sheet.md::The categories received | #1405 |
| 14.02 | §1.22.2 | not-applicable | Transcoding, translation and exchange are the contact points’; FerroFED serves FHIR R4 on the HL7 Europe guides. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 14.03 | §1.22.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 15 | §1.23 | not-met | The face serves and receives no document of this category yet. | website/book/src/evaluate/information-sheet.md::The categories received | #1406 |
| 15.01 | §1.23.1 | not-met | As 15. | website/book/src/evaluate/information-sheet.md::The categories received | #1406 |
| 15.02 | §1.23.2 | not-applicable | Transcoding, translation and exchange are the contact points’; FerroFED serves FHIR R4 on the HL7 Europe guides. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 15.03 | §1.23.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 16 | §1.24 | not-met | The face serves no vaccination history document; it is outside the Art 14(1) categories FerroFED builds. | website/book/src/evaluate/information-sheet.md::The categories received | - |
| 16.01 | §1.24.1 | not-met | As 16. | website/book/src/evaluate/information-sheet.md::The categories received | - |
| 16.02 | §1.24.2 | not-applicable | Transcoding, translation and exchange are the contact points’. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 16.03 | §1.24.3 | not-applicable | Display to the professional is the country of treatment’s. | website/book/src/integrate/national-contact-point.md::What stays with the contact point | - |
| 1.25 | §1.25 | met | Every failure of the face tells a temporary failure from a permanent one and relays a member’s refusal after the gateway’s issue. | website/book/src/integrate/national-contact-point.md::Failures the connector relaysapp/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::the_owners_own_issues_follow_the_gateways_on_a_refusal_and_an_errorapp/ferrofed-server/tests/it/contact_point/admission.rs::a_short_token_is_refused_on_every_route | - |
Information sheet
Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation) has every EHR system “accompanied by an information sheet that includes concise, complete, correct and clear information that is relevant, accessible and comprehensible to professional users” (Art 38(1)), free of charge for the user (Art 30(1)(d)). This page is that sheet for FerroFED. Each section answers one point of Art 38(2). The instructions for use are the other document Art 30(1)(d) asks for.
Art 38(3) lets a manufacturer enter the same information into the EU
database of Art 49 in place of supplying the sheet. The data that database
holds is set by a delegated act under Art 49(4) that is not adopted, so the
sheet is supplied here. Every quotation is from the Official Journal text,
vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
(a) The manufacturer
Art 38(2)(a): “the identity, registered trade name or registered trademark, and contact details of the manufacturer and, where applicable, of its authorised representative”.
| Manufacturer | Cadasto B.V. |
| Postal address | Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands |
| Single point of contact | info@cadasto.com |
| Website | https://www.cadasto.com/contact/ |
| Authorised representative | none: Art 31(1) asks for one only of a manufacturer “established outside of the Union” |
The running gateway names the same details in GET {base}/,
OPTIONS {base}/, its startup banner and ferrofed --version
(Complaints and incidents).
(b) Name, version and date of release
Art 38(2)(b): “the name and version of the EHR system and date of its release”.
The EHR system is FerroFED. This sheet describes FerroFED 0.0.20, released on 2026-11-12. It ships as these forms:
- the gateway image
ghcr.io/ferrohealth/ferrofed, for Linux onx86_64andaarch64; - the gateway binary
ferrofed, for Linux onx86_64andaarch64, on glibc and on musl; - the operator console image
ghcr.io/ferrohealth/ferrofed-console; - the source code, under the Business Source License 1.1 (Licensing).
Each release lists its changes in the
changelog,
and ferrofed --version prints the version a binary is. The book on the
main branch also documents changes merged since the release above; the
changelog’s [Unreleased] section lists them, and the next release carries
them. From FerroFED 0.0.10 on, the sheet of a release is this page at that
release’s tag.
Each release from 0.0.10 on carries its own copy of this sheet and of the
instructions for use: the Markdown
source of both pages at the tag, plain text a screen reader reads, attached
to the release on
the releases page as
ferrofed-vX.Y.Z-information-sheet.md and
ferrofed-vX.Y.Z-instructions-for-use.md. The gateway and console images
of the release name the download address of each copy in their labels
eu.ferrofed.information-sheet and eu.ferrofed.instructions-for-use,
which docker image inspect prints. The links inside a copy are relative
to this book.
Once the manufacturer issues it, a release also carries its EU declaration
of conformity (Art 39, with the content of Annex IV) as
ferrofed-vX.Y.Z-eu-declaration-of-conformity.md. No release carries one
yet; the
technical documentation
says what it holds and what it waits on.
| Release | Date |
|---|---|
| 0.0.20 | 2026-11-12 |
| 0.0.19 | 2026-11-11 |
| 0.0.18 | 2026-11-10 |
| 0.0.16 | 2026-11-05 |
| 0.0.15 | 2026-10-30 |
| 0.0.14 | 2026-10-28 |
| 0.0.13 | 2026-10-25 |
| 0.0.12 | 2026-10-24 |
| 0.0.11 | 2026-10-16 |
| 0.0.10 | 2026-10-15 |
| 0.0.9 | 2026-10-05 |
| 0.0.8 | 2026-10-04 |
| 0.0.7 | 2026-10-03 |
| 0.0.6 | 2026-10-03 |
| 0.0.3 | 2026-10-02 |
| 0.0.1 | 2026-10-01 |
| 0.0.1-rc.1, a pre-release | 2026-10-01 |
(c) Intended purpose
Art 38(2)(c): “the intended purpose of the EHR system”.
FerroFED is a federation gateway intended by its manufacturer to be used by healthcare providers when providing patient care. A clinician’s application sends it an ordinary AQL query about a patient. FerroFED resolves the patient to a record at each openEHR clinical data repository (CDR) of a federation, asks each CDR for its part of the answer, and returns one answer that names the CDR each row came from. It routes the follow-up reads and writes of that answer to the CDR that holds the record. It holds no clinical data of its own.
The users are healthcare providers and their health professionals, through the client applications they use in patient care, and the operator who configures a deployment. FerroFED offers patients no access service of its own. The full statement, and the classification as an EHR system under Art 2(2)(k), are on Regulatory status.
(d) The categories of data
Art 38(2)(d): “the categories of electronic health data that the EHR system has been designed to process”.
FerroFED selects no data by category: a query reaches whatever the member CDRs hold. It is designed to intermediate personal electronic health data of all six priority categories of Art 14(1):
| Art 14(1) | Category | Code in the access record |
|---|---|---|
| (a) | patient summaries | Patient-Summaries |
| (b) | electronic prescriptions | Electronic-Prescriptions |
| (c) | electronic dispensations | Electronic-Dispensations |
| (d) | medical imaging studies and related imaging reports | Medical-Imaging |
| (e) | medical test results, including laboratory and other diagnostic results and related reports | Laboratory-Reports |
| (f) | discharge reports | Discharge-Reports |
The codes are those of HL7 Europe’s EEHRxFDocumentPriorityCategoryCS
(hl7.fhir.eu.health-data-api 1.0.0-ballot), and the record writes each
with that system. The categories a deployment declares under national law
are recorded as well, each in the system its Member State defines
(Categories).
The categories received
Annex II, points 2.2 and 2.3, ask that an EHR system “be able to receive
personal electronic health data in the European electronic health record
exchange format”. A client sends such a document to
POST {fhir-base}/Bundle, and FerroFED writes it to the one member the
deployment declares for its category
(Receiving a document). This release can receive
documents of three categories:
| Art 14(1) | Code | Received in the exchange format |
|---|---|---|
| (a) | Patient-Summaries | yes |
| (b) | Electronic-Prescriptions | no |
| (c) | Electronic-Dispensations | no |
| (d) | Medical-Imaging | yes |
| (e) | Laboratory-Reports | yes |
| (f) | Discharge-Reports | yes |
An imaging report is received over the HL7 Europe Imaging Report 1.0.0-ballot package closure, which the package set pins (#1716).
An electronic dispensation, Art 14(1)(c), is received as one resource,
which the table above does not count: a client sends one
MedicationDispense-eu-mpd to POST {fhir-base}/MedicationDispense, and
FerroFED writes it to the member that owns the prescription it names, when the deployment declares
members for electronic dispensations in [[fhir.serve]]
(Receiving a dispensation).
Electronic prescriptions, Art 14(1)(b), are served by search and read and
are never received.
Which of these a deployment receives is its operator’s choice: each
[[fhir.receive]] table of the configuration names the one member that
stores a category’s documents
(Configuring the face).
A category with no such table has no receiving member, and a document of it
is refused with a 422 OperationOutcome that names the category, with
nothing sent to any member. Every receipt that reached a member writes one
access record carrying the document’s category and the member.
This sheet is the same for every deployment of a release, so it cannot
list one deployment’s receiving members. Each deployment states its own:
GET {fhir-base}/metadata answers a CapabilityStatement whose Bundle
resource, present only when the deployment receives a category, names, by the codes above, the categories the deployment receives
and those it has no receiving member for, and lists the Bundle profile of
each category received.
(e) Standards, formats and specifications
Art 38(2)(e): “the standards, formats and specifications supported by the EHR system and versions of those standards, formats and specifications”.
scripts/checks/versions.sh holds every row of this table to the pin
matrix,
docs/VERSIONS.md,
so a version that moves there fails CI until this sheet moves with it. Each
Item names its row in the matrix exactly.
| Item | Pin | What FerroFED does with it |
|---|---|---|
| Product version | 0.0.20 | the FerroFED version this sheet describes |
| Federation Tier with AQL | 1.0.1 | the federation it implements: the façade, the rewrite per node, the merged answer with meta.federation, follow-up routing; the release the openEHR SEC approved, with its 1.0.1 erratum to the routing of EHR-scoped version requests |
| openEHR ITS-REST | 1.1.0 | the API a client calls and the API it calls at each member |
| openEHR AQL | 1.1.0 | the query language a client sends and each member answers |
| openEHR Reference Model specification source | tag Release-1.1.0 | the identifiers and versioning model routing reads, RM 1.1.0 |
| IHE PIXm FHIR package | ihe.iti.pixm, version 3.1.0 | identity resolution, ITI-83 |
| IHE PDQm FHIR package | ihe.iti.pdqm, version 3.2.0 | the demographics step ahead of resolution, ITI-78 and ITI-119 |
| IHE PMIR FHIR package | ihe.iti.pmir, version 1.6.0 | the identity feed, ITI-93 and ITI-94 |
| IHE mCSD FHIR package | ihe.iti.mcsd, version 4.0.0 | the registry read from a care services directory, ITI-90 and ITI-91 |
| IHE IUA supplement | tag 2.5 | the claims of a caller’s access token, ITI-71 and ITI-72 |
| IHE ITI-20 Record Audit Event | Revision 20.2 | the audit trail sent to an Audit Record Repository |
| IHE RESTful ATNA supplement | Rev. 3.6 | the FHIR Feed of ITI-20 the audit and access records travel by |
| IHE BALP FHIR package | ihe.iti.balp, version 1.1.4 | the AuditEvent patterns of the audit trail and of the access record of Annex II, point 3.2 |
| Netherlands Generic Functions IG source | tag v0.3.0, version 0.3.0 | the Dutch binding: NVI localization, Mitz consent, LRZa addressing, Annex B of the Federation Tier |
| Nuts specifications | commit 7c0de53 | the Nuts grant of the Dutch binding, RFC003 and RFC021 |
| HL7 Europe Patient Summary | hl7.fhir.eu.eps 1.0.0-ballot | the patient summary document the FHIR face writes and receives, Annex II, points 2.1 to 2.3 |
| HL7 Europe Medication Prescription and Dispense | hl7.fhir.eu.mpd 1.0.0 | the MedicationRequest and MedicationDispense profiles the FHIR face serves prescriptions in and holds a received dispensation to, Art 14(1)(b) and (c) |
| HL7 Europe Laboratory Report | hl7.fhir.eu.laboratory 2.1.0 | with 2.0.0 beside it, the version the interoperability component reads: the laboratory report document the FHIR face receives, writes from a member’s composition and lists, Art 14(1)(e) |
| HL7 Europe Imaging Report | hl7.fhir.eu.imaging 1.0.0-ballot | the medical imaging report document the FHIR face writes from a member’s composition and lists, Art 14(1)(d) |
| HL7 Europe Hospital Discharge Report | hl7.fhir.eu.hdr 1.0.0 | the discharge report document the FHIR face receives, writes from a member’s composition and lists, Art 14(1)(f) |
| HL7 Europe Base and Core | hl7.fhir.eu.base 2.0.1 | the EU core profiles each mapped entry of the patient summary is written to |
| HL7 Europe EU Health Data API | hl7.fhir.eu.health-data-api 1.0.0-ballot | the document list’s DocumentReference profile, the Document Access Provider interactions it asks a member that serves its own laboratory, imaging or discharge reports, and the category codes of the access record |
eehrxf | 0.8.0 | the European interoperability software component, Art 25(1), published from FerroHEALTH/FerroBRIDGE (its pages); it reads the Xt-EHR EHDS Logical Information Models 1.0.0 as its dataset model |
Beside them:
- IHE XCPD (ITI-55), IHE ITI Technical Framework Volume 2 Revision 20.1, for localizing an undirected query.
- FHIR R4 (4.0.1), the version every IHE and Dutch profile above is written for.
- OAuth 2.0 and its extensions, by RFC: 6749, 7523 (client assertions), 7662 (introspection), 8414 (server metadata), 8693 (token exchange), 8705 (mutual TLS), 9068 (access tokens), 9396 (authorization details) and 9449 (DPoP); and the OpenID FAPI 2.0 Security Profile, Final, for the FAPI 2.0 grant of the Dutch binding.
- SMART on openEHR, the scope grammar of ITS-REST 1.1.0, for the scopes each route requires.
- OpenID Connect Core 1.0, for the operator console’s sign-in and the refresh of the operator’s access token.
- FHIRconnect 1.0.0, the mapping language the interoperability component maps openEHR content to FHIR R4 and back with.
- TLS 1.3 and 1.2 on the gateway’s listeners, with the TLS 1.2 suites RFC 9325 recommends (BCP 195).
The European electronic health record exchange format of Art 15 has no adopted implementing act. The gateway serves the patient summary and laboratory, medical imaging and discharge reports in the FHIR R4 form of HL7 Europe’s packages and receives each in the same form (The categories received, Laboratory reports over FHIR, Imaging reports over FHIR, Discharge reports over FHIR). It serves electronic prescriptions and receives electronic dispensations in the HL7 Europe MPD profiles, through members that serve them or, in the map mode, from their openEHR medication orders (Prescriptions and dispensations over FHIR). No other category is produced or received (Regulatory status). The instructions for use list this and the other limitations.
Support period
Art 38(2) asks for no support period. Regulation (EU) 2024/2847, the Cyber Resilience Act, does, and it reaches every FerroFED release as a product Cadasto B.V. places on the market (Regulatory status). Its Art 13(19) has “the end date of the support period …, including at least the month and the year”, specified “at the time of purchase”, and its Annex II, point 7, puts “the end-date of the support period” in the information to the user.
Only the newest release is supported: every earlier release stops
receiving fixes when a new one is published, and upgrading is free. For
every release placed on the market from 11 December 2027, the manufacturer
keeps publishing security fixes, in new releases, for at least five years,
the minimum of Art 13(8), and ships them only in the newest release
(Art 13(10)), with the end date published.
SECURITY.md
lists every release with the date until which fixes are published, five
years from its release date. For FerroFED 0.0.20 that date is 2031-11-12;
0.0.20 itself is supported while it is the newest release.
The rest of the information the Cyber Resilience Act’s Annex II asks for is
on CRA information to the user.
Economic operators supplied
Art 38(2) asks for no record of supply. Art 35 has the manufacturer identify, on request and for 10 years, “any economic operator to which they have supplied an EHR system”, and the Cyber Resilience Act’s Art 23 asks for that operator’s name and address. Cadasto B.V. enters every economic operator it supplies FerroFED to under a contract in a register it keeps outside the public repository; the register’s format is public and checked in CI. A public download is not entered, because its recipient is not known (The register of economic operators).
Accessible formats
Recital 37 asks for the sheet and the instructions “including in accessible
formats for persons with disabilities”. This sheet is HTML text with headings
and tables with header rows; no information on it is carried by an image or
by colour alone. Its source is plain Markdown in the repository,
website/book/src/evaluate/information-sheet.md,
which a screen reader or a braille display reads as text. The book’s print
page renders every page as one document for printing or saving. Ask the
single point of contact above for another format.
How the sheet is kept
Every release cut updates this sheet in its version-bump pull request
(docs/release.md,
Before the tag): the version, the date of release, the release table and the
standards. scripts/checks/versions.sh fails while the product version or a
pin here differs from the pin matrix, or while the date of release differs
from the release’s heading in CHANGELOG.md.
CRA information to the user
Regulation (EU) 2024/2847, the Cyber Resilience Act (CRA), has each product with digital elements “accompanied by the information and instructions to the user set out in Annex II, in paper or electronic form” (Art 13(18)). This page gives that information for FerroFED, point by point in the order of Annex II. Each point quotes what Annex II asks and answers it, or links the page that does.
The product is each tagged FerroFED release, in the forms on Forms placed on the market. Points 1 and 2, and the first half of point 7, are the same for every FerroHEALTH product, and the family book is their source (The information that accompanies each release). The Annex II checklist of this book is a different list: the essential requirements of Annex II of Regulation (EU) 2025/327, the EHDS Regulation.
Every quotation is from the Official Journal text, vendored at
docs/specs/eu-cra/reg-eu-2024-2847-en.xhtml
(http://data.europa.eu/eli/reg/2024/2847/oj). No conformity assessment
has been carried out and no EU declaration of conformity exists. This page
gives the information Annex II lists; it does not say that FerroFED, or a
deployment of it, meets the Regulation.
Which copy to read
The information for a release is this page at that release’s tag, from
FerroFED 0.0.11 on. Art 13(18) has information provided online kept
“available online for at least 10 years after the product with digital
elements has been placed on the market or for the support period, whichever
is longer”. A v* tag cannot be moved or deleted, so the page’s source at
each tag stays as it was
(Keeping it for ten years).
The page is written in English; a market surveillance authority that needs
a translation asks the single point of contact of point 2.
1. The manufacturer
“the name, registered trade name or registered trademark of the manufacturer, and the postal address, the email address or other digital contact as well as, where available, the website at which the manufacturer can be contacted;”
Cadasto B.V. The details are on the family book’s
The manufacturer
page, and the information sheet
repeats them. The running gateway names them in GET {base}/,
OPTIONS {base}/, its startup banner and ferrofed --version.
2. The single point of contact for vulnerabilities
“the single point of contact where information about vulnerabilities of the product with digital elements can be reported and received, and where the manufacturer’s policy on coordinated vulnerability disclosure can be found;”
The policy and the routes are on the family book’s
Security
page. A vulnerability in FerroFED is reported through
GitHub private vulnerability reporting,
as SECURITY.md
says, and the advisories are published on the repository’s
security advisories.
3. Identification
“name and type and any additional information enabling the unique identification of the product with digital elements;”
- Name: FerroFED.
- Type: software, a federation gateway for openEHR clinical data repositories, run as a server process.
- Unique identification: the version,
X.Y.Z, with the signed tagvX.Y.Z.ferrofed --version, the startup banner andGET {base}/name the version a gateway runs. An image is identified by its digest, and a tarball by its.sha256sumand its attestations ((b) Name, version and date of release).
4. Intended purpose, security environment and properties
“the intended purpose of the product with digital elements, including the security environment provided by the manufacturer, as well as the product’s essential functionalities and information about the security properties;”
- Intended purpose: Intended purpose.
- Essential functionalities: the federated query, follow-up routing and definitions (How it works).
- The security environment: the reverse proxy, the issuers, the identity services and the Audit Record Repository a deployment supplies (What FerroFED runs beside), and the trust relationships between them (Trust and keys).
- Security properties: every caller is authenticated; no directly identifying patient identifier reaches a node (§5.4, N33, Where the patient identifier stops); each node is told who asks and decides for itself; every access to patient data is recorded. The threat model names each property with the test that holds it, and the cybersecurity risk assessment reads them against Annex I.
5. Circumstances that may lead to significant cybersecurity risks
“any known or foreseeable circumstance, related to the use of the product with digital elements in accordance with its intended purpose or under conditions of reasonably foreseeable misuse, which may lead to significant cybersecurity risks;”
The full list, each with its control, is the threat model’s Risks the deployment carries. The circumstances that lead to the most severe ones:
- running
profile = "development"near real data (Hardening); - leaving the hop between the proxy and the gateway in cleartext on a shared network (TLS on the listeners);
- trusting an issuer or an identity service that is compromised, or a member that ignores the caller the gateway conveys (Admitting a node);
- keeping the audit spool on an unencrypted volume (The audit spool);
- running a release that is not the newest, which receives no fix (point 7).
6. The EU declaration of conformity
“where applicable, the internet address at which the EU declaration of conformity can be accessed;”
None has been issued. Once the manufacturer issues one, it is attached to
each release it covers at
https://github.com/FerroHEALTH/FerroFED/releases/download/vX.Y.Z/ferrofed-vX.Y.Z-eu-declaration-of-conformity.md
(The EU declaration of conformity).
7. Technical security support and the support period
“the type of technical security support offered by the manufacturer and the end-date of the support period during which users can expect vulnerabilities to be handled and to receive security updates;”
The type of support is the manufacturer’s, described on the family book’s
How a fix reaches you.
Only the newest release of FerroFED is supported. When a new release is
published, every earlier release stops receiving fixes: there are no
maintenance branches, no long-term-support line and no backports. A
security fix ships in a new release, a patch release with no feature
changes in it, and a user of an older release upgrades to the newest
release, free of charge, to get the fix
(SECURITY.md).
Cadasto B.V. keeps publishing security fixes, in new releases, for at least
five years after each release (Art 13(8)), and ships them only in the
newest release (Art 13(10)). The end date of each release is the date until
which that commitment runs; the release itself is supported only while it
is the newest. The end dates, as SECURITY.md lists them:
| Release | Released | Fixes published until |
|---|---|---|
| v0.0.1 | 2026-10-01 | 2031-10-01 |
| v0.0.3 | 2026-10-02 | 2031-10-02 |
| v0.0.6 | 2026-10-03 | 2031-10-03 |
| v0.0.7 | 2026-10-03 | 2031-10-03 |
| v0.0.8 | 2026-10-04 | 2031-10-04 |
| v0.0.9 | 2026-10-05 | 2031-10-05 |
| v0.0.10 | 2026-10-15 | 2031-10-15 |
| v0.0.11 | 2026-10-16 | 2031-10-16 |
| v0.0.12 | 2026-10-24 | 2031-10-24 |
| v0.0.13 | 2026-10-25 | 2031-10-25 |
| v0.0.14 | 2026-10-28 | 2031-10-28 |
| v0.0.15 | 2026-10-30 | 2031-10-30 |
| v0.0.16 | 2026-11-05 | 2031-11-05 |
| v0.0.18 | 2026-11-10 | 2031-11-10 |
| v0.0.19 | 2026-11-11 | 2031-11-11 |
| v0.0.20 | 2026-11-12 | 2031-11-12 |
SECURITY.md is the record. The release cut adds each new row to it and
copies the row here, and a guard, scripts/checks/support-periods.sh, fails
every change and every release while the two tables differ. A gateway names its own end date in the startup banner and in
OPTIONS {base}/, and says so once the date has passed.
8. Instructions
“detailed instructions or an internet address referring to such detailed instructions and information on:”
8(a) Secure use from commissioning on
“the necessary measures during initial commissioning and throughout the lifetime of the product with digital elements to ensure its secure use;”
Commission a deployment with A production deployment
and check it against the Hardening checklist. The
instructions for use give the order of
the steps, and their
maintenance table
lists what keeps it secure through its lifetime: upgrades, config check,
key rotation, certificate renewal and the alerts.
8(b) How changes affect the security of data
“how changes to the product with digital elements can affect the security of data;”
- Configuration. The configuration decides whom the gateway trusts and
where it sends data. The development profile admits a static
cross-reference and cleartext credentials; an issuer added to
[auth]is trusted for every caller it vouches for; a member added to the registry receives queries once admitted. Runferrofed config checkbefore every start, reload and upgrade (Configuration, Admitting a node). - Upgrades. A release’s upgrade notes list every key that became mandatory or a value now refused (Upgrading).
- Stored queries. A stored query an operator publishes runs against every member it reaches (Stored queries).
- Changes to the source. A binary or image built from modified source is not a Cadasto B.V. release: its provenance does not verify, and the cybersecurity risk assessment does not describe it.
8(c) Installing security updates
“how security-relevant updates can be installed;”
Hear of a release from the release feed,
https://github.com/FerroHEALTH/FerroFED/releases.atom, and of a
vulnerability from the repository’s security advisories
(Hearing of an update).
Verify the release with gh attestation verify
(Kubernetes,
The release binaries), then
follow Upgrading; Rollback
says how to go back.
8(d) Secure decommissioning
“the secure decommissioning of the product with digital elements, including information on how user data can be securely removed;”
Decommissioning takes a deployment out of service step by step: it lists what a deployment holds and where, moves what a replacement needs, lets the audit spools deliver, stops the replicas, keeps the access records for their retention period, retires the signing keys and the credentials, and removes the stores and the volumes.
8(e) Turning off automatic security updates
“how the default setting enabling the automatic installation of security updates, as required by Part I, point (2)(c), of Annex I, can be turned off;”
FerroFED installs no update by itself, so there is no such setting; the risk assessment says why (Automatic security updates).
8(f) Information for an integrator
“where the product with digital elements is intended for integration into other products with digital elements, the information necessary for the integrator to comply with the essential cybersecurity requirements set out in Annex I and the documentation requirements set out in Annex VII.”
This point does not apply: FerroFED is not intended for integration into another product, and its library crates reach a user only inside the release forms. Client applications connect to a running gateway over ITS-REST (The client contract).
9. The software bill of materials
“If the manufacturer decides to make available the software bill of materials to the user, information on where the software bill of materials can be accessed.”
Cadasto B.V. makes them available. Each release tarball on
the releases page comes
with a CycloneDX SBOM of the source graph (.cdx.json) and an SPDX SBOM of
the shipped binary (.spdx.json), each attested
(The release binaries). The
gateway and console images carry an SPDX SBOM per platform as an attestation
in the registry, which gh attestation verify reads
(Kubernetes).
Technical documentation
Art 30(1)(c) of Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation) has the manufacturer “draw up the technical documentation of their EHR systems in accordance with Article 37 before placing those EHR systems on the market, and subsequently keep it up to date”. Art 37(2) says what it is for: it “shall demonstrate that the EHR system complies with the essential requirements laid down in Annex II and provide market surveillance authorities with all the necessary information to assess the conformity of the EHR system with those requirements”, and “shall contain, as a minimum, the elements set out in Annex III and a reference to the results obtained from a European digital testing environment referred to in Article 40”.
This page is the index of FerroFED’s technical documentation. Each element
of Annex III is documented on the page or file the index names, and the
technical documentation of a release is those pages and files as they stand
at the release’s tag. The index is kept as
conformance/technical-documentation.tsv,
and scripts/checks/technical-documentation.sh fails a change that leaves
an element without a row, a row whose file or heading does not exist, or an
element phrase the vendored Regulation does not hold. CI runs it on every
change and the release lane runs it before a release is created.
Every quotation of the Regulation is from the Official Journal text, vendored
at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
The dates the obligations apply from are on the
regulatory status
page. The conformity record holds each obligation
Chapter III places on the manufacturer, and each item of the declaration,
with its evidence and what is still open. This page is not legal advice.
The Annex III index
Annex III asks for these elements “as applicable to the harmonised software components of an EHR system in the relevant EHR system”. A status of partial or planned names the issue that completes it.
| Item | What Annex III asks | Where FerroFED documents it | Status |
|---|---|---|---|
| 1(a) | “its intended purpose, and the date and version of the EHR system” | Intended purpose; the information sheet, (b) Name, version and date of release | documented |
| 1(b) | “the categories of personal electronic health data that the EHR system has been designed to process” | the information sheet, (d) The categories of data | documented |
| 1(c) | how it “interacts or can be used to interact with hardware or software that is not part of the EHR system itself” | The services a gateway consumes; the client contract; the patient summary over FHIR; prescriptions and dispensations over FHIR | documented |
| 1(d) | “the versions of relevant software or firmware and any requirement related to version update” | Pinned versions and docs/VERSIONS.md; the compatibility policy | documented |
| 1(e) | “the description of all forms in which the EHR system is placed on the market or put into service” | Forms placed on the market | documented |
| 1(f) | “the description of hardware on which the EHR system is intended to run” | The hardware it runs on; Supported platforms | documented |
| 1(g) | “a description of the system architecture explaining how software components build on or feed into each other”, with labelled diagrams | The parts of the gateway, with its diagrams; the design of record, docs/architecture.md | documented |
| 1(h) | the technical specifications, “including a detailed description of the data structures, storage and input/output of data” | the standards, (e) Standards, formats and specifications; what a client gets back; what the gateway keeps; Configuration; what a revision holds; Sizing | documented |
| 1(i) | “a description of any change made to the system throughout its lifecycle” | the changelog; Changes to the harmonised components; the clinical safety review table | documented |
| 1(j) | “the instructions for use for the user and, where applicable, installation instructions” | Instructions for use; A production deployment | documented |
| 2 | “A detailed description of the system in place to evaluate the EHR system performance, where applicable” | Metrics; Scoring a deployment; the clinical safety method | documented |
| 3 | “The references to any common specification used in accordance with Article 36” | none is adopted yet (The obligations and their dates) | planned, #799 |
| 4 | “The results and critical analyses of all verifications and validation tests” | the Annex II checklist, the Chapter III conformity record, the conformance statement, the conformance matrix, the obligations checklist and the clinical safety risk file; the Annex II checklist has points still partial, each with its issue (What is missing) | partial, #525 |
| 5 | “A copy of the information sheet referred to in Article 38” | the information sheet, attached to each release | documented |
| 6 | “A copy of the EU declaration of conformity” | The EU declaration of conformity, attached to each release as ferrofed-vX.Y.Z-eu-declaration-of-conformity.md once the manufacturer issues it; the generator scripts/release/declaration-of-conformity.sh | partial, #940 |
| 37(2) | “a reference to the results obtained from a European digital testing environment referred to in Article 40” | The European digital testing environment | planned, #675 |
The Cyber Resilience Act’s part
FerroFED is also a product with digital elements under Regulation (EU)
2024/2847, the Cyber Resilience Act
(The Cyber Resilience Act).
Art 104(2) of the EHDS Regulation replaces that Act’s Art 31(3), so for an
EHR system “a single set of technical documentation shall be drawn up
containing the information referred to in Annex VII and the information
required by those Union legal acts”. The index below is that Act’s part of
this set. It is kept as
conformance/cra-technical-documentation.tsv,
and scripts/checks/technical-documentation.sh holds it as it holds the
Annex III index, against the vendored Act at
docs/specs/eu-cra/reg-eu-2024-2847-en.xhtml.
Annex VII asks for each point “as applicable to the relevant product with
digital elements”. A status of partial names the issue that completes it.
| Annex VII | What it asks | Where FerroFED documents it | Status |
|---|---|---|---|
| point 1(a) | “its intended purpose” | Intended purpose | documented |
| point 1(b) | “versions of software affecting compliance with essential cybersecurity requirements” | Pinned versions and docs/VERSIONS.md | documented |
| point 1(c) | photographs or illustrations, “where the product with digital elements is a hardware product” | FerroFED is software (3. Identification) | not applicable |
| point 1(d) | “user information and instructions as set out in Annex II” | CRA information to the user | documented |
| point 2(a) | “necessary information on the design and development of the product with digital elements”, with the system architecture | The parts of the gateway; the design of record, docs/architecture.md | documented |
| point 2(b) | “necessary information and specifications of the vulnerability handling processes put in place by the manufacturer”, with the SBOM, the disclosure policy, the contact address and the secure distribution of updates | SECURITY.md, with the family book’s Security; The release binaries; The image; the CRA Art 14 procedure of docs/post-market.md | documented |
| point 2(c) | “necessary information and specifications of the production and monitoring processes of the product with digital elements and the validation of those processes” | the release lane, docs/release.md; the required checks, docs/ci-cd.md; post-market monitoring, docs/post-market.md | documented |
| point 3 | “an assessment of the cybersecurity risks against which the product with digital elements is designed, developed, produced, delivered and maintained pursuant to Article 13” | Cybersecurity risk assessment | documented |
| point 4 | “relevant information that was taken into account to determine the support period pursuant to Article 13(8)” | The support period and SECURITY.md state the period, and The basis of the five years answers each matter Art 13(8) names | documented |
| point 5 | “a list of the harmonised standards applied in full or in part”, or the solutions adopted where none is applied | the solutions, point by point, in the cybersecurity risk assessment; the specifications applied, (e) Standards, formats and specifications; no list of harmonised standards | partial, #525 |
| point 6 | “reports of the tests carried out to verify the conformity of the product with digital elements and of the vulnerability handling processes” | the conformance statement, the conformance matrix and the tests the threat model names; no report against Annex I | partial, #525 |
| point 7 | “a copy of the EU declaration of conformity” | The EU declaration of conformity; none is issued yet | partial, #940 |
| point 8 | “where applicable, the software bill of materials”, on a reasoned request of a market surveillance authority | The release binaries: every release attaches its SBOMs | documented |
This page holds the index for both acts in one set. The points still partial are the ones the two tables mark.
Forms placed on the market
Each release is placed on the market by Cadasto B.V. in these forms, all
from the same signed tag vX.Y.Z on
the releases page:
- The source code at the tag, under the Business Source License 1.1 (Licensing).
- The gateway binary
ferrofed, as one tarball per target:x86_64-unknown-linux-gnu,x86_64-unknown-linux-musl,aarch64-unknown-linux-gnuandaarch64-unknown-linux-musl. Each carries its checksum, two SBOMs and its signed provenance (The release binaries). - The gateway image
ghcr.io/ferrohealth/ferrofed, forlinux/amd64andlinux/arm64, tagged with the version and, for a release that is not a pre-release,<major>.<minor>andlatest(The image). - The operator console image
ghcr.io/ferrohealth/ferrofed-console, for the same two platforms (The operator console). The console holds neither harmonised component. - The files attached to the release: the gateway compose file with its
example
ferrofed.tomlandregistry.toml, the nginx reverse proxy configuration, the conformance seed data, the Grafana dashboard and the Prometheus alert rules, the copies of the information sheet and the instructions for use, and the EU declaration of conformity once the manufacturer issues it.
The library crates under crates/, ehds-logging and eehrxf among them,
are not published to crates.io; each reaches a user only inside the forms
above.
An EHR system is also “put into service” when someone else hosts FerroFED as a service or a health institution builds and runs it for itself (Art 26(2)); A deployment a health institution runs for itself says what that means for the release it was built from.
The hardware it runs on
FerroFED runs on Linux on x86_64 and aarch64 processors, as the binary
on glibc or musl, or as the container image under a container runtime such
as Docker or Kubernetes. It runs on Unix only, and nothing is built or
tested for Windows (Supported platforms).
Its signing keys and secrets are files, and it writes to local disk only
the embedded stored-query file and the audit spools, where a deployment
configures them (The audit spool).
The release sets no minimum processor or memory. The measured footprint of the quickstart is a few MiB of memory for the gateway, and the sizing guidance bounds the concurrent work from what you measure. Several replicas that accept stored queries share one PostgreSQL database (Running several replicas).
Changes to the harmonised components
Art 30(2) has the manufacturer “ensure that procedures are in place to ensure that the design, development and deployment of the harmonised software components of an EHR system continue to comply with the essential requirements laid down in Annex II”, and says that “changes in EHR system design or characteristics with regard to the harmonised software components of an EHR system shall be adequately taken into account and reflected in the technical documentation”.
The harmonised software components (Art 25(1)) and the code that serves them are these, two published crates and paths of this repository:
ehds-logging, the European logging software component, published from the family’s audit library (FerroHEALTH/FerroAUDIT) and pinned exactly in the rootCargo.toml, so a moved pin is a change to it;app/ferrofed-server/src/access, which builds its access record;eehrxf, the European interoperability software component, published from FerroHEALTH/FerroBRIDGE and pinned exactly in the rootCargo.toml, so a moved pin is a change to it (its pages);app/ferrofed-eehrxf, its stored section queries and its documents;app/ferrofed-server/src/fhir, the FHIR face that serves it.
A release that changes any of them since the previous release cannot be
cut until it is reviewed. scripts/checks/technical-documentation.sh
compares the version being built with the newest release tag below it, and
when a file under these paths differs or a published crate’s pin moves, it
fails unless the
clinical safety review table has a
row whose release is that version and the table below has a row for it. A
routine pull request keeps the version of the last release, whose rows
exist, so it is not held up; the version-bump pull request and the plan
job of the release lane are where the check stops a release
(docs/release.md,
Before the tag).
| Release | Date | What changed in the harmonised components | Review |
|---|---|---|---|
| 0.0.20 | 2026-11-12 | the interoperability component as the published eehrxf 0.7.0 from FerroHEALTH/FerroBRIDGE with fhirconnect 0.2.1, openehr-mapping-core 0.1.154, ferrobridge-term and ferrobridge-validator 0.6.0 on the openehr-* family at 0.0.136; a received imaging report’s performing organization carried into the member’s composition over FerroBRIDGE’s imaging report template at v0.0.24, and a performer that is no organization declared to the sender; the logging component on ehds-logging 0.13.0 with ihe-atna 0.13.0 on ferrohealth-smart 0.19, the professional and the purposes of use of its record read from the issuer’s [[auth.attributes]] table | the clinical safety review rows dated before 0.0.20 and the release cut row of 2026-11-12 |
| 0.0.19 | 2026-11-11 | the interoperability component as the published eehrxf 0.6.1 from FerroHEALTH/FerroBRIDGE with fhirconnect 0.2.1, openehr-mapping-core 0.1.154, ferrobridge-term and ferrobridge-validator 0.5.0 on the openehr-* family at 0.0.136; the medical imaging report, Art 14(1)(d), received at a [[fhir.receive]] member over the Imaging Report package closure; every received category compiled with the declarations of the project eehrxf ships for it, so what a received report loses is relayed to the sender as not carried, and a written report names what its mapping declared it left out; a written report names its member the custodian; the logging component on ehds-logging 0.12.0 with ihe-atna 0.12.0 on ferrohealth-smart 0.18, an access record stored within its request’s own time | the clinical safety review rows dated before 0.0.19 and the release cut row of 2026-11-11 |
| 0.0.18 | 2026-11-10 | the interoperability component as the published eehrxf 0.1.187 from FerroHEALTH/FerroBRIDGE with fhirconnect 0.1.241, openehr-mapping-core 0.1.148, ferrobridge-term 0.1.148 and ferrobridge-validator 0.1.154 on the openehr-* family at 0.0.135; the medical imaging report, Art 14(1)(d), and the discharge report, Art 14(1)(f), written from a member’s composition as a BundleReportEuImaging and a bundle-eu-hdr and listed, the discharge report received at a [[fhir.receive]] member, and a member’s own imaging and discharge documents passed through the document list with only their attachment URL readdressed; a report’s Patient written from the demographics binding and its member named a Composition.author; the laboratory mapping carrying a result’s typed reference ranges in both directions; the patient summary through the EPS project’s root context, each entry of a repeated archetype listed as its own; the logging component on ehds-logging 0.8.0 with ihe-atna 0.8.0, the record of a national contact point’s access naming the professional by the assertion’s subject-id, the provider acted for and the relay | the clinical safety review rows of 2026-11-05 to 2026-11-09 and the claims review findings of 2026-11-09 |
| 0.0.16 | 2026-11-05 | the interoperability component as the published eehrxf 0.1.152 from FerroHEALTH/FerroBRIDGE with fhirconnect 0.1.219, openehr-mapping-core 0.1.141, ferrobridge-term 0.1.141 and ferrobridge-validator 0.1.144 on the openehr-* family at 0.0.128; the laboratory report, Art 14(1)(e), received at a [[fhir.receive]] member, written from a member’s composition as a Bundle-eu-lab and listed, and a member’s own laboratory reports passed through the document list with only their attachment URL readdressed; prescriptions served and dispensations written in the map mode from openEHR medication data through the MPD project; a received dispensation withdrawn; the patient summary’s medication entries selected by template section; the logging component unchanged at ehds-logging 0.3.2, each new read and list recorded under its category | the clinical safety review rows of 2026-11-04 and 2026-11-05 and the claims review findings of 2026-11-05 |
| 0.0.15 | 2026-10-30 | the logging component on ehds-logging 0.3.2 with ihe-atna 0.3.2, a routed openEHR read or write naming its object as the data entity’s what.identifier in place of a what.reference that was no FHIR literal reference; the interoperability component taken as the published eehrxf 0.1.128 from FerroHEALTH/FerroBRIDGE with fhirconnect 0.1.204, openehr-mapping-core 0.1.135, ferrobridge-term and ferrobridge-validator 0.1.141 and the openehr-* family at 0.0.109, a mapped resource taking the FHIRconnect deterministic id; a composition two members hold under one full version id listed once in the patient summary, the members whose copy it left out named; a member’s own refusal of a dispensation relayed after the gateway’s issues; the FHIR face’s refusals of the shared stack written as an OperationOutcome | the clinical safety reviews of 2026-10-28 and 2026-10-30 and the claims review findings of 2026-10-30 |
| 0.0.14 | 2026-10-28 | the logging component on ehds-logging 0.3.1 with ihe-atna 0.3.1 on ferrohealth-smart 0.8.6, each access record’s security labels written once on its entities and no longer in meta.security; the patient summary’s safety controls: a probable duplicate medicine marked, the medical alert and functional status written as unavailable, the medication order selected, a repeated entry left out and named; a received dispensation checked against the owner’s by its business identifier; the laboratory, discharge and imaging crosswalks and document assembly in eehrxf, not served on the FHIR face; ferrobridge-term and ferrobridge-validator 0.1.140 | the clinical safety review of 2026-10-25, the review row of 2026-10-28 and the claims review findings of 2026-10-28 |
| 0.0.13 | 2026-10-25 | the logging component on ehds-logging 0.2.6 with ihe-atna 0.2.6, each forwarder closing its syslog connection with a TLS close_notify and every audit trail stopped on the drain; a document list that asked no member recorded as an access; the translation of a code in no preferred system through the terminology server, asked through FerroBRIDGE’s ferrobridge-term 0.1.131; a received document validated in full by FerroBRIDGE’s ferrobridge-validator 0.1.131, every constraint it did not evaluate named; a read by locator id held to the consent pre-filter; a patient looked up by identifier on the FHIR face | the clinical safety review of 2026-10-25 and the claims review findings of 2026-10-25 |
| 0.0.12 | 2026-10-24 | the logging component taken from FerroAUDIT’s published ehds-logging 0.2.3 in place of the local crate, its record, sinks and spool unchanged; client authentication and the SMART on openEHR grants of the access path on ferrohealth-smart 0.4.0; the summary’s term-mapping rule, a mapping that is no equivalence written into the narrative and never as a coding; the bare # admitted from the Patient entry in the reference property; openehr-* 0.0.90 with fhirconnect 0.1.173 | the clinical safety review of 2026-10-24 and the claims review findings of 2026-10-24 |
| 0.0.11 | 2026-10-16 | the access record’s labels per category and origin, the patients a population query read, the version a write and the EHR a create left, insertions by the person or a representative, and the log of each semantic transformation; the prescription and dispensation datasets and their search, read, version read and receipt on the FHIR face; a person’s own patient summary; archetype-local codes and the preferred code systems; the category a received document takes from its template | the clinical safety review of 2026-10-16 and the claims review findings of 2026-10-16 |
| 0.0.10 | 2026-10-15 | first released: the logging component and the access log, the interoperability component with the patient summary on the FHIR face, the document list, the receipt of a document, the section queries and the terminology rules | the clinical safety review of 2026-10-15 and the claims review findings of 2026-10-15 |
Keeping it for ten years
Art 30(3) has the manufacturer “keep the technical documentation referred to in Article 37 and the EU declaration of conformity referred to in Article 39 for 10 years after the EHR system covered by the EU declaration of conformity has been placed on the market”. FerroFED keeps every release from 0.0.10 on this way, from the date of the release in the changelog.
The repository keeps most of it:
- The tag. The
release-tagsruleset blocks the deletion and the moving of av*tag, so the source, this book’s pages,docs/,conformance/andCHANGELOG.mdstay as they were at the release. - The release. GitHub’s immutable-releases setting freezes a published
release’s notes and assets, the information sheet and instructions for
use among them, and the lane never reopens one
(
docs/release.md, What is immutable). - The images, by digest in the GitHub Container Registry, with their attestations. A withdrawal moves tags and deletes no image.
Two parts are not covered by those settings. The CI run of the release commit holds the conformance report and the FHIR validation results as workflow artifacts, which GitHub deletes when the repository’s artifact retention period ends. And no setting of the repository guarantees that GitHub serves it for ten years. So at each release Cadasto B.V. keeps an archive copy outside GitHub, for ten years from the release date:
git bundle create ferrofed-vX.Y.Z.bundle vX.Y.Z
gh release download vX.Y.Z --repo FerroHEALTH/FerroFED --dir ferrofed-vX.Y.Z
gh run list --repo FerroHEALTH/FerroFED --workflow ci.yml \
--commit "$(git rev-list -n1 vX.Y.Z)" --event push
gh run download <run id> --repo FerroHEALTH/FerroFED \
--name conformance-report --name eehrxf-fhir-validation --dir ferrofed-vX.Y.Z/ci
The release download also holds the release’s EU declaration of conformity when the release carries one. Where Cadasto B.V. publishes the archived declaration again, if the repository stops serving a release within those ten years, is the manufacturer’s decision and is still open (Decisions open to the manufacturer).
Art 30(3) also asks the manufacturer to make the source code available to the relevant authorities “upon a reasoned request”. The source of every release is public at its tag, and the archive keeps it.
The EU declaration of conformity
Art 39(4): “Where an EU declaration of conformity is drawn up in a digital format, it shall be made accessible online for the expected lifetime of the EHR system and, in any event, for at least 10 years from the placing on the market or the putting into service of the EHR system.”
Art 39(1) has the declaration “state that the manufacturer of an EHR system has demonstrated that the essential requirements laid down in Annex II have been fulfilled”, and Art 39(3) has it “contain the information set out in Annex IV”. FerroFED’s declaration is drawn up per release, as a Markdown document attached to that release beside the information sheet:
https://github.com/FerroHEALTH/FerroFED/releases/download/vX.Y.Z/ferrofed-vX.Y.Z-eu-declaration-of-conformity.md
The immutable-releases setting keeps each attached declaration unchanged and online, and the archive copy of Keeping it for ten years holds it too. Annex III, point 6, has its copy there.
scripts/release/declaration-of-conformity.sh writes it at the release’s
tag, with every item of Annex IV in its order. The release lane’s plan
job writes it before the release exists, github-release attaches it, and
finalize-release then publishes no release that misses it
(docs/release.md,
Before the tag, step 7). Each item comes from a record of the repository at
the tag, or from the manufacturer:
| Annex IV | What it asks | Where the value comes from |
|---|---|---|
| 1 | “The name of the EHR system, version and any additional unambiguous reference allowing identification of the EHR system” | the name and version of CITATION.cff, held to docs/VERSIONS.md; the tag and its commit; the versions of the two harmonised components, ehds-logging and eehrxf |
| 2 | “Name and address of the manufacturer or, where applicable, its authorised representative” | the manufacturer the running gateway names (Complaints and incidents); the authorised representative row of the information sheet |
| 3 | “A statement that the EU declaration of conformity is issued under the sole responsibility of the manufacturer” | the statement, as Annex IV words it |
| 4 | the statement of conformity with Chapter III and other relevant Union law, “complemented by the result from the testing environment mentioned in Article 40” | the statement; the other Union acts (Art 39(2)) and the testing environment’s result from the manufacturer |
| 5 | “References to any relevant harmonised standards used and in relation to which conformity is declared” | the manufacturer |
| 6 | “References to any common specifications used and in relation to which conformity is declared” | the Annex III, point 3 row of the index above: none is adopted yet |
| 7 | “Place and date of issue of the declaration, signature plus name and function of the person who signed and, if applicable, an indication of the person on whose behalf it was signed” | the date of the release in CHANGELOG.md; the place, the signatory, the signature and on whose behalf from the manufacturer |
| 8 | “Where applicable, additional information” | the technical documentation at the tag, the attached information sheet and instructions for use, and the standards table of the information sheet |
The manufacturer supplies its values in
conformance/eu-declaration-of-conformity.toml.
It also says whether the manufacturer issues a declaration at all. While it
says it does not, a release carries none and the release lane warns so.
Once it says it does, every value must be filled: the script refuses a
declaration that still holds an item marked for the manufacturer, and the
plan job then fails before the release exists, so no release carries a
declaration with a blank.
No EU declaration of conformity has been issued yet, and no release carries
one: the manufacturer’s file says issue = false and its values are empty.
A draft the script writes with --draft opens by saying that it is not
issued and claims no conformity: no conformity assessment against Chapter III
has been carried out, and the Article 40 testing environment does not exist
yet (#675). Point 4 of a
draft is the wording Annex IV has an issued declaration use, and a draft
makes no such statement.
Art 39(6) has the Commission “publish a standard uniform template for the
EU declaration of conformity and make it available in a digital format in
all official languages of the Union”. The Commission has not published it
yet, so the generator writes the items in the order of Annex IV.
scripts/checks/ehds-acts.sh watches EUR-Lex for an act adopted on
Art 39(6).
Decisions open to the manufacturer
-
Issuing the declaration. Art 39(5): “By drawing up the EU declaration of conformity, the manufacturer shall assume responsibility for the compliance of the harmonised software components of the EHR system with the requirements laid down in this Regulation when it is placed on the market or put into service.” Only Cadasto B.V. sets
issue = truein its file, and no step of the release lane sets it. -
Where the declaration is published again. Art 39(4) asks for the declaration to stay “accessible online for the expected lifetime of the EHR system and, in any event, for at least 10 years”. The releases page serves it while the repository does. Where Cadasto B.V. publishes the archived declaration if the repository stops serving a release within those ten years is not decided yet (#940).
-
The values of the manufacturer’s file, listed above.
-
The languages. Art 39(3) has the declaration “translated into one or more official Union languages determined by the Member States in which the EHR system is made available”. The declaration is drawn up in English; which translations a Member State asks for is not recorded yet.
The European digital testing environment
Art 40(1) has the Commission develop “a European digital testing environment for the assessment of harmonised software components of EHR systems”. FerroFED has not run it yet; #675 plans the run, and its results are referenced here when it is done.
Language and translation on request
Art 37(3): the technical documentation “shall be drawn up in an official language of the Member State concerned or a language that is easily understandable in that Member State”, and “following a reasoned request from the market surveillance authority of a Member State, the manufacturer shall provide a translation of the relevant parts of the technical documentation into an official language of that Member State”.
FerroFED’s technical documentation is written in English. A market surveillance authority that needs it, or a translation of parts of it, sends its reasoned request to the single point of contact on the Complaints and incidents page, naming the release, the parts and the language. Art 37(4) sets the deadline: Cadasto B.V. provides the documentation or the translation “within 30 days of the date of the request, unless a shorter deadline is justified because of a serious and immediate risk”, and then within the deadline the request sets. Cadasto B.V. records the date of each request and of its answer, so the deadline can be shown to have been met. The translation is of the documentation at the tag of the release the request names.
Conformity record
Art 26(1) of Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation): “EHR systems shall be placed on the market or put into service only if they comply with the provisions laid down in this Chapter”. This page is FerroFED’s record of where it stands against each obligation of Chapter III that Articles 25 to 30, 36 to 41 and 49 place on the manufacturer, and against each item of the EU declaration of conformity that Annex IV lists. For every one it names the evidence in this repository, the issue that closes any gap, and the date Art 105 applies it from.
This record is no EU declaration of conformity and states no conformity. No declaration has been issued for any release, no CE marking has been affixed, FerroFED is not registered in the EU database, and no assessment by an external body stands behind this record. A row marked met says that the repository holds what the provision asks for what the release does. Issuing a declaration, affixing the marking and registering are legal acts of the manufacturer, Cadasto B.V., and What the manufacturer must decide or do lists them.
Two other records sit beside this one. The Annex II
checklist holds each essential requirement, and the Annex III
index each element of the
technical documentation; a row here points at them where a provision rests on
them. The record is kept as
conformance/chapter-iii.tsv,
and scripts/checks/chapter-iii.sh fails a change that quotes a phrase the
provision’s own text in the vendored Regulation does not hold, names a page
heading or a test that does not exist, leaves a gap without its issue, gives
a row a date Art 105 does not, or lets the tables below differ from the
file. CI runs it on every change.
Every quotation of the Regulation is from the Official Journal text,
vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
This page is not legal advice.
The dates it applies from
Art 105 sets them. The Regulation “shall apply from 26 March 2027”. “Articles 25, 26, 27, 47, 48 and 49 shall apply as follows”: “from 26 March 2029” to EHR systems intended by the manufacturer to process the categories of Art 14(1), points (a), (b) and (c), and “from 26 March 2031” to those intended to process points (d), (e) and (f). “Chapter III shall apply to EHR systems put into service in the Union referred to in Article 26(2) from 26 March 2031.” The implementing acts of “Article 36(1) shall apply from the dates referred to in the third paragraph” of Art 105, by category in the same way.
FerroFED’s intended purpose covers all six categories of Art 14(1), so both category dates apply to it: 26 March 2029 for patient summaries, prescriptions and dispensations, and 26 March 2031 for medical test results, discharge reports and imaging. The column “Applies from” gives each row its date by that reading. Every Article Art 105 does not name applies from 26 March 2027, but each one attaches to an EHR system placed on the market or put into service, which Art 26(1) governs from the category dates; the regulatory status page says why counsel should confirm that reading.
How to read it
| Status | Meaning |
|---|---|
| met | the repository holds the record, the procedure or the code the provision asks for, for what this release does |
| open | work is still to be done in this repository or a sibling’s; the issue holds it |
| awaits-act | the provision rests on an act or a facility of the Commission that does not exist yet: an implementing act, a delegated act, the common specifications, the testing environment, the declaration template or the EU database |
| manufacturer | only Cadasto B.V. can do it, as a decision or a legal act; the repository has prepared what it can |
| not-applicable | the provision does not bind FerroFED; the note says why |
A provision with more than one phrase to answer has one row per phrase. The second table is the evidence index of the EU declaration of conformity: for each item of Annex IV, where the declaration’s generator takes the value from (The EU declaration of conformity).
The record
| Status | Rows |
|---|---|
| met | 29 |
| open | 7 |
| awaits-act | 8 |
| manufacturer | 14 |
| not-applicable | 4 |
| all | 62 |
The obligations
| Provision | Requirement | Applies from | Status | Evidence | Issue | Note |
|---|---|---|---|---|---|---|
| Art 25(1) | “EHR systems shall include a European interoperability software component for EHR systems and a European logging software component for EHR systems” | 26 March 2029 for (a) to (c), 26 March 2031 for (d) to (f) | open | Changes to the harmonised components; app/ferrofed-engine/tests/it/architecture.rs (the_two_harmonised_components_are_independent_of_each_other); What is missing | #1794 | Both components ship in every release from 0.0.10. The interoperability component serves patient summaries, prescriptions, dispensations and laboratory, discharge and imaging reports, and receives imaging reports too; the Annex II checklist lists what else is missing. |
| Art 25(2) | “This Chapter shall not apply to general purpose software used in a healthcare environment” | 26 March 2029 for (a) to (c), 26 March 2031 for (d) to (f) | not-applicable | The general-purpose exclusion | - | The exclusion does not cover FerroFED: it is classified as an EHR system, so Chapter III binds it. |
| Art 26(1) | “EHR systems shall be placed on the market or put into service only if they comply with the provisions laid down in this Chapter” | 26 March 2029 for (a) to (c), 26 March 2031 for (d) to (f) | open | The obligations and their dates; docs/release.md, Before the tag | #525 | The condition of every other row: before a release is placed on the market or put into service as an EHR system, no row of this record may stay open or wait on an act or on the manufacturer. |
| Art 26(2) | “shall be considered as having been put into service” | 26 March 2031 | not-applicable | A deployment a health institution runs for itself | - | A definition that makes a health institution that builds FerroFED for itself, or an operator that hosts it as a service, the one putting it into service; Art 105 applies Chapter III to such systems from 26 March 2031. |
| Art 28(a) | “ascribing functions and properties to the EHR system which it does not have” | 26 March 2027 | met | How a review is done; The record; docs/release.md, Before the tag | - | Every release’s public texts are reviewed against Art 28 before the tag, and the findings are recorded. |
| Art 28(b) | “failing to inform the professional user of likely limitations related to interoperability or security features” | 26 March 2027 | met | Limitations; Limitations; The record | - | - |
| Art 28(c) | “suggesting uses for the EHR system other than those stated to form part of the intended purpose in the technical documentation” | 26 March 2027 | met | Intended purpose; Not claimed; The record | - | - |
| Art 30(1)(a) | “are in conformity with the essential requirements laid down in Annex II and with the common specifications in accordance with Article 36” | 26 March 2027 | open | What is missing; conformance/annex-ii.tsv | #1405, #1406, #675, #799 | Points of Annex II are still partial, each with its issue on the Annex II checklist, and the Art 36(1) common specifications are not adopted (Art 36(1) row). |
| Art 30(1)(b) | “are not adversely affected by other software components of the same EHR system” | 26 March 2027 | met | Shared hazards; app/ferrofed-engine/tests/it/architecture.rs (no_crate_but_the_composition_root_links_both_components); app/ferrofed-server/tests/it/fhir/independence.rs (a_summary_is_recorded_as_the_same_query_over_its_rest_is); app/ferrofed-server/tests/it/fhir/independence.rs (the_summary_is_the_same_document_whatever_the_access_log_sink_does) | - | Hazard X-01 of the risk file, controlled at the crate and the server level. |
| Art 30(1)(c) | “draw up the technical documentation of their EHR systems in accordance with Article 37 before placing those EHR systems on the market” | 26 March 2027 | open | The Annex III index; conformance/technical-documentation.tsv; scripts/checks/technical-documentation.sh | #525, #675, #940 | The Annex III index is drawn up and held by CI; its items 3, 4 and 6 and the Art 40 result are not complete (the Art 36(1), Art 37(2) and Art 40(3) rows). |
| Art 30(1)(d) | “accompanied, free of charge for the user, by the information sheet provided for in Article 38 and clear and complete instructions for use” | 26 March 2027 | met | How the sheet is kept; Instructions for use | - | Both documents are attached to every release from 0.0.10. |
| Art 30(1)(e) | “draw up the EU declaration of conformity in accordance with Article 39” | 26 March 2027 | manufacturer | The EU declaration of conformity; scripts/release/declaration-of-conformity.sh; conformance/eu-declaration-of-conformity.toml | #940, #675 | The generator and its release-lane wiring exist; no declaration is issued (issue = false). Issuing one is Cadasto B.V.’s act, and Annex IV item 4 needs the Art 40 result. |
| Art 30(1)(f) | “affix the CE marking of conformity in accordance with Article 41” | 26 March 2027 | manufacturer | docs/release.md, Before the tag | #940 | Written as step 8 of the release process. No release carries the marking, and it follows an issued declaration. |
| Art 30(1)(g) | “indicate the name, registered trade name or registered trade mark, the postal address, and the website, email address or other digital contact details” | 26 March 2027 | met | The manufacturer; (a) The manufacturer; app/ferrofed-server/tests/it/options.rs (the_manufacturer_is_named_beside_federation_and_endpoints); app/ferrofed-operator-api/tests/it/manufacturer.rs (the_manufacturer_is_the_licensor_with_its_address_and_contact) | - | The running gateway names the manufacturer and its single point of contact. |
| Art 30(1)(h) | “comply with the registration obligations in Article 49” | 26 March 2027 | awaits-act | docs/release.md, Before the tag | #525 | See the Art 49(2) row. |
| Art 30(1)(i) | “take without undue delay any necessary corrective action in respect of their EHR systems” | 26 March 2027 | met | Corrective action, recall and withdrawal; docs/post-market.md, A non-conforming version; scripts/release/withdraw.sh | - | A written procedure with its withdrawal script; it runs when a non-conformity is found. |
| Art 30(1)(j) | “inform the distributors of their EHR systems and, where applicable, the authorised representative, importers and users of the non-conformity” | 26 March 2027 | met | docs/post-market.md, A non-conforming version; docs/post-market.md, The register of economic operators | - | - |
| Art 30(1)(k) | “of any mandatory preventive maintenance of the EHR systems and its frequency” | 26 March 2027 | met | Maintenance and its frequency | - | - |
| Art 30(1)(l) | “provide, in an official language of the Member State concerned, market surveillance authorities in that Member State with all the information and documentation necessary” | 26 March 2027 | met | Cooperation with the authorities; docs/post-market.md, A request from an authority; Language and translation on request | - | A written procedure; the documentation is in English and translated on request. |
| Art 30(1)(m) | “cooperate with market surveillance authorities, at their request” | 26 March 2027 | met | Cooperation with the authorities; docs/post-market.md, A request from an authority | - | - |
| Art 30(1)(n) | “establish channels of complaint and keep distributors informed thereof” | 26 March 2027 | met | Making a complaint; docs/post-market.md, The channels | - | - |
| Art 30(1)(o) | “keep a register of complaints and a register of non-conforming EHR systems” | 26 March 2027 | met | The registers; docs/registers/complaints.tsv; docs/registers/non-conforming-versions.tsv | - | - |
| Art 30(2) | “ensure that procedures are in place to ensure that the design, development and deployment of the harmonised software components” | 26 March 2027 | met | Changes to the harmonised components; Review and change; scripts/checks/technical-documentation.sh | - | A release that changes a harmonised component is held until its clinical safety review and its change row exist. Checking against the Art 36 common specifications waits on their adoption (Art 36(1) row). |
| Art 30(3) | “keep the technical documentation referred to in Article 37 and the EU declaration of conformity referred to in Article 39 for 10 years” | 26 March 2027 | manufacturer | Keeping it for ten years; docs/release.md, After the tag | #525 | The tag ruleset and immutable releases keep the repository’s part. The archive copy outside GitHub at each release is Cadasto B.V.’s to make and keep, and this repository holds no record that it was made. |
| Art 30(3) | “make available the source code or the programming logic included in the technical documentation, upon a reasoned request” | 26 March 2027 | met | Keeping it for ten years | - | The source of every release is public at its tag. |
| Art 30(4) | “A manufacturer of EHR systems established outside the Union” | 26 March 2027 | not-applicable | (a) The manufacturer | - | Cadasto B.V. is established in the Netherlands, in the Union. |
| Art 30(5) | “upon a reasoned request from a market surveillance authority, provide it with all the information and documentation” | 26 March 2027 | met | Cooperation with the authorities; docs/post-market.md, A request from an authority | - | - |
| Art 36(1) | “adopt common specifications in respect of the essential requirements laid down in Annex II” | 26 March 2029 for (a) to (c), 26 March 2031 for (d) to (f) | awaits-act | The obligations and their dates; scripts/checks/ehds-acts.sh | #799 | Not adopted; the Commission’s initiative 18579 was at the planning stage when ehds-acts last read it. When it is adopted, Annex II is checked again against it, and Annex III item 3 and Annex IV item 6 name it. |
| Art 37(1) | “draw up technical documentation before the EHR system is placed on the market or put into service, and shall keep that documentation up to date” | 26 March 2027 | met | The Annex III index; Changes to the harmonised components; scripts/checks/technical-documentation.sh | - | Drawn up and kept up to date by CI and the release gate; its content is incomplete (Art 37(2) rows). |
| Art 37(2) | “shall demonstrate that the EHR system complies with the essential requirements laid down in Annex II” | 26 March 2027 | open | The Annex III index; What is missing | #1405, #1406, #799 | Annex III item 4 stays partial while an Annex II point is partial. |
| Art 37(2) | “a reference to the results obtained from a European digital testing environment referred to in Article 40” | 26 March 2027 | awaits-act | The European digital testing environment | #675 | See the Art 40(3) row. |
| Art 37(3) | “drawn up in an official language of the Member State concerned or a language that is easily understandable in that Member State” | 26 March 2027 | met | Language and translation on request | - | Written in English, with a translation of the relevant parts on a reasoned request. |
| Art 37(4) | “within 30 days of the date of the request” | 26 March 2027 | met | Language and translation on request | - | A written procedure with the deadline and a record of each request. |
| Art 38(1) | “EHR systems shall be accompanied by an information sheet” | 26 March 2027 | met | How the sheet is kept | - | - |
| Art 38(2)(a) | “the identity, registered trade name or registered trademark, and contact details of the manufacturer” | 26 March 2027 | met | (a) The manufacturer | - | - |
| Art 38(2)(b) | “the name and version of the EHR system and date of its release” | 26 March 2027 | met | (b) Name, version and date of release | - | - |
| Art 38(2)(c) | “the intended purpose of the EHR system” | 26 March 2027 | met | (c) Intended purpose | - | - |
| Art 38(2)(d) | “the categories of electronic health data that the EHR system has been designed to process” | 26 March 2027 | met | (d) The categories of data | - | - |
| Art 38(2)(e) | “the standards, formats and specifications supported by the EHR system and versions of those standards, formats and specifications” | 26 March 2027 | met | (e) Standards, formats and specifications | - | - |
| Art 38(3) | “enter the information referred to in paragraph 2 of this Article into the EU database” | 26 March 2027 | not-applicable | Information sheet | - | An alternative FerroFED does not use: the sheet is attached to every release. |
| Art 39(1) | “shall state that the manufacturer of an EHR system has demonstrated that the essential requirements laid down in Annex II have been fulfilled” | 26 March 2027 | open | What is missing; The EU declaration of conformity | #1405, #1406, #675, #799 | A declaration cannot state this while an Annex II point is partial. |
| Art 39(2) | “a single EU declaration of conformity shall be drawn up in respect of all Union legal acts applicable to the EHR system” | 26 March 2027 | manufacturer | conformance/eu-declaration-of-conformity.toml; The Cyber Resilience Act | #940 | Which other Union acts the declaration covers (other_union_acts) is Cadasto B.V.’s to state; Regulation (EU) 2024/2847 is the one the repository already treats as applying. |
| Art 39(3) | “shall contain the information set out in Annex IV” | 26 March 2027 | manufacturer | scripts/release/declaration-of-conformity.sh; The EU declaration of conformity | #940 | The generator writes every Annex IV item in its order (the Annex IV rows); the values of items 4, 5 and 7 are the manufacturer’s. |
| Art 39(3) | “translated into one or more official Union languages determined by the Member States” | 26 March 2027 | manufacturer | Decisions open to the manufacturer | #940 | The declaration is drawn up in English; the translations are not decided. |
| Art 39(4) | “made accessible online for the expected lifetime of the EHR system” | 26 March 2027 | manufacturer | The EU declaration of conformity; Decisions open to the manufacturer | #940 | The releases page serves an attached declaration while the repository does; where it is published again if that stops within ten years is not decided. |
| Art 39(5) | “the manufacturer shall assume responsibility for the compliance of the harmonised software components” | 26 March 2027 | manufacturer | Decisions open to the manufacturer; conformance/eu-declaration-of-conformity.toml | #940 | Only Cadasto B.V. sets issue = true; no release step sets it. |
| Art 39(6) | “The Commission shall publish a standard uniform template for the EU declaration of conformity” | 26 March 2027 | awaits-act | The EU declaration of conformity; scripts/checks/ehds-acts.sh | #940 | Not published; the generator keeps the order of Annex IV, and ehds-acts watches Art 39(6). |
| Art 40(3) | “manufacturers shall use the digital testing environments referred to in paragraphs 1 and 2 of this Article” | 26 March 2027 | awaits-act | The European digital testing environment; scripts/checks/ehds-acts.sh | #675 | No testing environment exists to use: the Art 40(4) implementing act is not adopted, and the Commission’s initiative 17513 was at the planning stage when ehds-acts last read it. |
| Art 41(1) | “affixed visibly, legibly and indelibly to the accompanying documents of the EHR system” | 26 March 2027 | manufacturer | docs/release.md, Before the tag | #940 | Planned on the information sheet, the instructions for use and the declaration, once a declaration is issued. |
| Art 41(2) | “affixed before placing the EHR system on the market” | 26 March 2027 | manufacturer | docs/release.md, Before the tag | #940 | No release has been placed on the market with the marking. |
| Art 41(3) | “subject to the general principles set out in Article 30 of Regulation (EC) No 765/2008” | 26 March 2027 | manufacturer | docs/release.md, Before the tag | #940 | Regulation (EC) No 765/2008 is not vendored here, so this record does not restate its Art 30; the release process cites its Art 30(1). |
| Art 49(2) | “enter the required data as referred to in paragraph 4 of this Article into the EU database” | 26 March 2029 for (a) to (c), 26 March 2031 for (d) to (f) | awaits-act | docs/release.md, Before the tag; scripts/checks/ehds-acts.sh | #525 | The Commission has not established the database (Art 49(1)) and the Art 49(4) delegated act listing the data is not adopted; ehds-acts watches Art 49(4). The entry is then Cadasto B.V.’s, with the Art 40 result. |
The declaration item by item
| Provision | Requirement | Applies from | Status | Evidence | Issue | Note |
|---|---|---|---|---|---|---|
| Annex IV 1 | “The name of the EHR system, version and any additional unambiguous reference allowing identification of the EHR system” | 26 March 2027 | met | scripts/release/declaration-of-conformity.sh; The EU declaration of conformity | - | Read from CITATION.cff, the tag, its commit and the pins of the two components. |
| Annex IV 2 | “Name and address of the manufacturer or, where applicable, its authorised representative” | 26 March 2027 | met | scripts/release/declaration-of-conformity.sh; (a) The manufacturer | - | - |
| Annex IV 3 | “A statement that the EU declaration of conformity is issued under the sole responsibility of the manufacturer” | 26 March 2027 | met | scripts/release/declaration-of-conformity.sh | - | The generator writes the statement as Annex IV words it. |
| Annex IV 4 | “A statement that the EHR system in question is in conformity with the provisions laid down in Chapter III” | 26 March 2027 | open | scripts/release/declaration-of-conformity.sh; The EU declaration of conformity | #525 | The statement can be made only once no row of this record is open or waiting; a draft makes no such statement. |
| Annex IV 4 | “with any other relevant Union law that provides for the issuing of an EU declaration of conformity” | 26 March 2027 | manufacturer | conformance/eu-declaration-of-conformity.toml | #940 | The value other_union_acts (Art 39(2) row). |
| Annex IV 4 | “complemented by the result from the testing environment mentioned in Article 40” | 26 March 2027 | awaits-act | conformance/eu-declaration-of-conformity.toml; The European digital testing environment | #675 | The value testing_environment_result (Art 40(3) row). |
| Annex IV 5 | “References to any relevant harmonised standards used and in relation to which conformity is declared” | 26 March 2027 | manufacturer | conformance/eu-declaration-of-conformity.toml | #940 | The value harmonised_standards; this record names none. |
| Annex IV 6 | “References to any common specifications used and in relation to which conformity is declared” | 26 March 2027 | awaits-act | scripts/release/declaration-of-conformity.sh; The Annex III index | #799 | None is adopted (Art 36(1) row). |
| Annex IV 7 | “Place and date of issue of the declaration, signature plus name and function of the person who signed” | 26 March 2027 | manufacturer | conformance/eu-declaration-of-conformity.toml; scripts/release/declaration-of-conformity.sh | #940 | The date is the release date in CHANGELOG.md; the place, the signatory, the signature and on whose behalf are the manufacturer’s. |
| Annex IV 8 | “Where applicable, additional information” | 26 March 2027 | met | scripts/release/declaration-of-conformity.sh | - | The technical documentation at the tag, the attached information sheet and instructions for use, and the standards table. |
What the manufacturer must decide or do
Each row marked manufacturer above is one of these, and the repository cannot complete any of them:
- Art 30(1)(e), Art 39(5) and Annex IV 7: issue the declaration. Set
issue = trueinconformance/eu-declaration-of-conformity.tomlonly once no row of this record is open or awaiting an act, and fill the place of issue, the signatory’s name and function, the signature and on whose behalf it is signed. By drawing it up the manufacturer “shall assume responsibility” (Art 39(5)). - Art 39(2) and Annex IV 4: name the other Union acts. Decide which
other acts that provide for a declaration the single declaration covers
(
other_union_acts). The repository already treats Regulation (EU) 2024/2847, the Cyber Resilience Act, as applying to FerroFED (The Cyber Resilience Act). - Annex IV 5: name the harmonised standards, or state that none is used
(
harmonised_standards). - Art 39(3): fill the Annex IV values and choose the translations. Decide into which official Union languages the declaration is translated, for the Member States where FerroFED is made available.
- Art 39(4): choose where the declaration stays online for at least ten years if the repository stops serving a release.
- Art 30(1)(f), Art 41(1), Art 41(2) and Art 41(3): affix the CE
marking on the accompanying documents before a release is placed on the
market, after its declaration is issued, as step 8 of
docs/release.mddescribes. Art 41(3) applies the general principles of Art 30 of Regulation (EC) No 765/2008, which is not vendored here; have it read before the first marking. - Art 30(3): keep the archive copy of each release outside GitHub for ten years, as Keeping it for ten years describes, and keep a record that it was made.
- Art 49(2): register in the EU database once the Commission establishes it and the Art 49(4) delegated act lists the data, with the result of the testing environment.
- Confirm the reading of the dates. The application of Articles 28, 30 and 35 to 41 from 26 March 2027, against Art 26(1) from the category dates, is a reading counsel should confirm.
What waits on the Commission
These acts and facilities do not exist yet, and the rows that rest on them
stay awaits-act until they do. The weekly ehds-acts job of
pin-freshness.yml (scripts/checks/ehds-acts.sh) reads EUR-Lex and the
Commission’s “Have your say” register and files an issue when one is
adopted.
- The common specifications for the essential requirements (Art 36(1)), due “By 26 March 2027”: Annex II is checked again against them, and Annex III item 3 and Annex IV item 6 name them (#799).
- The common specifications for the European digital testing environment (Art 40(4)), and the environment itself (Art 40(1)): the result Art 37(2), Art 40(3), Art 49(2) and Annex IV item 4 ask for (#675).
- The standard uniform template for the declaration (Art 39(6)): until it is published, the generator writes the items in the order of Annex IV.
- The EU database and the list of data to enter (Art 49(1), Art 49(4)).
- The European electronic health record exchange format (Art 15(1)): the Annex II interoperability points are checked again against it (FerroHEALTH/FerroBRIDGE#1327).
Annex II checklist
Annex II of Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation) lays down the “Essential requirements for the harmonised software components of EHR systems”. Art 30(1)(a) has the manufacturer ensure that those components “are in conformity with the essential requirements laid down in Annex II”, and Art 37(2) has the technical documentation “demonstrate that the EHR system complies with the essential requirements laid down in Annex II”.
This page is FerroFED’s checklist of every point and sub-point of Annex II,
by the Regulation’s own numbering: whether FerroFED meets it, the page and
the tests that show it, and the issue that closes any gap. It is kept as
conformance/annex-ii.tsv,
and scripts/checks/annex-ii.sh fails a change that leaves a point without
a row, quotes a requirement that is not in that point’s text of the vendored
Regulation, names a page heading or a test that does not exist, or leaves a
gap without its issue. CI runs it on every change.
Every quotation of the Regulation is from the Official Journal text,
vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
This page is not legal advice.
How to read it
| Status | Meaning |
|---|---|
| met | the evidence shows the requirement met for what this release does |
| partial | part of the requirement is met; the note says what is missing and the issues close it |
| planned | nothing is built yet; the issues hold the work |
| not-applicable | the requirement does not bind FerroFED; the note says why |
The statuses are read against the Regulation’s text alone. The common specifications for the essential requirements (Art 36(1)) and the exchange format (Art 15(1)) are not adopted yet (#799, FerroHEALTH/FerroBRIDGE#1327), and each can add to what a point asks. When one is adopted, every point it touches is checked again, the access log against the common specifications among them (#799).
The technical documentation cites this checklist for Annex III, point 4 (Technical documentation). That point is marked documented only when every row below is met or not-applicable.
The access log’s own mapping of each 3.2 item to the element of the record that carries it is the Annex II 3.2 checklist of the access log page.
The tables and the notes below are rendered from the TSV by
scripts/conformance/annex-ii.sh --write, and the guard fails a page that
differs from it. Every issue a row names is open and tracks that row’s gap.
1. General requirements
| Point | Requirement | Evidence | Status | Issue |
|---|---|---|---|---|
| 1.1 | “shall achieve the performance intended by its manufacturer” | The logging component; The interoperability component; Overall residual risk; Review and change | partial | FerroHEALTH/FerroBRIDGE#1694, FerroHEALTH/FerroBRIDGE#1700, FerroHEALTH/FerroBRIDGE#1701 |
| 1.2 | “can be supplied and installed, taking into account the instructions and information provided by the manufacturer” | Installation; 9. Check, start and admit; scripts/checks/production-guide.sh; The compatibility policy | met | - |
| 1.3 | “its interoperability, safety and security features uphold the rights of natural persons” | Summary; Art 8: restricting access; Art 9: information on access; app/ferrofed-server/tests/it/consent_withheld/mod.rs (a_withheld_exclusion_reads_exactly_as_a_member_that_does_not_know_the_patient); app/ferrofed-server/tests/it/access/search.rs (the_persons_search_finds_every_access_to_their_data); app/ferrofed-server/tests/it/fhir/receive/write.rs (a_caller_confined_to_one_patient_writes_only_into_its_ehr); app/ferrofed-server/tests/it/access/emergency.rs (a_declared_emergency_purpose_marks_the_record); Art 5: inserting information; app/ferrofed-server/tests/it/fhir/receive/inserted.rs (a_persons_own_document_is_marked_as_inserted_by_them_with_no_identifier_added); app/ferrofed-server/tests/it/fhir/receive/laboratory_grant.rs (a_persons_own_grant_lists_and_reads_their_laboratory_report); app/ferrofed-server/tests/it/fhir/receive/laboratory_grant.rs (a_persons_own_grant_asking_for_another_patients_laboratory_reports_is_refused) | partial | #1405, #1406, FerroHEALTH/FerroBRIDGE#1325, FerroHEALTH/FerroBRIDGE#1326 |
| 1.4 | “interoperability and compatibility are reliable and secure” | B2: gateway to member node; scripts/checks/fhir-validator.sh; app/ferrofed-server/tests/it/fhir/summary.rs (the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node) | partial | #675, FerroHEALTH/FerroBRIDGE#1327 |
2. Requirements for interoperability
| Point | Requirement | Evidence | Status | Issue |
|---|---|---|---|---|
| 2.1 | “it shall provide an interface enabling access to the personal electronic health data processed by it in the European electronic health record exchange format” | Asking for a summary; The document list; app/ferrofed-server/tests/it/fhir/summary.rs (the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node); Laboratory reports in the list; app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_members_laboratory_composition_is_listed_and_read_as_a_laboratory_report); app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_served_laboratory_report_carries_the_normal_range_and_the_order) | partial | FerroHEALTH/FerroBRIDGE#1325, FerroHEALTH/FerroBRIDGE#1326, FerroHEALTH/FerroBRIDGE#1694, FerroHEALTH/FerroBRIDGE#1700, FerroHEALTH/FerroBRIDGE#1701 |
| 2.2 | “it shall be able to receive personal electronic health data in the European electronic health record exchange format” | Written to the declared member; The categories received; app/ferrofed-server/tests/it/fhir/receive/write.rs (a_document_is_written_to_its_member_by_its_ehr_id_alone); app/ferrofed-server/tests/it/fhir/receive/refused.rs (a_category_no_member_receives_is_refused_naming_it); Receiving a dispensation; app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchanged); app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_laboratory_report_is_written_to_its_member_by_its_ehr_id_alone); app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_received_normal_range_and_order_reach_the_members_composition); app/ferrofed-server/tests/it/fhir/receive/imaging_received.rs (an_imaging_report_is_written_to_its_member_by_its_ehr_id_alone) | partial | #1794 |
| 2.3 | “Where an EHR system is designed to provide access to personal electronic health data, it shall be able to receive” | Written to the declared member; The categories received; app/ferrofed-server/tests/it/fhir/receive/write.rs (a_document_is_written_to_its_member_by_its_ehr_id_alone); app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_laboratory_report_is_written_to_its_member_by_its_ehr_id_alone); app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_received_normal_range_and_order_reach_the_members_composition) | partial | FerroHEALTH/FerroBRIDGE#1325, FerroHEALTH/FerroBRIDGE#1326, FerroHEALTH/FerroBRIDGE#1694 |
| 2.4 | “shall enable the entry of data with sufficient granularity” | Intended purpose; Art 7(1): transmission without hindrance | not-applicable | - |
| 2.5 | “prohibit, restrict or place an undue burden on authorised access, personal electronic health data sharing or use of personal electronic health data for permitted purposes” | Annex II, point 2.5; app/ferrofed-server/tests/it/completeness.rs (partial_where_offered_is_a_200_with_the_answering_nodes_rows); app/ferrofed-server/tests/it/overload.rs (a_request_past_the_concurrency_limit_is_503_with_retry_after); app/ferrofed-server/tests/it/overload.rs (without_a_caller_rate_no_caller_is_limited) | met | - |
| 2.6 | “authorised exporting of personal electronic health data for the reasons of replacing the EHR system by another product” | What is exported and what is not; app/ferrofed-server/tests/it/identity_export.rs (the_export_holds_the_learned_creating_system_mappings_in_its_format); Reading the log; app/ferrofed-server/tests/it/access/search.rs (an_operator_reviews_by_ehr_id_endpoint_caller_and_outcome) | met | - |
3. Requirements for security and logging
| Point | Requirement | Evidence | Status | Issue |
|---|---|---|---|---|
| 3.1 | “shall provide reliable mechanisms for the identification and authentication of health professionals” | Professionals and assurance; app/ferrofed-server/tests/it/auth/token.rs (a_token_from_an_untrusted_issuer_is_401); app/ferrofed-server/tests/it/auth/professional.rs (a_token_below_the_least_level_is_401_and_reaches_no_node); app/ferrofed-server/tests/it/auth/professional.rs (a_client_token_naming_a_professional_is_refused_at_any_level) | met | - |
| 3.2 | “shall provide sufficient logging mechanisms that record at least the following information on every access event or group of events” | The access log; app/ferrofed-server/tests/it/access/routed.rs (every_access_whose_record_the_spool_cannot_take_is_refused_with_no_data); app/ferrofed-server/tests/it/access/gate.rs (a_patient_data_answer_that_reports_no_access_is_withheld); app/ferrofed-server/tests/it/access/query.rs (a_stored_query_execution_is_recorded_under_its_name); app/ferrofed-server/tests/it/fhir/summary.rs (a_summary_is_recorded_once_with_the_patient_summary_category); app/ferrofed-server/tests/it/fhir/receive/audit.rs (a_receipt_is_recorded_once_as_a_creation_of_its_category) | met | - |
| 3.2(a) | “identification of the healthcare provider or other individuals having accessed the personal electronic health data” | What a record names; app/ferrofed-server/tests/it/access/accessor.rs (a_person_is_recorded_with_the_professional_and_the_verified_level) | met | - |
| 3.2(b) | “identification of the specific natural person or persons having accessed the personal electronic health data” | The person behind an access; app/ferrofed-server/tests/it/access/accessor.rs (a_person_is_recorded_with_the_professional_and_the_verified_level) | met | - |
| 3.2(c) | “the categories of data accessed” | Categories; app/ferrofed-server/tests/it/access/query.rs (a_patient_query_names_the_caller_the_patient_the_origins_and_every_category); app/ferrofed-server/tests/it/access/query.rs (an_unmapped_template_is_unclassified_with_its_id_and_answered) | met | - |
| 3.2(d) | “the time and date of access” | What a record names; app/ferrofed-server/tests/it/access/retention.rs (with_no_retention_table_a_record_is_kept_three_years); app/ferrofed-server/tests/it/access/search.rs (the_period_bounds_a_search_and_is_required) | met | - |
| 3.2(e) | “the origin or origins of data” | What a record names; app/ferrofed-server/tests/it/access/origins.rs (each_origin_of_a_merged_answer_carries_the_categories_of_its_own_rows) | met | - |
| 3.3 | “shall include tools or mechanisms to review and analyse the log data, or it shall support the connection and use of external software for the same purposes” | Reading the log; app/ferrofed-server/tests/it/access/search.rs (the_persons_search_finds_every_access_to_their_data); app/ferrofed-server/tests/it/access/search.rs (an_operator_reviews_by_ehr_id_endpoint_caller_and_outcome); app/ferrofed-server/tests/it/access/search.rs (every_search_of_the_log_is_recorded) | met | - |
| 3.4 | “shall support different retention periods and access rights that take into account the origins and categories of electronic health data” | How long a record is kept; app/ferrofed-server/tests/it/access/retention.rs (a_category_kept_longer_sets_the_period); app/ferrofed-server/tests/it/access/retention.rs (an_origin_kept_longer_sets_the_period) | partial | #799 |
What is missing
- 1.1: The risk file leaves the repository’s part of hazard L-07 of the logging component, keeping each record for its period and limiting who reads it, at S3, P2 with the deployment, and I-16, I-21 and I-22 of the interoperability component open at S4, P3, so that component is not offered for its Annex II purpose.
- 1.3: Rights of natural persons holds each right of Chapter II to the components. Restriction (Art 8), the information on access (Art 9), the emergency mark (Art 11(5)), transmission without hindrance (Art 7(1)), information a person inserts, marked as theirs (Art 5), and the absence of a fee (Art 18) are met, and a person’s own grant gets their summary, and their prescriptions where members serve them (Art 3). A person’s own grant lists and reads their laboratory reports (Art 3; fhir/receive/laboratory_grant.rs, a_persons_own_grant_lists_and_reads_their_laboratory_report); imaging and discharge reports have no interface (Art 3); the cross-border route serves the requests a national contact point relays (Art 7(2)); and only patient summaries, laboratory reports and dispensations are received (Art 7(4)).
- 1.4: The patient summary is validated against the vendored HL7 Europe profiles, but the exchange format of Art 15(1) is not adopted and the European digital testing environment has not been run, so the interoperability of the component cannot yet be shown against either.
- 2.1: The FHIR face serves the patient summary, laboratory, discharge and imaging reports in the document list with their read, and prescriptions and dispensations from members that serve them in the HL7 Europe MPD profiles, with hazards I-03, I-18, I-19 and I-20 of the interoperability component closed at S4, P1.
- 2.2: A release receives patient summaries and laboratory, discharge and imaging reports, with hazards I-16, I-21 and I-22 open for laboratory, discharge and imaging reports, and dispensations, written to the member that owns their prescription; prescriptions are served, never received.
- 2.3: As for 2.2.
- 3.4: Each record states its retention period by its origins and categories and carries a security label per category and per origin; limiting access by them is the Audit Record Repository’s, and the Art 36(1) common specifications, not yet adopted, may add to the point.
Notes on the met and not-applicable points
- 2.4: FerroFED has no function for entering data: a write is composed in the client’s own application and forwarded to the member that controls the record as the client sent it, so the granularity of entry is that application’s.
- 2.5: Assessed for the features built; the assessment is repeated as the interoperability component grows.
- 2.6: The clinical data stays at the member CDRs; the gateway exports the routing state it learned, and the access records are exported from the Audit Record Repository.
- 3.1: The deployment’s issuer authenticates the professional; the gateway verifies its token and holds it to the assurance level the deployment sets.
- 3.2(a): The record’s wire form is tested by the ehds-logging test the_person_the_provider_and_the_client_are_named (FerroHEALTH/FerroAUDIT, at its exact pin in the root Cargo.toml).
- 3.2(b): The record names the professional as far as the issuer’s token states it. The record’s wire form is tested by the ehds-logging test the_persons_agent_carries_the_professional_the_level_and_who_acts (FerroHEALTH/FerroAUDIT, at its exact pin in the root Cargo.toml).
- 3.2(e): The record’s wire form is tested by the ehds-logging test the_categories_and_the_origins_ride_in_their_own_entities (FerroHEALTH/FerroAUDIT, at its exact pin in the root Cargo.toml).
- 3.3: Met by the second alternative: the records go to the deployment’s Audit Record Repository and are read there with ITI-81.
Rights of natural persons
Annex II, point 1.3 of Regulation (EU) 2025/327 asks that the harmonised software components of an EHR system ensure “its interoperability, safety and security features uphold the rights of natural persons”. Chapter II, Section 1 grants those rights for primary use. This page holds each right against FerroFED’s two harmonised components, the logging component (the access log) and the interoperability component (the patient summary over FHIR and receiving a document), and against the gateway around them where a right reaches it.
Most of these rights are exercised through a service FerroFED is not. The Member State’s electronic health data access service (Art 4) gives a person access, a download, insertion, rectification and the information on access. Its health professional access service (Art 12) and the healthcare provider transmit and restrict. FerroFED offers patients no access service of its own (Intended purpose). For each right the page says who acts, what the components do for it with the tests that show it, or the gap and the issue that closes it.
Every quotation is from the Official Journal text, vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
Tests are named by file under app/ferrofed-server/tests/it/ and function.
This page is not legal advice.
Summary
| Right | Who acts | FerroFED | Status |
|---|---|---|---|
| Art 3(1), access | the access service | the summary is served to a person’s own grant, confined to them, and so are their prescriptions where members serve them and their laboratory reports; imaging and discharge reports have no interface | partial |
| Art 3(2), a download in the exchange format | the access service | patient summaries only | partial |
| Art 4(2), a proxy or a legal representative | the proxy service | admits the token its issuer grants, records who acted, and marks what a declared representative inserts as theirs | not the gateway’s |
| Art 5, inserting information | the access service and the member CDR | a person’s grant writes only into its own EHR, marked as the person’s or as a representative’s the token names, and the record says so; the member keeps a person from changing a professional’s entry | met |
| Art 6, rectification | the access service and the controller | routes the controller’s correction | not the gateway’s |
| Art 7(1), transmission to another provider | the healthcare provider | adds no fee, limit or format | met |
| Art 7(2), cross-border transmission | the national contact point | serves the requests a national connector relays: patient summaries, and prescriptions and dispensations where members serve them | partial |
| Art 7(3), transmission to social security | the healthcare provider | outside the intended purpose | not the gateway’s |
| Art 7(4), sending a downloaded copy | the receiving provider | receives patient summaries and laboratory reports, and dispensations where members serve them | partial |
| Art 8, restricting access | the person, the consent service and the member | carries the restriction out and hides its fact | met |
| Art 9, information on access | the access service | records every access for at least three years and serves the search | met |
| Art 10, opting out | the Member State | an opt-out is carried out as a restriction is | not the gateway’s |
| Art 11(5), an emergency access logged for the person | the healthcare provider and the member | marks the record | met |
| Art 16(1), electronic identification | the access service | not an access service | not the gateway’s |
| Art 18, no fee | every provider and third party | charges a person nothing | met |
| Art 21, a complaint | the digital health authority | not a complaint body | not the gateway’s |
Art 3: access and a download
Art 3(1): “Natural persons shall have the right to access at least personal electronic health data relating to them that belong to the priority categories referred to in Article 14 … through the electronic health data access services referred to in Article 4.” Access “shall be provided immediately after the personal electronic health data have been registered in an EHR system”. Art 3(2) gives a right “to download free of charge an electronic copy … in the European electronic health record exchange format referred to in Article 15”.
Who acts. The access service of Art 4. Art 3(3) lets a Member State delay access, which is that service’s to apply.
What FerroFED does. The gateway holds no clinical data of its own and
reads each member at the time of the request, so what a member registered
is in the next answer (Intended purpose).
A token confined to one patient reads that patient’s EHR at every member
and nothing else (Patient grants;
auth/patient.rs: the_token_s_own_patient_is_admitted_at_every_member,
a_query_for_another_patient_is_refused). The interoperability component
serves the patient summary in the HL7 Europe format
(Asking for a summary;
fhir/summary.rs:
the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node).
A person’s own grant gets their summary, and only theirs: a grant confined
to one patient asking for another is refused before the identity service or
any member is asked, and the access record names the person as the caller
and as the data subject (fhir/patient_grant.rs:
a_persons_own_grant_gets_their_summary_and_no_identifier_reaches_a_node_or_a_log,
a_persons_own_grant_asking_for_another_patient_is_refused_before_anyone_is_asked,
the_access_record_names_the_person_as_caller_and_as_data_subject).
A person’s own grant also reads their prescriptions where the deployment
declares members that serve them, at those members alone; a prescription
id naming another patient’s ehr_id is refused before any member is
asked (Reading one prescription;
fhir/prescriptions/read.rs:
a_caller_confined_to_one_patient_reads_that_patients_prescription_alone).
A dispensation is read only by the id its receipt named.
A person’s own grant lists and reads their laboratory reports, and a grant
confined to one patient asking for another patient’s list is refused before
any member is asked
(Laboratory reports over FHIR;
fhir/receive/laboratory_grant.rs:
a_persons_own_grant_lists_and_reads_their_laboratory_report,
a_persons_own_grant_asking_for_another_patients_laboratory_reports_is_refused).
The document list also names discharge and imaging reports, from a
member’s composition or its own FHIR endpoint, read through the same
signed Binary/{token} reads
(Discharge reports over FHIR,
Imaging reports over FHIR).
Art 4(2): proxies and legal representatives
Art 4(2)(a) has proxy services enable “natural persons to authorise other natural persons of their choice to access their personal electronic health data”, and (b) lets “legal representatives of natural persons … access personal electronic health data of those natural persons whose affairs they administer”.
Who acts. The Member State’s proxy service, which decides the authorisation.
What FerroFED does. The gateway admits the token the issuer grants and
does not judge the authorisation behind it. The access record names the
natural person the token names, by its issuer and subject
(The person behind an access;
access/accessor.rs:
a_person_is_recorded_with_the_professional_and_the_verified_level). When
the proxy service’s token names the representative as its sub, the
record names the representative.
A proxy service may instead issue a token in the person’s name that names
the representative in its RFC 8693 act claim, the party the person
delegated to. The gateway reads that claim as a representative only when
the issuer declares it one (act_represents_patient on the patients
issuer, which ferrohealth-smart reads), never by inference
(Patient grants). A
document such a token inserts names the representative as each entry’s
provider, and its access record says a representative inserted it
(Art 5). The record’s caller is still the
token’s sub, the person; it names the representative only through that
mark, with no identifier of theirs.
Art 5: inserting information
Art 5: persons “shall have the right to insert information in the EHR of those natural persons through electronic health data access services or applications linked to those services”. The information “shall be clearly distinguishable as having been inserted by the natural person or by his or her representative”, and they “shall not be able to directly alter the electronic health data and related information inserted by health professionals”.
Who acts. The access service or an application linked to it writes; the member CDR stores the entry and knows who wrote each version.
What FerroFED does. The interoperability component takes a document and
creates a new composition at the declared member. It never updates or
deletes one, so it cannot alter a professional’s entry
(Written to the declared member;
fhir/receive/write.rs:
a_document_is_written_to_its_member_by_its_ehr_id_alone). A token
confined to one patient writes only into that patient’s EHR
(fhir/receive/write.rs:
a_caller_confined_to_one_patient_writes_only_into_its_ehr). Under a
patient grant, the caller token the gateway signs tells a node the patient
scope that covered the request, which a query test shows
(auth/patient.rs:
each_node_is_told_its_own_ehr_id_and_the_covering_patient_scope). Each
receipt is recorded with the caller who sent it
(fhir/receive/audit.rs:
a_receipt_is_recorded_once_as_a_creation_of_its_category).
A document the person sends under their own grant is written so that it
can be told from a professional’s: every entry names the record’s subject,
a PARTY_SELF with no identifier, as its provider, the RM attribute for
“the agent who provided the information”
(Inserted by the person;
fhir/receive/inserted.rs:
a_persons_own_document_is_marked_as_inserted_by_them_with_no_identifier_added),
while a professional’s document names no provider
(a_professionals_document_names_no_provider). The access record of the
person’s receipt carries the ehds-subject-inserted entity, and a
professional’s does not (fhir/receive/audit.rs:
a_receipt_a_person_inserted_is_recorded_as_inserted_by_them).
Art 5 asks that what a representative of Art 4(2) inserts be told apart
from what the person inserts. When the token’s issuer declares the RFC
8693 act claim of its patient tokens a representative and the token
carries one, every entry names that representative as its provider, a
PARTY_IDENTIFIED whose one identifier is the claim’s sub, never the
person’s PARTY_SELF
(Patient grants;
fhir/receive/representative.rs:
a_representatives_document_names_them_as_every_entrys_provider), and
the access record carries the ehds-representative-inserted entity in
place of ehds-subject-inserted (fhir/receive/audit.rs:
a_receipt_a_representative_inserted_is_recorded_as_theirs). A token that
names the representative by an identifier of the person is refused with
nothing sent
(a_representative_named_by_an_identifier_of_the_person_is_refused). The
gateway never infers a representative: a token whose issuer declares no
act claim is read as the person’s own, whatever it carries
(an_act_the_issuer_does_not_declare_leaves_the_persons_own_mark), so a
proxy service whose tokens do not name the representative leaves their
insertions marked as the person’s.
The routed ITS-REST writes are outside the harmonised components. Under a patient grant whose scopes cover an update or a delete, the gateway sends it to the member as for any caller, with the body the client composed, and the member, which records who committed each version and is told that a person acts, decides whether the person may change it (Patient grants). Keeping a person from altering a professional’s entry over ITS-REST is the member’s.
Art 6: rectification
Art 6: access services “shall enable natural persons to easily request online the rectification of their personal electronic health data in accordance with Article 16 of Regulation (EU) 2016/679. Where appropriate, the controller shall verify with a relevant health professional the accuracy of the information provided in the request.”
Who acts. The access service takes the request and the controller, the healthcare provider whose CDR holds the record, corrects it.
What FerroFED does. A correction is a write by the controller, which
the gateway routes to the member that controls the record and records with
the version it left
(What a record names;
access/written.rs:
a_composition_update_records_the_new_version_never_the_one_it_amends).
Art 7: portability
Art 7(1): persons may “give access to, or to request a healthcare provider to transmit, all or part of their personal electronic health data to another healthcare provider of their choice immediately, free of charge and without hindrance from the healthcare provider or from the manufacturers of the systems used by that healthcare provider”.
Who acts. The healthcare provider transmits; the manufacturer must not hinder it.
What FerroFED does. A record stays at the member that holds it, and the
gateway adds no fee, no export limit and no format of its own
(Art 7(1): transmission without hindrance).
The routing state it learned is exported for a replacement product
(What is exported and what is not;
identity_export.rs:
the_export_holds_the_learned_creating_system_mappings_in_its_format).
Art 7(2): where the providers are in different Member States, persons may “request the transmission of their personal electronic health data in the European electronic health record exchange format … through the cross-border infrastructure referred to in Article 23”.
Who acts. The national contact point for digital health. FerroFED does
not reach another Member State itself. It serves a request a contact point
relays, and the record names the professional of the other Member State
(A request a national contact point relays;
access/relayed.rs:
the_record_names_the_relayed_professional_and_provider_as_asserted).
It answers a contact point’s request for a patient’s documents on the
FHIR face: the patient summary, the document list and the prescriptions,
and a dispensation the contact point posts
(A national contact point,
Cross-border access).
Art 7(3): persons may request “a healthcare provider to transmit a part of their personal electronic health data to a clearly identified recipient in the social security or reimbursement services sector”.
Who acts. The healthcare provider. A social security recipient is outside FerroFED’s users, which are healthcare providers in patient care (Intended purpose).
Art 7(4): persons who downloaded a copy “shall be able to transmit those data to healthcare providers of their choice in the European electronic health record exchange format … The receiving healthcare provider shall accept such data and be able to read them, as applicable.”
Who acts. The receiving provider, whose EHR system must receive the format (Annex II, point 2.2).
What FerroFED does. It receives patient summaries and laboratory
reports (fhir/receive/laboratory.rs:
a_laboratory_report_is_written_to_its_member_by_its_ehr_id_alone), from
a person’s own grant too (fhir/receive/write.rs:
a_caller_confined_to_one_patient_writes_only_into_its_ehr). A category it
cannot receive is refused, naming it (fhir/receive/refused.rs:
a_category_no_member_receives_is_refused_naming_it). It receives a
dispensation where the deployment declares members that serve them, and
writes it to the member that owns its prescription
(Receiving a dispensation;
fhir/prescriptions/dispense.rs:
a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchanged).
A prescription is served, never received. A discharge report is received
at the member a [[fhir.receive]] declares for it
(Discharge reports the gateway receives).
An imaging report is received the same way
(Imaging reports the gateway receives).
Art 8: restricting access
Art 8: “Natural persons shall have the right to restrict the access of health professionals and healthcare providers to all or parts of their personal electronic health data”, and “The fact that a natural person has restricted access under the first paragraph shall not be visible to healthcare providers.”
Who acts. The person sets the restriction under the Member State’s rules; a consent service or the member CDR holds it.
What FerroFED does. A member the consent pre-filter excludes is never
sent the request, and a node’s own consent refusal contributes no rows
(Consent; consent.rs:
a_member_the_prefilter_denies_is_reported_and_never_contacted). Under
[federation.consent] disclose = false, which an EU deployment sets, the
exclusion and the refusal read exactly as a member that does not know the
patient
(Withholding consent exclusions;
consent_withheld/mod.rs:
a_withheld_exclusion_reads_exactly_as_a_member_that_does_not_know_the_patient;
consent_withheld/node.rs:
a_node_refusal_reads_exactly_as_a_member_that_does_not_know_the_patient).
A request a national contact point relays is always served that way
(contact_point/withheld.rs:
a_restricted_patient_and_an_unknown_one_answer_alike). The default,
disclose = true, follows the Federation Tier’s consent-denied (N27a), so
a deployment in the EU must change it.
Art 9: information on access
Art 9(1): persons have “the right to obtain information, including through automatic notifications, on any access to their personal electronic health data through the health professional access service”. Art 9(2): it is provided “through electronic health data access services and shall be available for at least three years from each date of access”, naming at least the provider or individuals who accessed the data, the date and time, and “which personal electronic health data were accessed”.
Who acts. The access service gives the information and sends any notification.
What FerroFED does. The logging component records every access with the
caller, the time, the categories and the origins, and refuses an access it
cannot record (What a record names;
Failing closed;
access/routed.rs:
every_access_whose_record_the_spool_cannot_take_is_refused_with_no_data).
A record names the patient behind an ehr_id the request reached
(The patient behind an ehr_id;
access/patient.rs:
a_read_by_ehr_id_names_the_patient_the_identity_binding_holds), and is
kept three years by default
(How long a record is kept;
access/retention.rs:
with_no_retention_table_a_record_is_kept_three_years,
a_retention_under_three_years_is_refused). The access service finds a
person’s records with an ITI-81 search at the Audit Record Repository
(Reading the log;
access/search.rs: the_persons_search_finds_every_access_to_their_data).
The gateway sends no notification of its own.
Art 10: opting out
Art 10(1): “Member States’ laws may provide that natural persons have the right to opt out from the access to their personal electronic health data registered in an EHR system through the electronic health data access services referred to in Articles 4 and 12.”
Who acts. The Member State, which sets the mechanism and its safeguards (Art 10(2)).
What FerroFED does. No mechanism of its own. An opt-out the consent service or a member reports is carried out as a restriction is (Art 8 above), and an access in the vital interests that Art 10(2) may allow is marked as under Art 11(5) below.
Art 11(5): an emergency access
Art 11(5): access to restricted data may be granted “where necessary in order to protect the vital interests of the data subject. Such cases shall be logged in a clear and understandable format and shall be easily accessible for the data subject.”
Who acts. The professional asserts the emergency and the member decides what to release.
What FerroFED does. A record whose token declares a configured
emergency purpose carries a mark the access service can search for
(Emergency access;
access/emergency.rs: a_declared_emergency_purpose_marks_the_record,
the_emergency_purpose_is_conveyed_to_the_node). Under
emergency = "pass-to-node" the record names each member whose consent
denial was set aside (access/emergency_consent.rs:
an_emergency_request_asks_the_denied_member_and_records_the_set_aside).
Art 16(1): electronic identification
Art 16(1): where persons use access services, they “shall have the right to identify themselves electronically using any electronic identification means which are recognised pursuant to Article 6 of Regulation (EU) No 910/2014”.
Who acts. The access service and its identity provider. The gateway verifies the tokens of the issuers you configure and authenticates no person itself (Client authentication).
Art 18: no fee
Art 18: “A healthcare provider or a third party shall not directly or indirectly charge data subjects a fee or costs, or require compensation, for sharing or accessing data.”
What FerroFED does. It has no fee, payment or quota per person (Art 7(1): transmission without hindrance).
Art 21: a complaint
Art 21(1): persons “shall have the right to lodge a complaint in relation to the provisions laid down in this Chapter … with the competent digital health authority”.
Who acts. The digital health authority. The manufacturer’s own complaint handling is Complaints and incidents.
The rest of Chapter II
Art 11(1) to (4), Art 12 to 15, 17, 19, 20 and 22 to 24 place obligations on Member States, health professionals, healthcare providers, the Commission and the authorities, and grant no right of their own to a natural person. Where they bind an EHR system, the Annex II checklist holds them.
Cybersecurity risk assessment
This page is the cybersecurity risk assessment Cadasto B.V. keeps for FerroFED under Regulation (EU) 2024/2847, the Cyber Resilience Act (CRA). Art 13(2) has the manufacturer “undertake an assessment of the cybersecurity risks associated with a product with digital elements” and take its outcome into account “during the planning, design, development, production, delivery and maintenance phases”. Art 13(3) says what it holds: “an analysis of cybersecurity risks based on the intended purpose and reasonably foreseeable use, as well as the conditions of use”, whether and how each point of Annex I, Part I, point (2), applies and is implemented, and how Part I, point (1), and the vulnerability handling of Part II are applied. Annex VII, point 3, makes it part of the technical documentation.
The manufacturer’s side of the CRA is the same for every FerroHEALTH product and is in the family book: who the manufacturer is (The manufacturer), what applies from when and how conformity is assessed (The Cyber Resilience Act), and the vulnerability-handling policy (Security). This page is FerroFED’s own product-level assessment.
Every quotation of the CRA is from the Official Journal text, vendored at
docs/specs/eu-cra/reg-eu-2024-2847-en.xhtml
(http://data.europa.eu/eli/reg/2024/2847/oj), and Art 104 of Regulation
(EU) 2025/327 (the EHDS Regulation) is read from
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
No conformity assessment has been carried out. A row below describes the
evidence the repository holds, never conformity. This page is not legal
advice.
Date and revision
This assessment was made on 2026-10-15, against the code of FerroFED
0.0.10 and the main branch after it, and last reviewed on 2026-11-12,
against FerroFED 0.0.20. It is updated with each release: the
version-bump pull request reviews every row against what the release
changes and adds a line to Revisions
(docs/release.md,
Before the tag, step 5). A vulnerability report, a security advisory or a
change to the threat model reopens the rows it touches
between releases (Art 13(7)). The assessment that applies to a release is
this page at that release’s tag.
One assessment, read under two acts
FerroFED is an EHR system under the EHDS Regulation (Classification). EHDS Art 104(1) replaces CRA Art 13(4), so that for a product of CRA Art 32(5a) “the cybersecurity risk assessment may be part of the risk assessment required by those Union legal acts”. This page uses that: it builds on two records that already exist, and copies neither.
- The threat model names the assets, the trust boundaries, the attacker positions, and per boundary each threat with the setting and the test that answers it. It covers the whole gateway.
- The clinical safety risk file is the EHDS-side risk assessment of the two harmonised software components, with each hazard rated before and after its control.
This page reads both against Annex I of the CRA and states what neither says on its own: whether each requirement applies, and the justification where it does not.
Scope
- The product. Each tagged release is one product with digital
elements, in the forms on
Forms placed on the market:
the source at the tag, the
ferrofedtarballs, the gateway image and the operator console image. The library crates reach a user only inside those forms, so they are assessed with them. - When. Annex I binds the releases placed on the market from 11 December 2027 (Art 71(2), Art 69(2)). The assessment runs now so that a gap is known before that date.
- Intended purpose. As stated on Regulatory status: a federation gateway used by healthcare providers, through their client applications, to read and write a patient’s openEHR records across the clinical data repositories (CDRs) of a federation. It holds no clinical data of its own.
- Reasonably foreseeable use and misuse. The misuse the clinical safety
file names (Scope): a wrong configuration, a
client that ignores
meta.federation, a member that breaks the conditions of membership. For security, also: the development profile run near real data, a listener reached without the TLS hop the deployment is meant to supply, and a release run after a newer one is published, which receives no fix (Hardening). - Operating environment. Linux on
x86_64oraarch64, as the image or the binary, behind a reverse proxy, with the services it consumes: the member CDRs, the callers’ issuers, the identity services and an Audit Record Repository (The services a gateway consumes). - Time in use. Art 13(3) asks the assessment to take into account “the
length of time the product is expected to be in use”. Security fixes are
published, in new releases, for five years after each release date, and
only the newest release is supported
(
SECURITY.md), set on the expected time in use that The basis of the five years records, and the rows below hold for that period. - Assets. The eight assets of the threat model (Assets). The patient identifier and the routing state weigh most: a leak of the first is a privacy breach, and a wrong route sends a request to another patient’s record.
How the risks were analysed
The threat model applies STRIDE per trust boundary (Trust boundaries). The clinical safety file rates severity and occurrence after the method of EN ISO 14971:2019 (Method). The CRA names no method, so no specification governs the method: our own design. This assessment applies no harmonised standard, common specification or European cybersecurity certification scheme (Annex VII, point 5).
Annex I, Part I, point (1)
“Products with digital elements shall be designed, developed and produced in such a way that they ensure an appropriate level of cybersecurity based on the risks.” The level is the one the threat model sets: every threat it names has a control and a test, or is named as a risk the deployment carries, with the control the operator supplies (Risks the deployment carries).
Annex I, Part I, point (2), point by point
| Point | Applies | How FerroFED implements it | Where |
|---|---|---|---|
| (a) no known exploitable vulnerabilities | yes | cargo deny check reads the RustSec advisory database and fails on any advisory or yanked crate; it is part of the required check on every change to main, from which a release is tagged. One exception in deny.toml is an unmaintained compile-time macro of the console with no runtime code (RUSTSEC-2024-0436, paste), and its OpenVEX statement under security/vex/ gives the argument. Each release scans both images and the binaries with Trivy at every severity, fixed or not, and is refused while a finding has no OpenVEX statement that judges it; each image is scanned for fixable HIGH and CRITICAL findings before it is tagged, and the published images are scanned every week. Dependabot proposes updates; fuzz targets run over the untrusted inputs; each binary embeds its dependency list (cargo auditable); the console’s site build runs its WebAssembly tools only at pinned releases checked against their SHA-256 | Checks and gates; deny.toml |
| (b) secure by default, and reset | yes | profile defaults to production; the development profile must be set, takes a restart and prints a red notice. There is no unauthenticated mode; the binary listens on loopback; the admin listener is off; a credential or a patient identifier travels only over https; the audit destination must be chosen once a registry is configured. See Reset | Configuration; What must travel encrypted; B8 |
| (c) security updates | yes, automatic installation excepted | a security fix ships as a patch release of the newest release, the only one supported, with the fix alone, or with the reason a functional change ships with it stated in the notes; users hear of it from the advisory and the release feed; the gateway names its support end date and says when it has passed. See Automatic security updates | SECURITY.md; Upgrading |
| (d) protection from unauthorised access, and reporting it | yes | every request is authenticated before the façade reads it; scopes per route; the operator surface and the admin write actions need the operator scope; the ADMIN API is refused. Every refusal is logged as caller-refused under ferrofed::security | B1, B5; Client authentication |
| (e) confidentiality | yes | in transit: https for every credential and patient identifier outside development, mutual TLS and DPoP where configured, TLS on the listener as an option, and the OTLP export of traces and metrics to an https:// collector, with mutual TLS where configured. At rest: no clinical data is written to disk; secrets are read from files, redacted and zeroised, and a secret the operator console sets is written to a new file of mode 0600 and held in a configuration revision only as its reference; the audit spool is 0700 and is encrypted by the volume, which the deployment supplies | B2, B6; Hardening |
| (f) integrity, and reporting corruptions | yes | each release is built in an isolated lane with signed provenance, verified with gh attestation verify; the image runs read-only; config check refuses a defective configuration and a refused reload keeps the running one; two members claiming one ehr_id is 409 and an integrity incident, counted and alerted | Integrity incidents; The image |
| (g) data minimisation | yes | the patient identifier is consumed at the gateway and never reaches a node (§5.4, N33); clinical content stays in memory for one request; logs, metrics and spans carry no body, query text, header value or patient identifier | Where the patient identifier stops; Processing inventory |
| (h) availability, also after an incident | yes | a concurrency limit, a per-caller rate, a body limit and a request timeout; per-node and overall budgets; several replicas and a bounded drain on stop; after a restart the routing state is learned again from the next resolutions and answers, and the spool delivers what it holds | Overload protection; Running several replicas; Health probes |
| (i) minimal impact on other devices and networks | yes | one federated query is a request to every member, so the gateway caps requests in flight per member, bounds each answer it reads, and reads an issuer’s key set again at most once per auth.jwks_refresh_interval_seconds | The per-member cap; The answer bound; B7 |
| (j) limited attack surface | yes | an ITS-REST path the gateway does not serve answers 501; only the health family, GET {base}/ and the JWK Set are open; the admin listener is off unless set and on loopback unless allowed; the image has no shell; a build can leave out a regional binding | B1; The bindings and their features |
| (k) exploitation mitigation | yes | Rust with unsafe code forbidden in every crate and overflow checks in the release build; the image runs as a non-root user with every capability dropped; the outbound gate stops a finished request that carries a consumed identifier and fails closed | The container; What the outbound gate reads |
| (l) recording and monitoring, with an opt-out | yes, the opt-out in part | every access to patient data is recorded with the verified caller; IHE transactions are audited over ITI-20; refusals and admin write actions are logged under ferrofed::security; metrics and traces are exported when turned on. See The opt-out | The access log; The audit trail; Metrics |
| (m) secure removal of data and settings, and secure transfer | yes | the gateway keeps its configuration, registry, secrets, the stored-query store, the audit spools and, under config.ui = "managed", the configuration revisions; each is a file or a database schema the operator removes. The learned routes are exported for a replacement product | What the gateway keeps; Exporting the identity state; Decommissioning |
Reset to the original state
Point (b) asks for “the possibility to reset the product to its original
state”. The binary and the image do not change while they run: the image’s
root filesystem is read-only. The gateway’s state is what the operator gives
it and what it keeps: the configuration and registry files, the secrets,
the stored-query store and the audit spools. The resolution bindings, the
ehr_id index and the learned routes are held in memory and lost on a
restart (What the gateway keeps).
Starting the release with the example configuration it attaches, an empty
stored-query store and empty spools returns it to the state it shipped in.
Empty a spool only once the dependency report shows audit_repository and
audit_feed as up, or its records are lost
(Losing a spool).
Automatic security updates
Point (c) asks for automatic security updates “where applicable”, and Annex I, Part II, point (7), asks for automatic distribution “where applicable for security updates”. They do not apply to FerroFED, and the gateway installs no update and asks no server of the manufacturer for one:
- An upgrade can need a configuration change: a key made mandatory or a
value now refused, listed in the release’s upgrade notes, which
config checkverifies before the new release runs (Before you upgrade). - An upgrade can migrate the stored-query store, which an older release then refuses, so the operator backs it up first.
- The gateway sits on the path of every clinical query of a federation. An update on the manufacturer’s schedule would restart it at a moment no operator chose, and a channel from the manufacturer into the deployment would be a new inbound path.
The operator installs each update, and is told of it by the channels of Hearing of an update and by the gateway itself once the end date of its release has passed.
The opt-out for recording
Point (l) asks for recording “with an opt-out mechanism for the user”. The
log level, the metrics surface and the trace export are the operator’s to
set, and the metrics and the traces are off by default
(Metrics,
Tracing). The record of each access
to patient data has no opt-out outside the development profile: EHDS
Annex II, point 3.2, has the European logging software component “record at
least the following information on every access event or group of events”,
and the gateway refuses an [audit] destination of off or log outside
development once a registry is configured
([audit]). Where the two acts meet, the EHDS
requirement decides, and no opt-out is offered for those records.
Annex I, Part II: vulnerability handling
Part II sets the manufacturer’s processes, and the family book’s Security page states them for every product. What is FerroFED’s own:
- Point (1), the SBOM. Each release attaches a CycloneDX SBOM of the source graph and an SPDX SBOM of the shipped binary per tarball, and attests an SPDX SBOM per platform of each image (The release binaries).
- Point (2), remediation. Point (2) asks that “where technically
feasible, new security updates shall be provided separately from
functionality updates”. A security fix ships as a patch release of the
newest release, the only one supported, with the fix alone; when a
functional change has to ship with it, the release notes open its
Security section with the reason
(
SECURITY.md). - Point (3), tests and reviews. The required checks run on every change; the leakage track 10 and the identifier-integrity track 11 run against the gateway (Conformance matrix); CodeQL and the fuzz targets run beside them.
- Points (4) to (6) and (8). The advisories, the disclosure policy and
the reporting address are the family book’s, applied through
SECURITY.md. - Point (7), secure distribution. Each release is built in a reusable workflow from a cold checkout, signed through Sigstore, and verifiable by digest; a published release cannot be changed (The image).
Residual risk
The risks the gateway cannot close alone, each with the control the deployment supplies or the issue that tracks it, are the threat model’s Risks the deployment carries. The residual risk of the two harmonised components is the clinical safety file’s Overall residual risk. The operator reads both before a deployment goes live, and the instructions for use name the ones a professional user needs to know.
Revisions
| Revision | Date | Release | Change |
|---|---|---|---|
| 1 | 2026-10-15 | 0.0.10, and main before 0.0.11 | first assessment |
| 2 | 2026-10-15 | main before 0.0.11 | Annex I, Part II, point (2): a security fix ships as its own patch release on each supported line, with no feature changes in it |
| 3 | 2026-10-16 | 0.0.11 | every row reviewed against the release: point (a) adds the pinned and checked tools of the console’s site build; point (e) the write-only secrets of configuration revisions; point (m) the revision store among what the gateway keeps. Configuration revisions and the console’s admin actions run on the admin listener behind the operator scope, as point (d) already says, and a configuration or registry refusal names no credential carried in a URL. No point changes whether it applies |
| 4 | 2026-10-19 | main before 0.0.12 | Annex I, Part II, point (2), and the time in use: only the newest release is supported, and a security fix ships as a patch release of it, with no feature changes in it |
| 5 | 2026-10-23 | main before 0.0.12 | Annex I, Part II, point (2): a security fix ships alone where technically feasible, and a release that ships one beside functional changes opens its Security section with the reason; every Security entry names its GitHub security advisory or VEX statement, and the release lane refuses a section that does not |
| 6 | 2026-10-24 | 0.0.12 | every row reviewed against the release: point (a) adds the release’s full image and binary scans with the OpenVEX record, the scan before an image is tagged and the weekly scan, and the console’s site build needs no CSS tool; point (d) holds with client authentication on ferrohealth-smart, every token that fails a check answered with one invalid_token challenge and every static bearer token removed, so a machine authenticates with HTTP Basic or the OAuth 2.0 client credentials grant; point (e) holds with the Argon2id hashes of the Basic users’ passwords; the gateway and console images no longer trust the compromised emSign intermediates (GHSA-w3hc-w475-673c) and ctutils 0.4.4 fixes RUSTSEC-2026-0385 (GHSA-rv83-4vc9-vqxg). No point changes whether it applies |
| 7 | 2026-10-25 | 0.0.13 | every row reviewed against the release: point (e) adds the OTLP export over TLS, with private trust roots and mutual TLS, so a remote collector needs no forwarder beside the gateway; point (d) holds with the running configuration the admin listener answers behind the operator scope, which names a secret by its file and never by its value; point (j) holds with the Kubernetes package’s NetworkPolicy, which admits only the reverse proxy, the scraper and the console to the gateway; Annex I, Part II, points (4) to (6) hold with the response times SECURITY.md now states, those of every product of the family. No point changes whether it applies |
| 8 | 2026-10-28 | 0.0.14 | every row reviewed against the release: point (d) holds with the family crates on ferrohealth-smart 0.8.6, which masks every secret through ferrohealth-secret and caches a verified Basic credential for 300 seconds under a keyed hash drawn at boot, emptied at a restart; point (e) holds with [auth] ca_file, which adds trust roots for the issuers alone; point (j) holds with a CONTRIBUTION in XML refused 415 before it is routed; point (g) holds more strictly with the static cross-reference removed, so no profile holds a table of patient identifiers. No point changes whether it applies |
| 9 | 2026-10-30 | 0.0.15 | every row reviewed against the release: point (d) holds with every credential in the secret types of ferrohealth-secret 0.2.0 and the configuration read by ferrohealth-config 0.2.8, whose refusals name the key and quote no value; point (e) holds more strictly with each TLS listener on TLS 1.3 unless its table admits TLS 1.2 with the four ECDHE AES-GCM suites of RFC 9325, and a client certificate asked only under an explicit client_auth, so a client_ca_file alone is refused; point (h) holds with both listeners served and drained by ferrohealth-server 0.4.1, a request head that does not arrive within header_read_timeout_ms and a kept-alive connection idle past idle_timeout_ms closed; point (j) holds with every error outside the FHIR face an RFC 9457 problem details document that names the gateway’s own request id and quotes no credential; point (l) holds with the access record of a routed openEHR read or write naming its object as what.identifier, which an Audit Record Repository that checks references accepts. No point changes whether it applies |
| 10 | 2026-11-05 | 0.0.16 | every row reviewed against the release: point (j) holds more strictly with the operator console drawn whole on its server, running no WebAssembly and admitting scripts and stylesheets from its own origin alone (script-src 'self'; style-src 'self'); point (d) holds with the console’s sign-in and sessions on ferrohealth-session 0.2.2, a session watch that ends every tab together, and a sign-out sent to the provider only for a session that holds an ID Token; point (d) holds on the FHIR face with each laboratory report, a member’s own included, read only through a Binary/{token} the gateway signs for the caller who listed it, so no URL reaches a member past the access check; point (l) holds with every new list, read and withdrawal recorded under its category; the four [server] keys 0.0.15 still read and the console’s site_root are refused by name. No point changes whether it applies |
| 11 | 2026-11-10 | 0.0.18 | every row reviewed against the release: point (d) holds more strictly with every token classified under its issuer’s kind on ferrohealth-smart 0.13.0, a person’s token admitted only through a listed first-party client or delegate, [[auth.basic.users]] removed so no Basic caller is admitted, a national contact point’s token bound to the connector’s client certificate and held to its treatment relationship patient on both faces, a DPoP proof refused when sent again to any replica, and every node request a caller originates sent under a token exchanged for that caller; point (e) holds more strictly with every outbound client on TLS 1.3 unless its tls sub-table lowers it, FerroFED’s own store reached only with sslmode=require, and a MyHealth@EU responding gateway’s certificates checked against their CRL or OCSP responder before each discovery; point (h) holds with an emergency access past the eHDSI limit refused before any member is asked, and a request that needs the store refused while the rest of the gateway serves; point (j) holds with a JSON document the gateway cannot read refused naming only its category, line and column (GHSA-r5p6-r9m2-c3pw); point (l) holds with a contact point’s access recorded with the assertion’s subject-id, the provider acted for and the relay, and the trc-patient-mismatch, trc-patient-unresolved and emergency-limit-reached security events. No point changes whether it applies |
| 12 | 2026-11-11 | 0.0.19 | every row reviewed against the release: point (d) holds more strictly with every endpoint and issuer URL of ferrohealth-smart 0.18, the issuer of each [[trust.issuer]] and [[federation.contact_point]], and the console’s [oidc] issuer and redirect URIs on ferrohealth-session 0.13 held as masking URLs, so a password in a URL’s userinfo reaches no rendering of the configuration, and with the identity and Dutch service clients built from one outbound client each, whose credential is a client credentials token or HTTP Basic under the endpoint table; point (e) holds with FerroPIX 0.0.26 reached under a client credentials token in the quickstart; the named refusals of the keys v0.0.18 removed are deleted, so each such key is refused as an unknown key. No point changes whether it applies |
| 13 | 2026-11-12 | 0.0.20 | every row reviewed against the release: point (d) holds more strictly with the issuer of every [[auth.attributes]], [[auth.federation]] and [[auth.party]] table and every auth.scopeless_subjects entry held as a masking URL on ferrohealth-smart 0.19, so a password in its userinfo reaches no rendering of the configuration, with each client assertion of a grant valid for 60 seconds, and with a caller’s professional and purposes of use read by the crate from its issuer’s attributes table alone; point (e) holds with the quickstart’s FerroEHR 5.7.0 nodes reached under the caller’s exchanged token, never under a Basic user, and FerroPIX 0.0.29; the named refusals of the keys v0.0.19 retired, the console’s [gateway] timeout_ms among them, are deleted, so each such key is refused as an unknown key. No point changes whether it applies |
Receiving a document
Regulation (EU) 2025/327 asks an EHR system that stores, intermediates or gives access to health data to “be able to receive personal electronic health data in the European electronic health record exchange format, by means of the European interoperability software component” (Annex II, points 2.2 and 2.3). FerroFED intermediates, so it must receive. It stores nothing of its own, so a received document goes to one member: the one the deployment declares for the document’s category, as the Federation Tier sends every new object to one explicitly chosen node (§2.3, N23).
The interoperability component, the eehrxf crate published from
FerroHEALTH/FerroBRIDGE and pinned exactly, reads a received document,
checks it against its profiles and maps it into openEHR. The gateway takes
the document at POST {fhir-base}/Bundle and writes the composition to the
declared member, with an access record of each receipt
(The patient summary over FHIR).
The information sheet lists the categories a release can receive, and each
deployment’s GET {fhir-base}/metadata the ones it receives and the ones
it has no receiving member for, with no Bundle create at all when it
receives none
(The categories received).
ferrofed config check names the same categories to the operator, each
received one with its member
(Running it).
What the component does
What the component accepts, the structural check, the full validation by
ferrobridge-validator and the mapping into one openEHR composition that
keeps the document in FEEDER_AUDIT.original_content are described where
the component is built, on
FerroBRIDGE’s receiving page. In short:
a document is decoded strictly against FHIR R4 and held to the R4 document
rules; its subject resolves to exactly one Patient entry; it is validated
in full against the Bundle profile of its category over the package’s
whole dependency closure; and no refusal quotes a value of the document.
The gateway adds what is its own:
- each
[[fhir.receive]]names the package and, independencies, every package of its dependency closure, which the validator refuses to load short of (decision A106); - an issue of severity
errororfatalrefuses the document with422and anOperationOutcomeof every such issue, before any member is asked, and each warning, and every constraint the validator met and did not evaluate as awarningof typenot-supported, is returned in the outcome of the write; - a
requiredbinding the packages cannot decide is asked of the[eehrxf.terminology]server, and a server that refuses or does not answer refuses the document with502; with no server configured the binding is listed as not evaluated, and a coding that carries no display refuses the document (Terminology); - the FHIRconnect mapping files the component runs are the deployment’s.
Written to the declared member
The federation half takes the composition and the document’s patient. It
resolves the patient from every identifier of the document’s Patient in a
namespace fhir.receive_namespaces names, at the declared member alone, and
every one must name the same ehr_id there: identifiers that name two
patients, or a patient the member holds no EHR for, are refused before
anything is sent. A caller whose grant is confined to one patient writes
only into that patient’s EHR. The composition then goes to the member as an
ITS-REST composition_create, with no patient identifier in the request
path, query string or headers (§5.4.1, N33); the body keeps the received
document, which is the clinical content the client sent. A member that
refuses the composition or does not answer is an error that names it, never
a success.
A laboratory report
A release receives three categories as documents: patient summaries,
laboratory reports, Art 14(1)(e), and discharge reports, Art 14(1)(f). A laboratory report is held to the HL7
Europe Laboratory Report 2.0.0 Bundle-eu-lab and Composition-eu-lab
profiles, which a document whose Composition.type is LOINC 11502-2
names, and mapped by the laboratory mapping the component ships over the
KDS_Laborbericht template into one composition. Declare the member that
stores laboratory reports with its own [[fhir.receive]], naming the
package hl7.fhir.eu.laboratory 2.0.0 and its dependency closure, the
template and the mapping files, which eehrxf writes out of its
mappings::LABORATORY project:
[[fhir.receive]]
category = "Laboratory-Reports"
member = "node-a"
package = "/etc/ferrofed/packages/hl7.fhir.eu.laboratory-2.0.0.tgz"
dependencies = [ # the package's dependency closure
"/etc/ferrofed/packages/hl7.fhir.r4.core-4.0.1.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.1.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.extensions.r4-5.2.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.xver-r5.r4-0.1.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.extensions.r4-1.3.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.base-2.0.0.tgz",
]
template = "/etc/ferrofed/mappings/laboratory/KDS_Laborbericht.opt"
files = [ # the files of eehrxf's laboratory project, in its order
"/etc/ferrofed/mappings/laboratory/ferrobridge_eu_laboratory.context.yml",
]
context = "ferrobridge_eu_laboratory.context"
language = "en"
territory = "NL"
List every file of the project in files, in the order the project names
them; the example shows the first. The composition carries each result’s
value with its unit, the report’s code, status, time and conclusion and
each specimen. Since eehrxf 0.1.149 it also carries a result’s normal
reference range, as the normal_range of its quantity, and the order
filler’s identifier, as the order the template holds. Since eehrxf
0.1.183 a typed range, one whose Observation.referenceRange.type is set,
is carried as one of the quantity’s other_reference_ranges, with its type
as the range’s meaning
(FerroHEALTH/FerroBRIDGE#1694).
A range text, Observation.basedOn and an order named by reference alone
are declared lost, and the answer’s OperationOutcome names each loss
(FerroHEALTH/FerroBRIDGE#1481).
The member’s record holds those only in the original document the
composition keeps. Hazard I-16 of the clinical safety risk
file stays open at S4, P3: a reader of the member’s
record is not told of those losses, so they still arise in normal use.
An imaging or a discharge report
Since eehrxf 0.1.170 the component maps a received medical imaging
report, Art 14(1)(d), and a discharge report, Art 14(1)(f), the same way.
The gateway receives both
(#1716). An imaging report
is held to the HL7 Europe Imaging Report 1.0.0-ballot BundleReportEuImaging
and CompositionEuImaging profiles, which a document whose
Composition.category is Medical-Imaging names, and mapped by the
component’s mappings::IMAGING project (context
ferrobridge_eu_imaging.context). A discharge report is held to the HL7
Europe Hospital Discharge Report 1.0.0 bundle-eu-hdr and
composition-eu-hdr profiles, which a document whose Composition.type is
LOINC 34105-7 names, and mapped by the mappings::HDR project (context
ferrobridge_eu_hdr.context). Declare each category’s member with its own
[[fhir.receive]], as the laboratory example above shows: the category’s
package and its dependency closure, the template the project maps over and
every file of the project in its order. The discharge project maps
FerroBRIDGE’s ADL 2 template, so its entry names the .adlt template with
every archetype it holds in archetypes
(Discharge reports the gateway receives),
and so does the imaging project’s
(Imaging reports the gateway receives).
Inserted by the person
Regulation (EU) 2025/327 Art 5 asks that information a person, or their
representative of Art 4(2), inserts in the person’s EHR “be clearly
distinguishable as having been inserted by the natural person or by his or
her representative”. When the caller is a person acting under their own grant,
a token whose sub names the person and whose patient/ scopes are
confined to them (Patient grants),
the gateway has the component mark the composition before it is sent: every entry, in
the composition and in each of its sections, names the record’s subject
as its provider. The openEHR RM defines ENTRY.provider as “the agent
who provided the information”, which “is usually the patient or the
clinician” (RM 1.1.0, EHR Information Model, “Entry and its Subclasses”),
and refers to the subject of the record with a PARTY_SELF (Common
Information Model, “PARTY_SELF and Referring to the Patient from the EHR”).
The mark is a PARTY_SELF with no external_ref, so it names no
identifier of the person. A provider the mapping wrote is replaced; the
document as received stays in the original content.
A composition with no entry, or with a GENERIC_ENTRY, which has no
provider, cannot carry the mark, so a person’s document that maps to one is
refused 422 with nothing sent. A professional’s document is written as
before, with no provider set. The access record of a person’s receipt
carries the ehds-subject-inserted entity (Information a person
inserted).
A proxy or a legal representative who acts for the person under the
person’s grant is told apart only when the token says a representative
acts: its issuer declares that the RFC 8693 act claim of its patient
tokens names one, and the token carries that claim
(Patient grants). Every
entry then names the representative as its provider, a
PARTY_IDENTIFIED, which the Common Information Model uses for every party
other than the subject of the record (“Referring to Demographic
Entities”), never a PARTY_SELF. Its one identifier is the act claim’s
sub, issued by the claim’s iss or the token’s own; it carries nothing
else the token does not state. A token that names the representative by
nothing, by its own sub or by an identifier of the document’s patient is
refused 403 with nothing sent, so the provider names nothing of the
person. The access record carries the ehds-representative-inserted
entity in place of ehds-subject-inserted. A token that cannot say a
representative acts, because its issuer declares no act claim or the
proxy service issues tokens in the person’s own name, is read as the
person’s own, and the gateway cannot mark the representative.
The component only creates compositions, so it never changes an entry a professional wrote. What a person changes over ITS-REST is the member’s to refuse (Patient grants).
Hazards
The clinical safety risk file lists the receive path
as I-10 (a document filed under the wrong member or patient), whose
controls are built, and I-11 (a document stored with content lost), whose
controls include the full validation above
(#808). The safety
review of 2026-10-25 closed both at S4, P1
(#1327). A received laboratory report adds I-16 (a result the
record does not hold whole), open at S4, P3. A received discharge report
adds I-21, open at S4, P3: its diagnoses, procedures and discharge
medicines reach the member’s composition with every element the mapping
reads, and a diagnosis’s severity and certainty are relayed as not
carried, but its hospital course and every section the template holds no
place for reach the member’s record only in the original document; the
answer declares them to the sender as not carried, and a reader of the
member’s record is told of none
(FerroHEALTH/FerroBRIDGE#1820).
A received imaging report adds I-22, open at S4, P3: its study, its
impression and, since eehrxf 0.6.6, its performing organization reach the
member’s composition, but its report identifier, a performer that is no
organization and each section’s narrative reach the member’s record only in
the original document; the answer declares each to the sender as not
carried, and a reader of the member’s record is told of none
(FerroHEALTH/FerroBRIDGE#1827,
#1716,
#1860).
Clinical safety risk file
Annex II, point 1.1, of Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation) asks that the harmonised software components of an EHR system “achieve the performance intended by its manufacturer” and be designed and manufactured so that, “during normal conditions of use, they are suitable for their intended purpose and their use does not put at risk patient safety”. This page is FerroFED’s risk management file for its two harmonised components (Art 25(1)): the European logging software component and the European interoperability software component. It names each hazard, the control that answers it, and the test or code that implements the control, by path. A control that is not built is marked planned, with its issue.
The Annex II checklist cites this page for point 1.1, with
the hazards still open. Every quotation of the Regulation is from
the Official Journal text, vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
This page is not legal advice.
Scope
| Component | Where it is | What it does in this release |
|---|---|---|
| Logging (Art 2(2)(o)) | the ehds-logging crate (FerroAUDIT), with the record built in app/ferrofed-server/src/access/ | records every access to patient data the gateway intermediates (The access log) |
| Interoperability (Art 2(2)(n)) | the eehrxf crate 0.1.187, published from FerroHEALTH/FerroBRIDGE and pinned exactly, with the federation half in app/ferrofed-eehrxf/ | the EHDS dataset model and the mapping between openEHR and FHIR R4; the gateway serves it on its FHIR face, writing the patient summary and receiving a document in the exchange format (Receiving documents) |
Both components ride on the federated query path: the logging component records what that path did, and the interoperability component builds its documents from what that path returns. The federation hazards the interoperability component inherits are therefore assessed here with it, and their controls on the query path are named.
The file covers the use the intended purpose
states and the misuse a deployment can reasonably make of it: a wrong
configuration, a client that ignores meta.federation, a member that
breaks the conditions of membership. It does not cover a client
application’s own presentation, which is that application’s to assess.
Method
The Regulation names no standard for this assessment, so no specification governs the method: our own design. The file follows the risk management process of EN ISO 14971:2019, Medical devices: Application of risk management to medical devices, as a method. FerroFED claims no conformity with that standard, and its documents do not classify it as a medical device (The medical device question). The standard’s text is copyrighted and not vendored; the clauses below are named by the standard’s table of contents.
| EN ISO 14971:2019 | Where this file answers it |
|---|---|
| 4.4 Risk management plan, 4.5 Risk management file | this page, Method and Review and change |
| 5.2 Intended use and reasonably foreseeable misuse | Scope |
| 5.4 Identification of hazards and hazardous situations | the hazard tables |
| 5.5 Risk estimation | the severity and occurrence scales below, the “Before” column |
| 6 Risk evaluation | the acceptability rule below |
| 7.1 Risk control option analysis | the order of controls below |
| 7.2 Implementation of risk control measures | the “Control” and “Implemented by” columns |
| 7.3 Residual risk evaluation | the “After” column |
| 7.5 Risks arising from risk control measures | the hazards a control itself creates, L-02, L-05 and L-10 |
| 8 Evaluation of overall residual risk | Overall residual risk |
| 9 Risk management review, 10 Production and post-production activities | Review and change |
Sources of hazards. The four hazards a federated summary adds to a CDR’s own, named when this file was opened: duplicate entries across nodes, a silent node, an unmapped code and a demographic mismatch. Then the Federation Tier’s failure statuses (§11.1, §11.2), the serious-incident classes of Complaints and incidents, the threat model, and each point of Annex II, 3.2 to 3.4, read as “what if this field is wrong or missing”.
Severity. The harm the hazardous situation can lead to. Art 2(2)(r) names the serious ones: “the death of a natural person or serious harm to a natural person’s health” and “serious prejudice to a natural person’s rights”.
| Level | Harm |
|---|---|
| S1 | none to care or rights |
| S2 | care is delayed, or a record of access is incomplete in a way the deployment can see and correct |
| S3 | a person’s rights are prejudiced: a restriction is shown, an access goes unrecorded, a patient identifier is disclosed; or a clinician decides on information that is incomplete or wrong where the harm is reversible |
| S4 | a clinician decides on information that is incomplete or wrong, where the harm to health can be serious |
Occurrence. For software, how readily the hazardous situation arises, judged qualitatively.
| Level | The hazardous situation arises |
|---|---|
| P1 | only through two independent faults, or a misconfiguration config check refuses |
| P2 | through one fault in another system, or one misconfiguration nothing refuses |
| P3 | in normal use |
Acceptability. A risk is acceptable after control at S1 at any level, at S2 at P2 or P1, and at S3 or S4 at P1 alone. A control counts only when it is built and a test holds it. A hazard whose control is planned keeps its “Before” rating and is open; the component it belongs to is not offered for its Annex II purpose while a hazard of S3 or S4 is open.
The order of controls, after clause 7.1: a design that cannot produce the hazardous situation first; then a protective measure in the software, such as refusing the request or failing closed; then information for safety, which the instructions for use carry.
The logging component
| Id | Hazardous situation | Harm | Before | Control | Implemented by | After |
|---|---|---|---|---|---|---|
| L-01 | An access to patient data leaves no access record: the sink cannot store it, or a path of the gateway hands out data without one | the person cannot learn who read their data (Art 9), and misuse goes unseen | S3, P2 | the record is stored before the answer leaves; when it cannot be, the access is refused 503 access-unrecorded with no data; an answer that carries data and reports no access is withheld; only the server builds a record | app/ferrofed-server/src/access/mod.rs; app/ferrofed-server/tests/it/access/routed.rs (every_access_whose_record_the_spool_cannot_take_is_refused_with_no_data, a_stored_query_whose_record_the_spool_cannot_take_is_refused_with_no_data); app/ferrofed-server/tests/it/access/gate.rs (a_patient_data_answer_that_reports_no_access_is_withheld); app/ferrofed-engine/tests/it/architecture.rs (no_crate_but_the_server_builds_an_access_record) | S3, P1 |
| L-02 | Failing closed blocks care: a full spool refuses every access to patient data (a risk the L-01 control creates) | a clinician gets no answer, care is delayed | S2, P2 | the spool is bounded on disk, so a repository outage alone blocks nothing; alerts fire on the backlog and on refusals; the instructions name the alert | app/ferrofed-server/tests/it/audit_repository.rs (a_full_spool_fails_the_query_closed_and_asks_no_member); app/ferrofed-server/tests/it/feed_audit/pixm.rs (a_repository_that_never_answers_holds_no_query_and_the_records_are_spooled); deploy/observability/ferrofed-alerts.yaml (FerroFEDAuditSpoolBacklog, FerroFEDAuditRefused) | S2, P1 |
| L-03 | The record names the wrong person or provider | an access is attributed to someone who did not make it, and the one who did is not found | S3, P2 | the accessor comes from the token the gateway verified, never from the request; a request refused at authentication, for its person or assurance, or by a limit, writes no record; a console query names the operator | app/ferrofed-server/tests/it/access/query.rs (a_patient_query_names_the_caller_the_patient_the_origins_and_every_category, a_console_query_names_the_operator); app/ferrofed-server/tests/it/access/professional.rs; app/ferrofed-server/tests/it/access/limits.rs; tests/it/balp/mod.rs of the ehds-logging crate (FerroAUDIT) (the_person_the_provider_and_the_client_are_named) | S3, P1 |
| L-04 | The record omits the professional’s identification and assurance level, or the client’s address behind a proxy | a reviewer cannot tell which professional acted, or from where | S2, P3 | the professional’s name and identifier, who acts and the assurance level come from the verified token, recorded only as the issuer states them and never inferred; a client token reaches patient data only when it names the professional it acts for; behind a proxy listed in server.trusted_proxies the record names the client’s address, and a forwarded header from any other peer changes nothing | app/ferrofed-server/tests/it/access/accessor.rs (a_person_is_recorded_with_the_professional_and_the_verified_level, a_declared_client_is_recorded_acting_for_the_professional, a_token_naming_no_professional_records_none); app/ferrofed-server/tests/it/access/address.rs (a_trusted_proxy_names_the_client_in_the_access_record, a_forwarded_header_from_an_untrusted_peer_never_reaches_the_record) | S2, P2; an issuer whose tokens name no professional or state no level leaves them out (instructions for use) |
| L-05 | An access to priority-category data is recorded as of no category, so it is kept for less time and the person’s view of it is wrong; or the map sends every access to “unclassified” (a risk the control creates) | the access is lost to review before its time | S3, P2 | none only when every object is an exact none key; everything else ehds-unclassified with its ids as evidence, never refused; a map with an unknown code is refused at load; the map’s digest is in every record; the deployment authors the map | tests/it/classify.rs of the ehds-logging crate (FerroAUDIT) (an_unmapped_object_is_unclassified_with_its_ids_and_never_dropped, none_never_erases_a_category_in_a_mixed_result, an_id_differing_by_case_space_or_specialisation_is_unmapped_never_none); tests/it/property.rs of the ehds-logging crate (FerroAUDIT) (a_result_is_of_no_category_only_when_every_object_is_an_exact_none, evidence_that_shows_nothing_is_unclassified); tests/it/map.rs of the ehds-logging crate (FerroAUDIT) (a_code_that_names_no_category_is_refused) | S3, P1 for “no category”; unclassified records are kept, retention by category is L-07 |
| L-06 | The record names an origin that sent nothing, an ehr_id at a member the query never reached, or the categories of one member’s rows under another | the record misstates which CDRs released which data | S2, P3 | an endpoint is an origin only when the query was sent to it, through the endpoint it was sent through; a query sent to no member writes no record; each origin of a merged answer carries the categories of its own rows; a member past the answer bound is node-error with no row | app/ferrofed-server/tests/it/access/origins.rs (a_query_every_member_of_which_was_capped_writes_no_record, a_capped_member_is_no_origin_of_the_answer, each_origin_of_a_merged_answer_carries_the_categories_of_its_own_rows); app/ferrofed-server/tests/it/access/query.rs (a_query_no_node_was_sent_is_not_an_access, a_member_answer_past_the_read_bound_is_recorded_as_node_error_with_no_row) | S2, P1 |
| L-07 | The records cannot be reviewed, or are not kept by origin and category (Annex II, points 3.3 and 3.4) | misuse is not found, or records go before their time | S3, P2 | the records go to the deployment’s Audit Record Repository, external software point 3.3 admits, refused unset or off outside development; a person’s records are found there with ITI-81 by their identifier, an operator’s by caller, ehr_id, endpoint and outcome, a search needs a period, and each search is recorded; each record states the period its origins and categories call for, three years at least, and a shorter period is refused at load; each record carries a security label per category and per origin; the state the gateway learned is exported for a replacement product; keeping a record for its period and limiting who reads it by origin and category is the repository’s, which the instructions for use name | app/ferrofed-server/tests/it/feed_audit/config.rs (a_binding_outside_development_needs_a_declared_destination, off_is_refused_outside_development_and_admitted_inside); app/ferrofed-server/tests/it/access/search.rs (the_persons_search_finds_every_access_to_their_data, an_operator_reviews_by_ehr_id_endpoint_caller_and_outcome, the_period_bounds_a_search_and_is_required, every_search_of_the_log_is_recorded); app/ferrofed-server/tests/it/access/retention.rs (with_no_retention_table_a_record_is_kept_three_years, a_category_kept_longer_sets_the_period, an_origin_kept_longer_sets_the_period, an_unclassified_access_is_kept_the_longest_period_declared, a_retention_under_three_years_is_refused); app/ferrofed-server/tests/it/access/labels.rs (with_no_labels_table_a_record_carries_its_category_and_its_origins, an_unclassified_access_carries_every_categorys_label); app/ferrofed-server/tests/it/identity_export.rs (the_export_holds_the_learned_creating_system_mappings_in_its_format, the_export_is_refused_without_the_operator_scope) | S3, P1 for the records the gateway writes and their review; a repository that does not keep a record for its stated period or apply the labels is one misconfiguration nothing refuses, S3, P2, the deployment’s to control as the instructions for use say |
| L-08 | The record discloses the patient or the caller where it must not: to a node, the operator log, a span or a metric | a patient identifier leaks (§5.4, N33) | S3, P2 | no record content reaches a node or a log; Debug shows no identifier | app/ferrofed-server/tests/it/access/query.rs (no_patient_template_or_caller_reaches_the_log_a_metric_or_a_node); tests/it/balp/mod.rs of the ehds-logging crate (FerroAUDIT) (no_debug_shows_the_patient_the_person_or_an_id); tests/it/classify.rs of the ehds-logging crate (FerroAUDIT) (no_debug_shows_an_id) | S3, P1 |
| L-09 | Emergency access to restricted data is not marked | the person cannot see that a restriction was overridden (Art 8) | S3, P2 | a record whose verified token declares a purpose of use the deployment names in [[access_log.emergency_purpose]] carries the ehds-emergency-access entity, stated in words, with the purposes that marked it; the mark is read from the token alone, never inferred, and changes no dispatch and no answer, so the node still decides (N26); config check notes a deployment that names no emergency purpose, and the instructions for use ask for one; whether restricted data were released is the node’s to record | app/ferrofed-server/tests/it/access/emergency.rs (a_declared_emergency_purpose_marks_the_record, no_access_is_marked_without_a_declared_purpose, a_token_without_the_purpose_is_not_marked, a_nodes_refusal_of_an_emergency_access_stands, config_check_notes_an_access_log_without_an_emergency_purpose); tests/it/emergency.rs of the ehds-logging crate (FerroAUDIT) (only_an_exact_match_marks_the_access); tests/it/balp/emergency.rs of the ehds-logging crate (FerroAUDIT) (an_emergency_access_is_marked_in_its_own_entity); app/ferrofed-server/tests/it/access/emergency_consent.rs (an_emergency_request_asks_the_denied_member_and_records_the_set_aside, by_default_the_pre_filter_applies_to_an_emergency_request) | S3, P1 |
| L-10 | A write whose record the sink cannot take is answered as refused after a member stored it, so a client retries it and the member holds it twice, or gives up believing it was not stored (a risk the L-01 control creates for writes) | a duplicate or a missing clinical document in the patient’s record | S3, P2 | the place of a write’s record is reserved in the sink before any member is asked: a write whose record has no place is refused 503 access-unrecorded and reaches no member, and two writes never take one last place; the reservation is filled with the record of how the write ended, a failed one included; if storing it still fails (the spool’s write bound or disk), the member’s answer stands and the loss is raised as the security event write-unrecorded naming no value | app/ferrofed-server/tests/it/access/routed.rs (a_routed_write_whose_record_has_no_place_never_reaches_the_node, concurrent_routed_writes_against_one_free_place_write_once, a_routed_write_records_the_category_of_what_it_wrote); app/ferrofed-server/tests/it/fhir/receipt.rs (a_receipt_whose_record_has_no_place_never_reaches_its_member, concurrent_receipts_against_one_free_place_write_once); app/ferrofed-server/tests/it/fhir/independence.rs (a_received_document_is_written_the_same_whatever_the_access_log_sink_does); crates/ihe-iti/src/atna/spool/reserve.rs | S3, P1 for a write answered as refused after it was made; a write whose reserved record still fails to store (one fault at the spool’s disk) is unrecorded in a way the deployment sees, S2, P2: the alert FerroFEDWriteUnrecorded names it and the request id joins it to the member’s own log |
The interoperability component
The gateway serves this component on its FHIR face: the patient summary and the receipt of a document (a laboratory report, I-16, and its serving, I-18; a discharge report, I-21; an imaging report, I-22), the discharge and imaging reports it writes (I-19, I-20), and prescriptions and dispensations (I-12 to I-15, and I-17 for a prescription mapped from openEHR). Each situation below is open until its control is built, and the component is not offered for its Annex II purpose while any of S3 or S4 is open.
| Id | Hazardous situation | Harm | Before | Control | Implemented by | After |
|---|---|---|---|---|---|---|
| I-01 | A silent node: a member that holds the patient’s data does not answer, and the summary reads as the whole record | a missed allergy or medication leads to a wrong treatment | S4, P3 | all-or-nothing by default (N37), a partial document only on request and with every silent member named; a section says “no information” (emptyReason nilknown) only when every member in scope answered empty, unavailable otherwise | built on the query path: app/ferrofed-server/tests/it/completeness.rs (all_stated_explicitly_is_accepted_and_fails_closed, incompleteness_is_the_complete_flag_and_never_an_operation_outcome), app/ferrofed-server/tests/it/endpoint_report.rs (an_undirected_query_asks_every_member_and_excludes_none); the section queries run on that path, every member asked and named: app/ferrofed-server/tests/it/stored/reserved.rs (each_section_query_runs_by_name_and_no_patient_identifier_reaches_a_node); the document: app/ferrofed-server/tests/it/fhir/summary.rs (a_silent_member_fails_the_summary_and_is_named, under_partial_a_silent_member_is_named_in_every_section), app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_silent_member_is_named_and_its_sections_are_unavailable) | S4, P1 |
| I-02 | Duplicate entries across nodes: two members hold the same medication or problem, and the summary lists it twice, or merges two different entries as one | a dose counted twice, or a distinct entry lost | S4, P3 | entries are never merged across members; each is listed with its provenance, one Provenance per source composition and each section’s contributing member as its author; version-identity de-duplication on the query path removes only copies of one version; the instructions tell clinicians to expect duplicates; two members’ medication entries that name the same medicine, strength and route are marked in the section’s narrative as a probable duplicate, naming both members, with nothing merged | crates/eehrxf/tests/it/mapping.rs (a_composition_maps_to_its_resource_with_one_provenance); app/ferrofed-server/tests/it/dedup.rs (without_the_header_both_copies_come_back_and_none_is_recorded, under_version_identity_one_row_comes_back_and_the_copy_is_named); Reading an answer safely; listed apart, each member a section author with its own Provenance: app/ferrofed-server/tests/it/fhir/summary.rs (the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node), crates/eehrxf/tests/it/document.rs (two_runs_over_one_composition_never_share_a_full_url); the probable-duplicate mark: app/ferrofed-eehrxf/tests/it/summary/medication.rs (one_medicine_two_members_record_alike_is_marked_a_probable_duplicate_and_kept_twice, another_route_or_one_member_is_no_probable_duplicate), app/ferrofed-eehrxf/src/summary/duplicates.rs (#1343) | S4, P1 |
| I-03 | An unmapped code or element: content the mapping does not cover is dropped, so an entry reaches the document without its code or not at all | an allergy or a result is missing from the summary | S4, P3 | a composition of another template, a flat composition and a context the mapping files do not declare are refused; unmapped content is reported, never dropped; every producer element of the dataset is covered by the section catalogue; every emitted document is validated against the vendored profiles; the medical alert and the functional status, which no openEHR content feeds, are written in every summary as unavailable with a narrative saying the gateway does not supply them; the medication entries are selected by the template section that holds them, as the component places them (eehrxf 0.1.181 crosswalk::patient_summary::SOURCES): a medication order or a medication action under the ad hoc Medication Summary section for the medication summary and a medication action under the vaccination list section for the immunisations, never by the archetype alone, so a vaccine is never selected for the medication summary; a composition the EPS project’s root context maps whole is run once, and each section takes the entries of its own slot, each held to the profiles the crosswalk admits for that section, so no section takes another’s entries (FerroHEALTH/FerroBRIDGE#1696); each medication use is placed by an explicit criterion of its mapping, never by inference; every element a mapping run dropped is named in its section’s narrative by path and member, and every element of the composition a run leaves unread is declared and counted in the narrative of each section that takes from the record (FerroHEALTH/FerroBRIDGE#1712); a composition holding a context’s start archetype more than once is mapped once per entry by the engine (FerroHEALTH/FerroBRIDGE#1254), and each entry is listed on its own, in the section its context’s condition places it, never one resource that mixes its entries | crates/eehrxf/tests/it/mapping.rs (a_composition_of_another_template_is_refused, a_flat_composition_is_refused, a_context_the_files_do_not_declare_is_refused_with_diagnostics); crates/eehrxf/tests/it/category.rs (the_patient_summary_producer_populates_its_five_required_sections); the section catalogue: crates/eehrxf/tests/it/crosswalk.rs (the_patient_summary_crosswalk_holds_against_the_pinned_packages, a_producer_shall_element_without_a_row_fails, an_uncovered_required_section_slice_fails), with the medical alert (A.2.1.2), a producer element, and the functional status (A.2.3.4) open because no openEHR content is named to feed them (the patient summary crosswalk); the openEHR content each section is selected by, held to the vendored template: app/ferrofed-eehrxf/tests/it/template.rs (each_section_selects_exactly_the_entry_archetypes_its_template_sections_name), app/ferrofed-eehrxf/tests/it/crosswalk.rs (every_crosswalk_section_has_a_query_or_a_reason_and_never_both) (the section queries); what no mapping covers is counted in the section, never dropped: app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_composition_no_mapping_covers_is_reported_never_dropped); every document held to the vendored EPS profiles in the tests: app/ferrofed-server/tests/it/fhir/summary.rs (the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node), and every document those tests emit checked by HL7’s FHIR Validator in CI: scripts/checks/fhir-validator.sh (#688); the terminology rules of #687 are built in the component, and the summary the face writes puts a term mapping that is no equivalence into its section’s narrative, never dropped: app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_broader_term_mapping_reaches_the_narrative_and_no_coding, a_narrower_term_mapping_reaches_the_narrative_and_no_coding, an_unknown_term_mapping_reaches_the_narrative_and_no_coding, an_equivalent_term_mapping_is_a_coding_and_no_narrative_line); crates/eehrxf/tests/it/terminology/withheld.rs (each_relation_that_is_no_equivalence_is_withheld_with_its_line) (#926); a code in no preferred system gains each equivalent or equal translation of the configured terminology server after the original coding, a refused translation fails the summary, and with no server the code is kept as recorded: app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_code_in_no_preferred_system_gains_its_equivalent_translation, a_refused_translation_fails_the_summary, with_no_server_a_code_in_no_preferred_system_is_kept_as_recorded) (#1213); the sections not supplied: app/ferrofed-eehrxf/tests/it/summary/mod.rs (the_medical_alert_and_the_functional_status_are_written_as_not_supplied) (#1344); the medication orders and the explicit placement: app/ferrofed-eehrxf/tests/it/summary/medication.rs (a_medicine_recorded_only_as_an_order_reaches_the_medication_summary, a_vaccination_and_a_medicine_each_land_in_their_own_section_by_the_vaccine_code), the placement by template section: app/ferrofed-eehrxf/tests/it/summary/placement.rs (one_composition_holding_a_medicine_and_a_vaccine_lists_each_in_its_own_section_only) (#1551), a medication action selected and listed by the section that holds it: app/ferrofed-eehrxf/tests/it/template.rs (a_medication_action_is_selected_by_the_section_that_holds_it), app/ferrofed-eehrxf/tests/it/summary/placement.rs (a_medication_action_under_the_medication_summary_is_a_medication_summary_entry), and an unread element counted and named: app/ferrofed-eehrxf/tests/it/summary/placement.rs (an_element_the_run_leaves_unread_is_counted_and_named) (#1742), app/ferrofed-eehrxf/tests/it/template.rs (each_archetype_beyond_the_template_is_a_vendored_ckm_archetype) (FerroHEALTH/FerroBRIDGE#1323); a dropped element named: app/ferrofed-eehrxf/src/summary/mod.rs (dropped_line); a composition with repeated entries carried entry by entry: app/ferrofed-eehrxf/tests/it/summary/medication.rs (a_composition_holding_two_medicines_lists_each_in_its_own_entry, a_composition_holding_a_vaccine_and_a_medicine_lists_each_in_its_own_section_only) (#1726) | S4, P1, closed: the medication action under the ad hoc Medication Summary section, the last gap that arose in normal use, is selected and listed as a medication summary entry since eehrxf 0.1.181 (FerroHEALTH/FerroBRIDGE#1696, closed; #1742), a composition holding more than one entry of a mapped archetype is carried entry by entry (FerroHEALTH/FerroBRIDGE#1254, #1726), and each control is built and held by a test; what a mapping still does not carry, a composition no mapping covers, an element a run dropped or left unread, is told to the reader in the section’s narrative, so content is left out silently only through a fault in the mapping and in its declaration of the loss, two independent faults, and the occurrence scale gives P1; the severity stays S4 as rated |
| I-04 | A code translated to one that is not equivalent: a broader or narrower code presented as the same | a clinician reads a more or less specific diagnosis than was recorded | S3, P2 | the original code is kept; only an RM TERM_MAPPING with match '=', or a terminology server answer of equivalent or equal, adds a coding; without a terminology server the component fails closed where one is required; detection: every transformation the face applies is logged on the access record of its request, with the mapping and terminology versions in force and each element transcoded or left untranscoded counted by path, never by value, so a mistranslation found later can be traced to the access, the mapping and the release that made it (eHealth Network General Guidelines Release 3.1 Art 6(3), #985) | crates/eehrxf/tests/it/terminology/transcode.rs (an_equivalent_mapping_adds_a_coding_after_the_original, a_broader_mapping_produces_no_coding_and_appears_in_the_narrative, a_translation_adds_only_equivalent_and_equal_matches, translation_without_a_server_fails_closed); app/ferrofed-server/tests/it/fhir/receive/ (a_coding_with_no_display_is_refused_with_no_terminology_server); in the summary the face writes, a broader, narrower or unknown mapping is no coding and is written into its section’s narrative, naming the member: app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_broader_term_mapping_reaches_the_narrative_and_no_coding, a_narrower_term_mapping_reaches_the_narrative_and_no_coding, an_unknown_term_mapping_reaches_the_narrative_and_no_coding, an_equivalent_term_mapping_is_a_coding_and_no_narrative_line); crates/eehrxf/tests/it/terminology/withheld.rs (each_relation_that_is_no_equivalence_is_withheld_with_its_line) (#926); in that summary, a terminology server’s translation is added only for an equivalent or equal match and a refusal is never no translation: crates/eehrxf/tests/it/terminology/translation.rs (an_equivalent_match_is_added_after_the_original_coding, no_accepted_match_leaves_the_code_as_recorded, a_refused_translation_fails_and_is_never_no_translation) (#1213); the log of each transformation: app/ferrofed-server/tests/it/fhir/transformation.rs (a_summary_records_its_transformation_by_path_with_nothing_dropped_as_zero, a_transformation_holds_no_clinical_value_code_value_or_patient_identifier), crates/eehrxf/tests/it/transformation.rs (a_coded_element_is_transcoded_only_in_a_preferred_system) | S3, P1 |
| I-05 | A demographic mismatch: the summary carries another patient’s data, or a header naming another patient | a decision for one patient made on another’s record | S4, P2 | the patient is resolved by identifier through the identity binding and never matched by demographics in the gateway; an ambiguous match is never chosen; an ehr_id two members claim is refused and raised as an incident; a PMIR merge drops the bindings it makes stale; the header comes from the identity binding | app/ferrofed-server/tests/it/pdqm/flow.rs (several_matched_patients_refuse_the_resolution_and_fail_the_query); app/ferrofed-identity/tests/it/session.rs (two_members_with_one_ehr_id_are_ambiguous_and_never_chosen_between); app/ferrofed-server/tests/it/ehr_id_collision.rs (a_probe_collision_is_a_409_naming_both_with_one_incident_and_neither_is_read); app/ferrofed-server/tests/it/pmir/feed.rs (an_authenticated_merge_drops_the_bindings_of_the_merged_ehr_ids); the header from the demographics binding, with no document for an unknown, nameless or ambiguous patient: app/ferrofed-server/tests/it/fhir/header.rs (the_header_is_the_supplier_s_and_no_header_value_reaches_a_node, a_patient_the_binding_does_not_know_is_not_found_and_nothing_is_sent, several_patients_under_the_identifier_write_no_document) | S4, P1 for the gateway; a wrong link at the identity service stays the deployment’s (threat model, risk 7) |
| I-06 | A restriction shows through the document: a section marked withheld, or a member’s absence that only a restriction explains | the patient’s restriction is visible to the provider (Art 8) | S3, P2 | emptyReason is never withheld while disclose = false; a node’s refusal and a pre-filter exclusion read as a member without the patient; a national contact point’s requests are always served with disclose = false | built on the query path: app/ferrofed-server/tests/it/consent_withheld/mod.rs (a_withheld_exclusion_reads_exactly_as_a_member_that_does_not_know_the_patient), app/ferrofed-server/tests/it/consent_withheld/node.rs (a_node_refusal_reads_exactly_as_a_member_that_does_not_know_the_patient); the document has no withheld empty reason to write (crates/eehrxf/src/document/mod.rs, EmptyReason); a national contact point’s requests: app/ferrofed-server/tests/it/contact_point/withheld.rs (an_excluded_member_and_one_without_the_patient_answer_alike, a_restricted_patient_and_an_unknown_one_answer_alike) | S3, P1 |
| I-07 | A section query carries the patient identifier to a node | a patient identifier leaks (§5.4, N33) | S3, P2 | each section is a stored query run through the rewrite and the outbound gate, so a node is asked by its ehr_id alone | built on the query path: app/ferrofed-server/tests/it/outbound.rs (either_carrier_and_a_projection_reach_a_node_as_its_ehr_id_alone), app/ferrofed-server/tests/it/hygiene.rs; the section queries (#776), each held with the patient a parameter and run by name, reach every member with no identifier in the query, path or headers: app/ferrofed-server/tests/it/stored/reserved.rs (each_section_query_runs_by_name_and_no_patient_identifier_reaches_a_node), app/ferrofed-eehrxf/tests/it/query.rs (the_patient_and_its_namespace_are_parameters_and_nothing_else_is_compared) | S3, P1 |
| I-08 | The mapping gives different output for the same input, or reads a dataset model other than the pinned one | two requests for one patient disagree, or a profile element is mapped against the wrong definition | S3, P2 | the mapping is deterministic and the dataset model reads the same whatever the order of the package; the package is the pinned one and a malformed one is refused | crates/eehrxf/tests/it/mapping.rs (the_same_composition_maps_to_the_same_bundle); crates/eehrxf/tests/it/property.rs (the_model_is_the_same_whatever_the_member_order); crates/eehrxf/tests/it/dataset.rs (the_package_is_the_pinned_one); crates/eehrxf/tests/it/refusals.rs | S3, P1 |
| I-09 | A generated summary is read as one a clinician wrote and attested, or as exhaustive | a clinician trusts it as a complete, attested record | S3, P3 | the author is the gateway’s Device and the operator’s Organization, with no attester, and every section’s narrative says it is not exhaustive | app/ferrofed-eehrxf/tests/it/summary/mod.rs (a_document_from_two_members_meets_the_eps_profiles) | S3, P2, accepted by the benefit-risk judgement under Overall residual risk (EN ISO 14971:2019 clause 7.4) |
| I-10 | A received document is written to the wrong member or under the wrong patient | a record is filed in another patient’s EHR | S4, P2 | a received document goes to the one member declared for its category, and the answer names it with the new version; a member the registry does not hold refuses the start; the patient is resolved from every identifier of the document in a declared namespace at that member alone, and identifiers that do not all name one ehr_id there, or a patient the member holds no EHR for, are refused before anything is sent; a caller confined to one patient writes only into that patient’s EHR | app/ferrofed-server/tests/it/fhir/receive/ (a_document_is_written_to_its_member_by_its_ehr_id_alone, identifiers_that_name_two_patients_are_refused, a_patient_the_member_holds_no_ehr_for_is_refused, a_caller_confined_to_one_patient_writes_only_into_its_ehr, a_member_the_registry_does_not_hold_refuses_the_start); a wrong link at the identity service stays the deployment’s (threat model, risk 7) | S4, P1 |
| I-11 | A received document is stored with content lost: the mapping carries only part of it, or a document that breaks its profiles is taken in | a later reader misses an allergy or a medication the sender recorded | S4, P3 | the document is decoded against FHIR R4 and held to the R4 document rules, then validated in full by FerroBRIDGE’s ferrobridge-validator against its category’s Bundle profile over the package’s whole dependency closure: every constraint of that profile, of the profile each entry’s slice names and of each profile an entry claims, every invariant of the core definitions and the profiles, and the terminology bindings, an error refused with 422 before any member is asked, a required binding the terminology server does not answer refused with 502, and every constraint the validator met and did not evaluate named as a warning in the answer (decision A106, #808); the constraints left unevaluated are of the kinds the validator lists: an invariant whose expression calls a function it cannot answer (subsumes(), elementDefinition(), slice()) or evaluates to empty, a value set no package enumerates with no terminology server configured, a reference to a resource outside the document, and an extension context it cannot decide; the original document is kept inline in the composition’s FEEDER_AUDIT.original_content, and the losses a run declares travel beside the composition; detection: every receipt’s transformation is logged on its access record, with the mapping, the package and the terminology in force and each element transcoded, left untranscoded or dropped counted by path, never by value, so a lost element found later can be traced to the receipt and the mapping that lost it (eHealth Network General Guidelines Release 3.1 Art 6(3), #985) | crates/eehrxf/tests/it/receive/read.rs (a_property_r4_does_not_define_is_refused_with_its_path, a_document_whose_first_entry_is_no_composition_is_refused); crates/eehrxf/tests/it/receive/conform.rs (a_missing_required_section_is_found, a_closed_slicing_finds_every_section_no_slice_admits, a_pattern_form_the_model_does_not_read_is_listed_as_not_evaluated); crates/eehrxf/tests/it/receive/openehr.rs (a_document_maps_to_one_composition_that_keeps_the_original); the full validation, over documents that break an invariant, a required binding and an entry profile: app/ferrofed-eehrxf/tests/it/receive.rs; the gateway refuses a document the component does not read before any member is asked: app/ferrofed-server/tests/it/fhir/receive/ (a_document_the_component_does_not_read_is_refused_before_any_member, a_coding_with_no_display_is_refused_with_no_terminology_server); the log of each receipt’s transformation: app/ferrofed-server/tests/it/fhir/transformation.rs (a_receipt_records_its_transformation_by_path_with_the_loss_it_declared, a_transformation_shares_the_request_id_of_its_access_record), crates/eehrxf/tests/it/transformation.rs (each_declared_loss_is_dropped_at_the_path_it_names_without_a_value) | S4, P1 |
| I-12 | Double dispensing: an imported copy is listed as dispensable, a dispensation reaches a member holding a copy, two pharmacies dispense at once, a client retries after a time-out, or a member silent during the owner lookup leaves the dispensation sent elsewhere | a patient receives more than was prescribed | S4, P2 | the owner is the member whose own endpoint serves the prescription (native) or whose system_id created it, with feeder_audit entries never listed (map mode); every member holding the patient must answer the lookup, and a silent one refuses the dispensation; two members owning the identifier are refused 409 with an integrity incident and nothing written; the member refuses a dispensation its state no longer allows and the gateway passes the refusal through as 409, holds no lock and never resends a write, and a time-out answers 504 saying the outcome is unknown; an identifier at two members is listed once per member with a warning; the instructions for use ask a member to refuse a second dispensation carrying an identifier it already holds (decision A102); a received dispensation carries its business identifier, and the owner’s dispensations of the patient are read before the write: one with that identifier refuses the dispensation 409 with nothing written, and a read that fails refuses it too, with no lock and no count at the gateway (A102) | the search (#1040) lists an identifier at two members once per member with a warning: app/ferrofed-server/tests/it/fhir/prescriptions/mod.rs (a_prescription_identifier_two_members_hold_is_listed_once_per_member_and_warned); the receipt (#1042) writes at the owner alone, refuses with a silent member and with two owners, passes the owner’s refusal through and never sends a write twice: app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchanged, a_member_silent_at_the_lookup_refuses_the_dispensation, a_prescription_two_members_hold_is_refused_with_an_integrity_incident, the_owners_refusal_is_a_conflict_naming_it, an_owner_that_does_not_answer_is_asked_once_and_the_outcome_is_unknown); the duplicate check: app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (a_dispensation_the_owner_already_holds_is_refused_as_a_duplicate_with_nothing_written, another_dispensation_of_the_patient_is_no_duplicate_and_is_written, an_owner_that_does_not_answer_the_duplicate_check_writes_nothing, a_dispensation_with_no_business_identifier_is_refused_naming_the_element) (#1346) | S4, P1; the member’s contract and concurrent dispensations at the member stay the deployment’s and the member’s (instructions for use) |
| I-13 | A missed prescription: a member that holds it is silent or declared for no category, the lookup does not find its identifier, or it is restricted | a valid prescription is not dispensed, or a dispensation report is lost | S3, P3 | the search is all-or-nothing by default and names every silent member under partial (N37, §11.1); a deployment that does not serve prescriptions answers so, never with an empty list (A96); a dispensation with no owner is refused 404, so the national contact point reports it back (Art 23(6)); no member failure becomes a success | the read names a silent member and never answers an empty one: app/ferrofed-server/tests/it/fhir/prescriptions/read.rs (a_silent_member_is_named_and_nothing_is_answered); the not-served answer (#1039) is never an empty list: app/ferrofed-server/tests/it/fhir/serving/mod.rs (with_no_member_declared_every_route_answers_not_served_and_asks_no_member, a_served_category_is_never_answered_as_not_served); the search (#1040) fails under all-or-nothing and names every silent or unresolved member under partial: app/ferrofed-server/tests/it/fhir/prescriptions/mod.rs (a_silent_member_fails_the_search_and_is_named, under_partial_a_silent_member_is_named_in_an_outcome_entry), app/ferrofed-server/tests/it/fhir/prescriptions/resolution.rs (a_member_the_cross_reference_could_not_answer_for_fails_the_search_and_is_named); a prescription the read cannot reach is warned of: app/ferrofed-server/tests/it/fhir/prescriptions/read.rs (a_prescription_with_no_id_to_read_it_by_is_listed_and_warned_of); a dispensation with no owner is refused 404 as an unknown patient is, and a silent member refuses it: app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (no_owner_answers_as_an_unknown_patient_does, a_member_silent_at_the_lookup_refuses_the_dispensation) | S3, P1 |
| I-14 | A dispensation filed against the wrong prescription or patient: a prescription identifier two members hold, a reference and an identifier that disagree, or identifiers that name two patients | a dispensation recorded on another prescription or in another patient’s record | S4, P2 | two owners are refused 409 with an incident, two matches at one member 409, and a dispensation whose prescriptions have different owners 422, nothing written in each; a reference and an identifier must name the same prescription, and a prescription named by the face’s reference alone is refused, since its member receives the body unchanged; the patient is resolved as a received document’s is, and identifiers that name two patients are refused before any member is asked (decision A102) | the read’s id names its member and is asked of it alone, by its ehr_id, and an ehr_id two members claim is refused 409 with the incident: app/ferrofed-server/tests/it/fhir/prescriptions/read.rs (a_prescription_the_search_lists_is_read_at_its_member_as_sent, an_ehr_id_two_members_claim_refuses_the_read_with_one_incident, a_member_answering_two_prescriptions_for_one_id_is_a_conflict_with_no_incident); the receipt refuses two owners, two matches at one member, owners that differ and a reference and an identifier that disagree, writing nothing: app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (a_prescription_two_members_hold_is_refused_with_an_integrity_incident, two_matches_at_one_member_are_refused_with_no_incident, prescriptions_held_by_different_members_write_nothing, a_reference_and_an_identifier_that_disagree_write_nothing, a_dispensation_naming_its_prescription_by_the_faces_reference_alone_is_refused) | S4, P1 |
| I-15 | A prescription read as signed when it is not | a medicine dispensed on a prescription its prescriber never signed | S3, P2 | the gateway signs nothing and adds no author or attester; the prescriber’s signature travels only as the member supplies it, and each prescription is passed through unchanged with a Provenance naming its member (decision A102) | the read answers the member’s prescription unchanged but for its id, naming the member: app/ferrofed-server/tests/it/fhir/prescriptions/read.rs (a_prescription_the_search_lists_is_read_at_its_member_as_sent); the search (#1040) does the same, with one Provenance naming the member: app/ferrofed-server/tests/it/fhir/prescriptions/mod.rs (each_members_prescriptions_are_answered_as_sent_with_a_provenance_naming_it); a dispensation reaches its owner as it was sent: app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs (a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchanged) | S3, P1 |
| I-16 | A received laboratory report is filed with a result the record does not hold whole: under another patient, with its unit or its reference range lost, or read as the answer to the wrong order | a clinician reads a result against no range, or as the answer to another order, and treats on it | S4, P3 | the receipt’s controls hold for the category unchanged: the patient resolved as I-10 says, every subject reference of every entry held to the document’s one Patient entry and every other reference resolved inside the document or refused (eehrxf receive::references), and the document held by ferrobridge-validator to the HL7 Europe Laboratory Report 2.0.0 Bundle-eu-lab and Composition-eu-lab profiles as I-11 says; each loss the mapping run declares is relayed in the answer’s OperationOutcome and counted by path on the access record; a result’s valueQuantity maps with its unit, and a quantity with neither code nor unit fails the mapping (fhirconnect 0.1.212, engine/lens/quantity.rs); the gateway attaches the report to no order at the member, and the composition keeps the document byte for byte in FEEDER_AUDIT.original_content; since eehrxf 0.1.149 a result’s normal reference range is carried as the normal_range of its quantity and the order filler’s identifier as the order the template holds, and a range text, Observation.basedOn and an order named by reference alone are declared lost, each loss relayed and counted as above (FerroHEALTH/FerroBRIDGE#1481, closed); since eehrxf 0.1.183 a typed range, one whose Observation.referenceRange.type is set, is carried as one of the quantity’s other_reference_ranges, its type as the range’s meaning (FerroHEALTH/FerroBRIDGE#1694, closed by FerroHEALTH/FerroBRIDGE#1704); at the gateway, a received report’s normal range reaches the normal_range of the result’s quantity in the composition written at the member, a typed range one of its other_reference_ranges with its coded meaning, and the order filler’s identifier its protocol, with nothing declared lost | crates/eehrxf/tests/it/lab_mapped.rs (a_received_laboratory_report_is_of_its_category_and_conforms, a_received_laboratory_report_maps_into_one_composition_that_keeps_it, the_report_mapped_back_out_is_a_document_the_profiles_constrain, a_document_typed_otherwise_is_no_laboratory_report); the receive path’s tests I-10 and I-11 name, which every category takes; end to end at the gateway, a synthetic report validated in full and written at its member: app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_laboratory_report_is_written_to_its_member_by_its_ehr_id_alone, a_laboratory_report_that_breaks_its_profiles_reaches_no_member, a_laboratory_report_of_a_patient_no_member_holds_is_refused, a_received_normal_range_and_order_reach_the_members_composition) (#1668); a typed range written at the member: app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_received_typed_reference_range_reaches_the_members_composition) (#1754) | open at S4, P3: a sender’s range text, Observation.basedOn and an order named by reference alone arise in normal use and reach the member’s structured record only in the original document; a typed range no longer does; the loss is declared to the sender and counted on the access record, but a reader of the member’s record is not told, so the hazardous situation still arises in normal use and the occurrence scale gives P3; the KDS_Laborbericht template holds no element for them, and severity stays at S4 as rated |
| I-17 | A prescription mapped in the map mode differs from the member’s order: an order another system created, or an imported copy, listed as the member’s own; an element of the order lost in the mapping; or the prescription named under another patient | a pharmacist dispenses on a prescription the member does not own, or on a dose or product the order does not state, or for another patient | S4, P3 | only a composition whose uid’s creating_system_id is the member’s system_id, with no feeder_audit, is listed or read as its prescription (A102 point 1); the order is mapped by the MPD project eehrxf ships, compiled against the member’s declared template at start, a template it does not compile against refusing the start, and a composition that does not map fails the search under all-or-nothing, never an empty list; each loss the run declares travels as an outcome entry of the searchset; the patient is the one the search names, or on a read the one the identity binding names behind the member’s ehr_id, never one the gateway matches; each mapped prescription carries one Provenance naming its member; the gateway signs nothing, and a mapped prescription carries no signature (I-15) | the own-order filter, the mapping and the Provenance: app/ferrofed-server/tests/it/fhir/prescriptions/mapped.rs (a_map_mode_members_own_orders_are_mapped_beside_a_native_members, a_mapped_id_whose_composition_another_system_created_has_no_owner); a silent member: (a_silent_map_mode_member_fails_the_search_under_all_or_nothing); the read at its member alone: (a_mapped_prescription_is_read_at_its_member_by_its_composition); the mapping and its refusals: app/ferrofed-eehrxf/tests/it/mapped.rs (an_order_composition_maps_to_one_mpd_prescription_with_its_product, a_template_the_mpd_project_does_not_map_is_refused); the start refused: app/ferrofed-server/tests/it/fhir/serving/start.rs (a_map_mode_member_whose_template_the_mpd_project_does_not_map_is_refused) (#1560) | S4, P2; the member’s order as recorded and the completeness of the shipped MPD mapping stay the member’s and the component’s (FerroHEALTH/FerroBRIDGE#1322) |
| I-18 | A laboratory report the face writes from a member’s composition is read as more than the member records: under another patient, from a silent member read as no report, with its unit or its reference range lost, or a copy listed twice | a clinician reads a result against no range, misses a report a silent member holds, or reads one result as two | S4, P3 | the list asks every member the patient resolves at and is all-or-nothing by default, every silent member named under partial (A65); a composition two members hold under one version id is listed once (§10.3); the report is read at the member the signed URL names, by its ehr_id alone, and its Patient is the identifier the identity binding names behind that ehr_id, never one the gateway matches, a read with none refused 424, with the demographics binding’s header and each name or birth date it does not hold written absent, never filled in; the report is written by the shipped laboratory mapping, a result’s valueQuantity with its unit, a composition that does not map refused 424 and never answered empty; one Provenance names the member, and the gateway’s Device and the operator author the document; a member’s normal_range is written as the result’s Observation.referenceRange and the order filler’s identifier the template holds as the report’s DiagnosticReport.basedOn; each of a result’s other_reference_ranges is written as a further Observation.referenceRange whose type is the range’s meaning, its term mappings as codings and its text as the text, so a range the member names by text alone keeps its text (FerroHEALTH/FerroBRIDGE#1694, closed by FerroHEALTH/FerroBRIDGE#1704) | the list and the read: app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_members_laboratory_composition_is_listed_and_read_as_a_laboratory_report, a_member_silent_at_the_laboratory_query_fails_the_list_and_is_named) (#1556); the normal range and the order served: app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_served_laboratory_report_carries_the_normal_range_and_the_order) (#1668); each other reference range served with its type: app/ferrofed-server/tests/it/fhir/receive/laboratory.rs (a_served_laboratory_report_carries_each_typed_reference_range_with_its_type, a_served_reference_range_named_by_text_alone_keeps_its_text) (#1754); the written report held to Bundle-eu-lab by FerroBRIDGE’s validator: app/ferrofed-eehrxf/tests/it/laboratory.rs (a_written_laboratory_report_conforms_to_bundle_eu_lab, a_laboratory_report_of_a_patient_the_binding_holds_nothing_of_conforms) (#1777) | closed at S4, P1: the gap that kept it at P3, a range the member records under other_reference_ranges served with nothing declared, has a built control held by a test in each form the member may name the range by, and no other way the report loses a unit or a range the member records arises in normal use; a range or unit is left out only through a fault in the shipped mapping that its own tests miss, so the occurrence scale gives P1; the severity stays S4 as rated (FerroHEALTH/FerroBRIDGE#1694, closed) |
| I-19 | A discharge report the face writes from a member’s composition is read as more than the member records: under another patient, from a silent member read as no report, a diagnosis, a procedure or a discharge medicine lost in the mapping, a hospital course read as recorded, or a copy listed twice | a clinician continues care without a diagnosis or a discharge medicine the stay recorded, or reads the gateway’s sentence as the hospital’s course | S4, P3 | the list asks every member the patient resolves at and is all-or-nothing by default, every silent member named under partial (A65); a composition two members hold under one version id is listed once (§10.3); the report is read at the member the signed URL names, by its ehr_id alone, and its Patient is the identifier the identity binding names behind that ehr_id, a read with none refused 424, with the demographics binding’s header and each name or birth date it does not hold written absent, never filled in, a binding that cannot answer refusing the read; the report is written by the shipped HDR mapping over the template [[fhir.report]] declares, a composition that does not map refused 424 and never answered empty; the hospital course section says the record holds none the gateway writes; one Provenance names the member for every mapped entry, and the gateway’s Device and the operator author the document; a discharge medicine’s timing the mapping leaves out named by its openEHR path in the report: app/ferrofed-eehrxf/tests/it/discharge.rs (an_element_the_mapping_leaves_out_is_named_in_the_report_by_path) (#1826) | app/ferrofed-eehrxf/tests/it/discharge.rs (a_discharge_composition_is_written_as_a_hospital_discharge_report, the_patient_is_named_by_identifier_and_header_and_the_member_by_provenance); the written report held to bundle-eu-hdr by FerroBRIDGE’s validator over the HDR 1.0.0 closure, with the demographics binding’s header and with none: app/ferrofed-eehrxf/tests/it/discharge.rs (a_written_discharge_report_conforms_to_bundle_eu_hdr, a_report_of_a_patient_the_binding_holds_nothing_of_conforms_to_bundle_eu_hdr) (#1777); app/ferrofed-server/tests/it/fhir/receive/discharge.rs (a_members_discharge_composition_is_listed_and_read_as_a_discharge_report, a_member_silent_at_the_discharge_query_fails_the_list_and_is_named) (#1405); every section of a full composition served, two diagnoses, two procedures and two discharge medicines, each with every element the mapping reads: app/ferrofed-server/tests/it/fhir/receive/discharge.rs (a_full_discharge_composition_is_served_with_every_section_and_entry) (#1727) | closed at S4, P1: each section the template holds is served with its entries, and since eehrxf 0.3.2 the HDR mapping declares each element of an entry it does not read (FerroHEALTH/FerroBRIDGE#1701, closed); the hospital course section names each declared element by its openEHR path, never its value, so a reader is told the record holds more than the report shows (#1826); content is then left out silently only through a fault in the mapping and in its declaration of the loss, two independent faults, and the occurrence scale gives P1, as for I-03; the severity stays S4 |
| I-20 | An imaging report the face writes from a member’s composition is read as more than the member records: under another patient, from a silent member read as no report, a finding or the study lost in the mapping, an empty section read as recorded, or a copy listed twice | a clinician acts on an imaging report without a finding the member recorded, or on the wrong study | S4, P3 | the list asks every member the patient resolves at and is all-or-nothing by default, every silent member named under partial (A65); a composition two members hold under one version id is listed once (§10.3); the report is read at the member the signed URL names, by its ehr_id alone, and its Patient is the identifier the identity binding names behind that ehr_id, a read with none refused 424; the report is written by the shipped imaging mapping over the template [[fhir.report]] declares, a composition that does not map refused 424; the sections it holds nothing for are empty as unavailable; one Provenance names the member, which is the document’s custodian, and the gateway’s Device, owned by the operator, authors it (#1785) | app/ferrofed-eehrxf/tests/it/imaging.rs (an_imaging_composition_is_written_as_an_imaging_report, the_member_is_named_by_provenance_and_the_patient_by_identifier); app/ferrofed-server/tests/it/fhir/receive/imaging.rs (a_members_imaging_composition_is_listed_and_read_as_an_imaging_report, a_member_silent_at_the_imaging_query_fails_the_list_and_is_named) (#1406); the report, the study and the impression served with every element the mapping reads: app/ferrofed-server/tests/it/fhir/receive/imaging.rs (an_imaging_composition_is_served_with_the_report_its_study_and_impression) (#1727); a finding the mapping leaves out named by its path, and the report naming it held to BundleReportEuImaging: app/ferrofed-eehrxf/tests/it/imaging.rs (a_finding_the_mapping_leaves_out_is_named_in_the_report_by_path, a_report_naming_a_lost_finding_conforms_to_bundle_report_eu_imaging) (#1826) | closed at S4, P1: the study and the impression are served, each imaging diagnosis as DiagnosticReport.conclusionCode, and since eehrxf 0.3.2 the mapping declares a composition’s findings, differential, comparison, clinical summary, recommendation and comment as unread (FerroHEALTH/FerroBRIDGE#1700); the imaging study section names each declared element by its openEHR path, never its value, so a reader is told the record holds more than the report shows (#1826); content is then left out silently only through a fault in the mapping and in its declaration of the loss, two independent faults, and the occurrence scale gives P1, as for I-03; the severity stays S4 |
| I-21 | A received discharge report is filed with content the record does not hold whole: under another patient, with a diagnosis, a procedure or a discharge medicine lost in the mapping, or with its hospital course or another section the template holds no place for kept only in the original document | a later reader of the member’s record continues care without the hospital course or a diagnosis’s severity the sender recorded, and treats on what is left | S4, P3 | the receipt’s controls hold for the category unchanged: the patient resolved as I-10 says, every subject reference of every entry held to the document’s one Patient entry and every other reference resolved inside the document or refused (eehrxf receive::references), and the document held by ferrobridge-validator to the HL7 Europe Hospital Discharge Report 1.0.0 bundle-eu-hdr and composition-eu-hdr profiles over the package’s dependency closure as I-11 says; the shipped HDR mapping carries the stay’s admission and discharge and, for each diagnosis, procedure and discharge medicine, every element it reads: a diagnosis’s code, onset, recognition, body site and comment, a procedure’s code, time, body site and comment, and a medicine’s product, directions and comment; each loss the mapping run declares is relayed in the answer’s OperationOutcome and counted by path on the access record, and the composition keeps the document byte for byte in FEEDER_AUDIT.original_content; since eehrxf 0.3.2 the HDR project declares each section of a received report none of whose entries the run carries, and each element of a diagnosis, procedure or discharge medicine it carries into no structured element (FerroHEALTH/FerroBRIDGE#1820), and the gateway compiles every received category’s mapping with its shipped project’s declarations, so the hospital course is relayed to the sender as not carried (#1827) | end to end at the gateway, a synthetic report validated in full and written at its member: app/ferrofed-server/tests/it/fhir/receive/discharge_received.rs (a_discharge_report_is_written_to_its_member_by_its_ehr_id_alone, a_discharge_report_that_breaks_its_profiles_reaches_no_member, a_discharge_receipt_is_recorded_once_as_a_creation_of_its_category) (#1714); a full report, a hospital course and two entries in each mapped section with every element the mapping reads, carried element by element, its hospital course held in the original document alone, and a diagnosis’s severity and certainty relayed as not carried: a_full_received_discharge_report_reaches_the_members_composition_but_its_course (#1794); the hospital course relayed as not carried, by its section: the same test (#1827); the receive path’s tests I-10 and I-11 name | open at S4, P3: the hospital course, which composition-eu-hdr cmp-hdr-2 makes nearly every report carry, every other section the template holds no place for, and a diagnosis’s severity and certainty reach the member’s structured record only in the original document; each is declared to the sender and counted on the access record, but a reader of the member’s record is told of none, so the hazardous situation still arises in normal use and the occurrence scale gives P3, as for I-16; severity S4, as I-16; the same loss for a received imaging report is I-22 |
| I-22 | A received imaging report is filed with content the record does not hold whole: under another patient, with the study or the impression lost in the mapping, or with its report identifier, its performing organization or a section’s narrative kept only in the original document | a later reader of the member’s record acts on the impression without the report’s identifier or the organization that performed the study, and cannot trace the report or ask its performer | S4, P3 | the receipt’s controls hold for the category unchanged: the patient resolved as I-10 says, every subject reference of every entry held to the document’s one Patient entry and every other reference resolved inside the document or refused (eehrxf receive::references), and the document held by ferrobridge-validator to the HL7 Europe Imaging Report 1.0.0-ballot BundleReportEuImaging and CompositionEuImaging profiles over the package’s dependency closure as I-11 says; the shipped imaging mapping carries the examination, the modality, the study start, the observation time, the status, the impression, the study description and its Study Instance UID, and the performing organization (DiagnosticReport.performer:organization, an organization-eu-core) into the Imaging service slot of OBSERVATION.imaging_exam_result.v1 as a CLUSTER.organisation.v1 with its name and identifiers (FerroBRIDGE’s imaging report template at v0.0.24, eehrxf 0.6.6, FerroHEALTH/FerroBRIDGE#1878); the composition keeps the document byte for byte in FEEDER_AUDIT.original_content; the gateway compiles the received mapping with the declarations of the IMAGING project eehrxf ships (#1827), so each element of the report and its study the mapping does not carry, and each section none of whose entries it reads, is declared to the sender as an incomplete issue of the answer’s OperationOutcome (eehrxf 0.6.6, Project::accounted, FerroHEALTH/FerroBRIDGE#1827, FerroHEALTH/FerroBRIDGE#1875), a performer that is no Organization among them (FerroHEALTH/FerroBRIDGE#1888) | end to end at the gateway, a synthetic report validated in full and written at its member: app/ferrofed-server/tests/it/fhir/receive/imaging_received.rs (an_imaging_report_is_written_to_its_member_by_its_ehr_id_alone, an_imaging_report_that_breaks_its_profiles_reaches_no_member, an_imaging_receipt_is_recorded_once_as_a_creation_of_its_category), and each element the mapping reads carried, the performing organization in the structured composition, the report identifier held in the original document alone and declared to the sender, and no carried element declared: a_received_imaging_report_reaches_the_members_composition_and_declares_what_it_loses; a Practitioner performer beside the organization declared to the sender and the organization not: a_performer_that_is_no_organization_is_declared_to_the_sender (#1716, #1838, #1860); the receive path’s tests I-10 and I-11 name | open at S4, P3: the performing organization now reaches the member’s structured record, so a reader can ask the performer; the report identifier, a performer that is no organization and every section’s narrative still reach it only in the original document; each is declared to the sender, but a reader of the member’s record is told of none, so the hazardous situation still arises in normal use for the identifier and the occurrence scale gives P3, as for I-21; severity S4, as I-21 |
The section queries
Each patient summary section is fed by a stored query the gateway holds
read-only, eu.ferrofed.eehrxf::patient-summary-{section} at version
1.0.0, which selects every whole composition that contains one of the
section’s archetypes, with its template id, from every member
(the gateway’s own queries).
The archetypes are those the openEHR International Patient Summary template
of the openEHR international CKM (cid 1013.26.376, asset version 1, DRAFT)
names in the template section that carries the patient summary section. The
safety review of 2026-10-25 (#1327) confirmed each section’s selection
against its template section, but for one finding: the medication summary
also selects openEHR-EHR-INSTRUCTION.medication_order.v3, which a CDR commonly keeps the
current medication list as (FerroHEALTH/FerroBRIDGE#1323).
| Section (Xt-EHR) | eHN | Template section | Archetypes |
|---|---|---|---|
allergiesAndIntolerances | A.2.1.1 | Allergies & Intolerances | EVALUATION.adverse_reaction_risk.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2 |
problems | A.2.3.1 | Problem List, Past History of Illnesses | EVALUATION.problem_diagnosis.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2 |
medicationSummary | A.2.4 | Medication Summary | ACTION.medication.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2, and beyond the template INSTRUCTION.medication_order.v3 |
medicalDevicesAndImplants | A.2.3.2 | Medical Devices | EVALUATION.device_summary.v0 |
procedures | A.2.3.3 | History of Procedures | ACTION.procedure.v1, EVALUATION.absence.v2, EVALUATION.exclusion_global.v1 |
immunisations | A.2.2.1 | Immunizations | ACTION.medication.v1, EVALUATION.absence.v2 |
socialHistory | A.2.5 | Social History | EVALUATION.tobacco_smoking_summary.v1, EVALUATION.alcohol_consumption_summary.v1 |
pregnancyHistory | A.2.6 | Pregnancy | EVALUATION.pregnancy_summary.v0, EVALUATION.estimated_date_delivery.v0, OBSERVATION.exclusion_pregnancy.v0 |
advanceDirectives | A.2.7.2 | Advanced Directives | EVALUATION.advance_care_directive.v1, EVALUATION.limitation_of_treatment.v0 |
observationResults | A.2.8 | Diagnostic Results, Vital Signs | OBSERVATION.laboratory_test_result.v1, OBSERVATION.imaging_exam_result.v0, OBSERVATION.body_weight.v2, OBSERVATION.height.v2, OBSERVATION.respiration.v2, OBSERVATION.pulse.v2, OBSERVATION.body_temperature.v2, OBSERVATION.head_circumference.v1, OBSERVATION.pulse_oximetry.v1, OBSERVATION.body_mass_index.v2, OBSERVATION.blood_pressure.v2 |
carePlans | A.2.9 | Plan of Care | ACTION.care_plan.v0, INSTRUCTION.service_request.v1 |
Each archetype id is openEHR-EHR- followed by the name in the table. The
Xt-EHR observation results are “measurements, laboratory results, anatomic
pathology results, radiology results or other imaging or clinical
results”, so they take the template’s vital signs as well as its diagnostic
results. Four crosswalk sections have no query:
- The medical alert (A.2.1.2), a gap. The template has no alert section, and no openEHR content is named to feed it.
- The functional status (A.2.3.4), a gap. The template’s Functional
Status section names only
EVALUATION.problem_diagnosis.v1andEVALUATION.clinical_synopsis.v1, which do not tell a functional status from any other problem or impression, so a query on them would put every problem in the section. - The travel history (A.2.7.1). The template has no travel section.
- The patient story. The section carries a note alone and no entry.
The selection is a superset for three reasons the review weighs. The
exclusion and absence statements are offered in several sections, so a
composition with “no known allergies” is selected for every section that
offers them, and the mapping places each statement by its content. The
template records both a medication statement and an immunisation statement
in ACTION.medication.v1, so the medication and immunisation queries select
the same compositions. The problem list and the past history share
EVALUATION.problem_diagnosis.v1.
Shared hazards
| Id | Hazardous situation | Harm | Before | Control | Implemented by | After |
|---|---|---|---|---|---|---|
| X-01 | One component changes the other’s output or behaviour (Art 30(1)(b)) | an access goes unrecorded, or a document changes, because of the other component | S3, P2 | two crates with no edge between them; only the composition root links both; the engine compiles neither; on the running gateway, an access the FHIR face serves is recorded with the caller, the patient, the members, the data and the outcome the same access over the ITS-REST face is recorded with, and the document list’s record is the same whatever entries the face writes; the patient summary and the composition a received document becomes are the same bytes with no access log and whether the repository takes the record, refuses it into the spool or holds its answer, and a sink that refuses the record refuses the access with none of the document | app/ferrofed-engine/tests/it/architecture.rs (the_two_harmonised_components_are_independent_of_each_other, no_crate_but_the_composition_root_links_both_components, the_engine_compiles_neither_component_and_no_fhir); app/ferrofed-server/tests/it/fhir/independence.rs (a_summary_is_recorded_as_the_same_query_over_its_rest_is, a_receipt_is_recorded_as_the_same_write_over_its_rest_is, a_list_is_recorded_alike_whatever_entries_the_face_writes_of_the_documents, the_summary_is_the_same_document_whatever_the_access_log_sink_does, a_received_document_is_written_the_same_whatever_the_access_log_sink_does) | S3, P1 at the crate and the server level |
The completeness default, re-examined
The interoperability component’s document is all-or-nothing by default, with a partial document on request and every silent member named, as §11 sets for a federated query. That default was to be re-examined here.
It holds. I-01 is the most severe hazard a federated summary adds, and the default makes the summary fail visibly when a member that may hold the patient’s data is silent. A partial document is no less safe than a partial query answer as long as every silent member is named in the document and no section claims “no information” for a member that did not answer, which are I-01’s controls. The cost is L-02’s kind: an answer withheld while one member is down. The claims review assessed that cost against Annex II, point 2.5, and found the partial answer one header away.
Overall residual risk
- Logging component: L-01, L-02, L-03, L-05, L-06, L-08 and L-09 are controlled and tested; L-09 depends on the deployment naming its emergency purposes. L-04 is controlled at S2, P2: the record carries the professional and the assurance level as far as the issuer’s tokens state them. L-07 is controlled at S3, P1 for the records the gateway writes, which are reviewed with ITI-81 at the deployment’s Audit Record Repository and each state their period; keeping them for that period and limiting who reads them is the repository’s, at S3, P2, which the instructions for use name. The component records every access the gateway serves today.
- Interoperability component: I-01, I-02, I-04 to I-08 and I-10 to
I-15 are controlled at P1, I-05 for the gateway’s part. I-09 is accepted
at S3, P2 by the benefit-risk judgement below. I-03 is closed at S4, P1:
a medication action under the ad hoc Medication Summary section is
selected and listed, a composition holding more than one entry of a
mapped archetype is carried entry by entry, and what a mapping does not
carry is told in the section’s narrative. I-16 is open: a received
laboratory result’s normal range, typed reference ranges and order reach
the member’s composition, but its range text,
Observation.basedOnand an order named by reference alone reach the member’s record only in the original document. I-18 is closed at S4, P1: a served report carries the member’s normal range, each range recorded underother_reference_rangeswith its type, and the order (FerroHEALTH/FerroBRIDGE#1694). I-19 and I-20 are closed at S4, P1: a served discharge report holds every section of a full composition, a served imaging report the study and the impression, and each names by its openEHR path every element of the member’s record its mapping declares it did not carry (#1826). I-21 is open at S4, P3: a received discharge report’s diagnoses, procedures and discharge medicines reach the member’s composition with every element the mapping reads, but its hospital course and every section the template holds no place for reach the member’s record only in the original document, declared to the sender but told to no reader of the member’s record (#1827). I-22 is open at S4, P3: a received imaging report’s study and impression reach the member’s composition, but its report identifier, its performing organization and each section’s narrative reach the member’s record only in the original document, declared to the sender but told to no reader of the member’s record (#1827, FerroHEALTH/FerroBRIDGE#1875). So the component is not offered for its Annex II purpose. The gateway serves it on the FHIR face, which a deployment turns on with[fhir]. The overall residual risk of the component is evaluated again when those controls are in.
The benefit-risk judgement for I-09
EN ISO 14971:2019 clause 7.4 has a residual risk that the acceptability
rule does not accept, and that no further practicable control reduces,
weighed against the benefit of the intended use. I-09 is that case: a
generated patient summary may be read as one a clinician wrote and attested,
or as the whole record. Its controls are information for safety (the
author is the gateway’s Device, there is no attester, and every section’s
narrative says the summary is not exhaustive), which cannot reach P1 on its
own, and no further control is practicable: a patient summary is a summary
by definition (eHealth Network Guidelines on Patient Summary, Release 3.4),
and how a client presents it is outside the gateway.
The benefit is the one the eHealth Network guideline gives a patient summary: the essential data of a patient from the CDRs that hold them, available to a professional at an unplanned contact, where the alternative is no summary at all. The harm is a decision taken on a summary read as the whole record, which the narrative of every section speaks against. The judgement is that the benefit outweighs the residual risk of S3, P2, and I-09 is accepted at that rating. The safety review of 2026-10-25 (#1327) proposed the judgement, and the owner accepted it.
The instructions for use name each open hazard a professional user needs to know (Art 28(b)).
Review and change
- At each release. The release cut reviews this file when the release
changes either component, its mapping pins or the query path it rides
(
docs/release.md, Before the tag), and records the review below. A version whose components differ from the previous release is not cut until a row below names it as its release (Changes to the harmonised components). A change to the section catalogue or the mapping pin is not merged without a review of I-01 to I-05. - From the field. Every complaint and every possible serious incident is checked against this file, and a new hazard or a control that failed enters it (Complaints and incidents).
- When an implementing act is adopted. The exchange format (Art 15(1)) and the common specifications (Art 36(1)) can change what a component must do; their adoption reopens the hazards they touch.
| Reviewed on | Release | Change | Hazards touched |
|---|---|---|---|
| 2026-10-06 | before 0.0.10 | file opened | all |
| 2026-10-06 | before 0.0.10 | the patient summary section catalogue: the crosswalk of eehrxf 0.0.4 (#777), with the medical alert and the functional status open | I-03; I-01, I-02, I-04 and I-05 unchanged, because no document is built yet |
| 2026-10-06 | before 0.0.10 | the section queries (#776): one read-only stored query per section, selecting by the archetypes the openEHR International Patient Summary template names (pending the clinical review), with the medical alert, the functional status, the travel history and the patient story unselected | I-07 controlled; I-03 (the selection and its gaps); I-01, I-02, I-04 and I-05 unchanged, because no document is built yet |
| 2026-10-15 | 0.0.10 | the release cut: the access log (crates/ehds-logging 0.0.1 to 0.0.15), with the professional, the assurance level, the client’s address, retention, emergency access and coded categories (#623, #742, #722, #795, #659, #824); the FHIR face, its patient summary, document list and receipt (crates/eehrxf 0.0.1 to 0.0.8; #809, #810, #663, #802); the terminology rules and the validator lane (#687, #688); every node query bounded by an explicit fetch (#868) | L-04 controlled, S2, P2; the controls that had shipped and were still marked planned in I-03, I-05, I-06 and I-11 now cite their tests, with their ratings unchanged; X-01’s server tests, planned with #689, are not written though that issue is closed, so X-01 stays controlled at the crate level only |
| 2026-10-15 | before 0.0.11 | the server tests of the two components’ independence (#935): a summary and a receipt recorded as the same access over the ITS-REST face, a list recorded alike whatever entries the face writes, and each document the face writes unchanged by what the access log’s sink does | X-01 controlled at the crate and the server level, S3, P1 |
| 2026-10-15 | before 0.0.11 | a write’s record reserved before the write (#945): a routed write or a received document whose record has no place in the audit spool is refused before any member is asked, and a reserved record that still cannot be stored leaves the member’s answer standing and raises write-unrecorded | L-10 opened and controlled, S3, P1 for a write answered as refused after it was made and S2, P2 for a lost record; L-01 and L-02 unchanged |
| 2026-10-15 | before 0.0.11 | the review of the access log, built and tested (#962): ITI-81 searches of the records at the Audit Record Repository by the person and by the operator, the period each record states by origin and category (#795), the labels per category and origin, and the export of the identity state (#798) | L-07 controlled, S3, P1 for the records the gateway writes; the repository’s part, keeping each record for its period and limiting who reads it, S3, P2, carried by the deployment |
| 2026-10-15 | before 0.0.11 | the log of each semantic transformation the FHIR face applies (#985; crates/eehrxf 0.0.18, crates/ehds-logging 0.0.23): a summary and a receipt each carry, on the access record of their request, the direction, the category, the mapping and terminology versions in force and the elements transcoded, untranscoded and dropped, counted by path and never by value (eHealth Network General Guidelines Release 3.1 Art 6(3)) | I-04 and I-11 gain a detection control, their ratings unchanged, because the log finds a wrong or lost element after the fact and prevents none |
| 2026-10-15 | before 0.0.11 | the design of prescriptions and dispensations on the FHIR face (FerroHEALTH/FerroBRIDGE#1321, decision A102): pass-through from the member that owns each prescription, a dispensation written at that owner alone, and no lock or count at the gateway; nothing is built yet | I-12 to I-15 opened, their controls planned in #1040, #1041 and #1042; the clinical review of #1327 takes all four |
| 2026-10-16 | before 0.0.11 | the read of one prescription (#1041): an id naming the member’s endpoint, its ehr_id and its own id, asked of that member alone by its ehr_id, 404 alike for every id with no owner, 409 with the incident for an ehr_id two members claim, and a warning for a prescription it cannot reach; the rows brought up to date for the not-served answer (#1039) and the search (#1040) | I-12 to I-15: the not-served answer’s, the search’s and the read’s parts built; each stays open until the receipt (#1042) and the clinical review of #1327 |
| 2026-10-16 | before 0.0.11 | the receipt of a dispensation (#1042): held to the MPD profile before any member is asked, its prescription looked up at every member holding the patient and matched at the gateway, written once at the one owner with its body unchanged, 404 alike for no owner and no patient, 409 with the PrescriptionIdentifierCollision incident for two owners, 409 for the owner’s refusal and 504 with the outcome unknown | I-12 to I-15: every control the gateway holds is built; each stays open until the clinical review of #1327 |
| 2026-10-16 | 0.0.11 | the release cut: the access log (crates/ehds-logging 0.0.17 to 0.0.26) with a security label per category and origin (#797), the patients a population query read up to a bound (#819), the version a write and the EHR a create left at the member (#946, #983), information the person or a representative inserts marked as theirs (#992, #1003), each semantic transformation logged (#985) and a write’s record reserved before the write (#945); the interoperability component (crates/eehrxf 0.0.13 to 0.0.24) and the FHIR face with the prescription and dispensation datasets, the serving members, the search, the read, the version read, the receipt and the read of a dispensation (#1038, #1039, #1040, #1041, #1042, #1118, #1129, #1136), a person’s own patient summary (#991), archetype-local codes and the preferred code systems (#927, #967, #968, #977), a summary with no OperationOutcome (#1008), and a patient the cross-reference could not answer for named as not-resolved (#1104, #1107); openehr-* 0.0.87 with fhirconnect 0.1.117, whose mapping takes a received document’s category from the template (#714) | each row read against the release; the ratings are unchanged. L-03, L-06 and L-10 hold with the new record fields; I-01 and I-13 hold with the unresolved member named; I-03, I-04 and I-11 stay open on #926 and #808; I-12 to I-15 have every control the gateway holds built and stay open until the clinical review of #1327; X-01 holds at the crate and the server level |
| 2026-10-21 | 0.0.12 | the summary the FHIR face writes applies the term-mapping rule: a broader, narrower or unknown RM TERM_MAPPING, which fhirconnect 0.1.173 writes as no coding, is written into the section’s narrative with the member it came from (#926) | I-03 and I-04 read against the change: the control holds for every match code, tested per code; both stay open, I-03 on #808 and the translation of a code in no preferred system (#1213), I-04 until the clinical review of #1327; the ratings are unchanged |
| 2026-10-24 | 0.0.12 | the release cut: the logging component taken from FerroAUDIT’s published ehds-logging 0.2.3 with ihe-atna 0.2.3 in place of crates/ehds-logging 0.0.26 (#1177, #1194, #1211), its record, its sinks, its spool and its category map digest unchanged; the access path’s client authentication and SMART on openEHR grants on ferrohealth-smart 0.4.0 (#1174, #1186), every token that fails a check answered with one invalid_token challenge; the term-mapping rule of the summary (#926); the bare # admitted from the Patient entry in the reference property (#1195); openehr-* 0.0.90 with fhirconnect 0.1.173 (#1181) | each row read against the release; the ratings are unchanged. L-01, L-02, L-03 and L-10 hold with the record, the sinks and the spool the published crate carries; L-03 and L-04 hold with the accessor and the assurance level read from the token ferrohealth-smart verifies; I-03 stays open on #808 and #1213, I-04, I-11 and I-12 to I-15 until the clinical review of #1327; X-01 holds at the crate and the server level |
| 2026-10-24 | before 0.0.13 | the summary the FHIR face writes translates a code in no preferred system through the configured terminology server: each equivalent or equal match of ConceptMap/$translate is added after the original coding, a refused or unanswered translation fails the summary, and with no server the code is kept as recorded under Art 10(7) (#1213; crates/eehrxf 0.0.27) | I-03 and I-04 read against the change: the control holds, tested against a stub server and a scripted one; I-03 stays open on #808, I-04 until the clinical review of #1327; the ratings are unchanged |
| 2026-10-24 | before 0.0.13 | a received document is validated in full by FerroBRIDGE’s ferrobridge-validator 0.1.131 over its package’s dependency closure: every invariant, binding and entry profile, an error refused, an unanswered required binding refused, every constraint not evaluated named in the answer (#808, decision A106) | I-11 read against the change: its last planned control is built, tested over documents that break an invariant, a required binding and an entry profile; I-11 stays open until the clinical review of #1327; the ratings are unchanged |
| 2026-10-25 | 0.0.13 | the release cut: the logging component on FerroAUDIT’s published ehds-logging 0.2.6 with ihe-atna 0.2.6 and ihe-iti 0.0.50, every audit trail stopped on the SIGTERM drain and each forwarder closing its connection with a TLS close_notify (#1278), its record, its sinks, its spool and its category map digest unchanged; a document list that asked no member writes one access record (#1271); the interoperability component (crates/eehrxf 0.0.26 to 0.0.31) with the translation of a code in no preferred system (#1213), the terminology server asked through FerroBRIDGE’s ferrobridge-term 0.1.131 (#925), a received document validated in full by ferrobridge-validator 0.1.131 (#808) and every invariant and required binding a received dispensation’s check does not evaluate named in its answer (#1291); on the FHIR face, a read by locator id held to the consent pre-filter as the search is (#1270) and a patient looked up by identifier (#1294) | each row read against the release; the ratings are unchanged. L-01 holds with the document list’s record and the lookup that finds a patient recorded, and L-01 and L-07 hold with fewer records lost at the end of a syslog connection; I-05 holds with the lookup resolved by identifier at the gateway and a search by demographics refused; I-06 holds with the read by locator id and a restricted patient’s lookup answering as a member or a patient the gateway does not know; I-03 stays open on the medical alert and the functional status, which no openEHR content feeds yet; I-04, I-11 and I-12 to I-15 stay open until the clinical review of #1327; X-01 holds at the crate and the server level |
| 2026-10-25 | before 0.0.14 | safety review of the interoperability component (#1327), accepted by the owner without a separate clinician sign-off: every hazard read with its control, its tests and its rating, and the section queries’ selection with its gaps | I-01, I-04, I-06, I-11, I-13, I-14 and I-15 closed at P1, I-05 at S4, P1 for the gateway with the identity service’s residual kept as the deployment’s; I-09 accepted at S3, P2 by the benefit-risk judgement under Overall residual risk; I-02, I-03 and I-12 kept open, each naming its control (#1343, #1344, FerroHEALTH/FerroBRIDGE#1323, #1346); the medication summary’s selection gains openEHR-EHR-INSTRUCTION.medication_order.v3 with FerroHEALTH/FerroBRIDGE#1323 |
| 2026-10-25 | before 0.0.14 | the controls of the safety review: the probable-duplicate mark (#1343); the medical alert and the functional status written as not supplied (#1344); the medication order selected and each medication use placed by an explicit criterion (FerroHEALTH/FerroBRIDGE#1323); every element a run dropped named in the narrative (#1350) | I-02 closed at S4, P1, its control tested; I-03 stays open at S4, P3: building the controls found that a composition with two entries of a mapped archetype yields one resource that mixes them (#1350), which the narrative now names but does not prevent |
| 2026-10-25 | before 0.0.14 | the duplicate check of a received dispensation (#1346): its business identifier required, and the owner’s dispensations of the patient read before the write and matched at the gateway, a held one refused 409 with nothing written | I-12 closed at S4, P1, its control tested; A102 holds, a read with no lock |
| 2026-10-25 | before 0.0.14 | a composition holding a context’s start archetype more than once is left out of that context’s section and named in its narrative, never mapped to one resource that mixes its entries (#1350; crates/eehrxf 0.0.35) | I-03 read against the change: no resource mixes two entries; I-03 stays open at S4, P3, because such a composition is named and not carried, and one composition holding a vaccine and a medicine is placed in neither section (FerroHEALTH/FerroBRIDGE#1323) until the FHIRconnect engine writes one resource per entry |
| 2026-10-28 | 0.0.14 | the release cut: the logging component on FerroAUDIT’s published ehds-logging 0.3.1 with ihe-atna 0.3.1 and ihe-iti 0.0.66 on ferrohealth-smart 0.8.6 (#1390), each access record naming its security labels once on its ehds-categories and origin entities and no longer in meta.security, the record, the sinks, the spool and the category map digest otherwise unchanged; the interoperability component (crates/eehrxf 0.0.31 to 0.0.40) with the safety controls of the review (#1343, #1344, #1350), the duplicate check of a received dispensation (#1346), the laboratory, discharge and imaging crosswalks and document assembly, which the FHIR face does not serve yet (#1333, #1337, #1339, #1355, #1375), and ferrobridge-term and ferrobridge-validator 0.1.140; patient identity only through a PIX Manager, with no static cross-reference (#1377). No hazard’s rating changes: I-03 stays open at S4, P3 until the clinician signs off the medication order’s mapping, which moves to FerroBRIDGE with the component (FerroHEALTH/FerroBRIDGE#1323, #813) | |
| 2026-10-28 | before 0.0.15 | the interoperability component taken as the published eehrxf 0.1.128 from FerroHEALTH/FerroBRIDGE, pinned exactly, with fhirconnect 0.1.204, openehr-mapping-core 0.1.135, ferrobridge-term and ferrobridge-validator 0.1.141 and the openehr-* family at 0.0.109 (#813); the component’s code and tests are unchanged except that a resource mapped from a composition takes the FHIRconnect deterministic id of the composition uid and the entry path (FHIRconnect v1.0.0, engine/id-management.adoc, Deterministic IDs), and its evidence is cited at the FerroBRIDGE commit it was published from | each row read against the move; the ratings are unchanged, because no control changed: I-02 holds with the new resource ids, which stay distinct per member and per entry, and the component’s tests run in FerroBRIDGE’s CI |
| 2026-10-30 | 0.0.15 | the release cut: the logging component on FerroAUDIT’s ehds-logging 0.3.2 with ihe-atna 0.3.2, the access record of a routed openEHR read or write naming the object it reached as entity[data].what.identifier where it wrote a what.reference that was no FHIR literal reference (#1416); the interoperability component as the published eehrxf 0.1.128, reviewed in the row above (#813); the patient summary listing a composition two members hold under one full version id once, from the member that created it or else the lowest endpoint id, and naming in the section’s narrative the members whose copy it left out (#1428); a member’s own OperationOutcome relayed after the gateway’s when it refuses a dispensation (#1426); the FHIR face’s refusals of the shared stack kept as an OperationOutcome (#1412, #1418) | I-02 read against the change: an imported copy is no longer listed twice, two different compositions stay listed, and the copy left out is named, so I-02 holds at P1; I-12 and I-13 hold, the contact point now reading the member’s own reason a prescription was not dispensable; the access record carries the same facts with a reference an Audit Record Repository accepts. No hazard’s rating changes; I-03 stays open at S4, P3 (FerroHEALTH/FerroBRIDGE#1323) |
| 2026-11-04 | before 0.0.16 | the interoperability component as the published eehrxf 0.1.142 with fhirconnect 0.1.212, openehr-mapping-core 0.1.138, ferrobridge-validator 0.1.143 and ferrobridge-term 0.1.141 on the openehr-* family at 0.0.116 (#1432): Laboratory-Reports, Art 14(1)(e), becomes receivable at a [[fhir.receive]] member, held to the HL7 Europe Laboratory Report 2.0.0 document profiles and mapped into one KDS_Laborbericht composition | I-16 opened at S4, P3, with its planned controls on FerroHEALTH/FerroBRIDGE#1481 and #1404; I-10 and I-11 read against the new category: the receive path is the one every category takes, so both hold at S4, P1; the other ratings are unchanged |
| 2026-11-04 | before 0.0.16 | the receipt of a laboratory report end to end at the gateway (#1555): a synthetic HL7 Europe Laboratory Report 2.0.0 document validated in full against the vendored package and its closure, mapped by the component’s shipped laboratory mapping over the vendored KDS_Laborbericht template, and written at the declared member by its ehr_id alone, the report kept byte for byte | I-16 read against the change: the receive path’s controls are tested for the category; I-16 stays open at S4, P3 on FerroHEALTH/FerroBRIDGE#1481; the ratings are unchanged |
| 2026-11-04 | before 0.0.16 | the map mode of the prescription face (#1560): a member declared with mode = "map" serves its prescriptions from its openEHR medication orders, read with eu.ferrofed.eehrxf::medication-orders and mapped by the MPD project of eehrxf 0.1.142 into MedicationRequest-eu-mpd | I-17 opened at S4, P3 and reduced to S4, P2 by its controls; I-12 and I-13 read against the mode: the own-order filter and all-or-nothing hold for a map-mode member as for a native one; I-15 does not hold for a mapped prescription, which carries no signature, and I-17 says so; the other ratings are unchanged |
| 2026-11-04 | before 0.0.16 | a dispensation written in the map mode (#1561): the owner of a mapped prescription found as for a native member, the received MedicationDispense-eu-mpd mapped by the MPD project of eehrxf 0.1.142 into one composition whose ACTION names the order by INSTRUCTION_DETAILS.instruction_id, and created once with composition_create at the owner | I-12 read against the mode: the owner is the member whose system_id created the order and every member holding the patient must answer the lookup, and the duplicate check reads the dispensations the owner’s medication actions keep, so a dispensation sent again is refused 409 with nothing written (app/ferrofed-server/tests/it/fhir/prescriptions/mapped_dispense.rs: a_dispensation_the_owner_already_keeps_is_refused_with_nothing_written), though a dispensation recorded at the member outside the gateway keeps no received identifier for it to find, which stays the member’s to refuse (A102); I-13: the owner’s silence is 504 and never a success (an_owner_that_does_not_answer_is_asked_once_and_the_outcome_is_unknown); I-14: the dispensation names one prescription, matched at the gateway by its identifier or the face’s mapped id, and its ACTION names that order alone (a_dispensation_of_a_mapped_prescription_is_one_composition_at_its_owner_naming_the_order); I-15 unchanged, as I-17 says for the mapped prescription; the ratings are unchanged |
| 2026-11-04 | before 0.0.16 | the laboratory report served on the FHIR face (#1556): the document list runs eu.ferrofed.eehrxf::laboratory-report on every member and names each laboratory composition, and its signed read writes it as a Bundle-eu-lab through the laboratory mapping of eehrxf 0.1.142 (decision A114) | I-18 opened at S4, P3, its planned controls on FerroHEALTH/FerroBRIDGE#1481; I-01 and I-02 read against the list: the laboratory query is all-or-nothing and lists a copy once; the other ratings are unchanged |
| 2026-11-04 | before 0.0.16 | the interoperability component moves to eehrxf 0.1.149 with fhirconnect 0.1.218, openehr-mapping-core 0.1.141 and ferrobridge-validator 0.1.144 on the openehr-* family at 0.0.128, and the patient summary selects its medication entries by the template section crosswalk::patient_summary::SOURCES names (#1551): a medication order under an ad hoc section for the medication summary, a medication action under the vaccination list section for the immunisations | I-03 reviewed: a vaccine recorded as a medication action is no longer selected for the medication summary, so the interim control of #1350 no longer meets a composition whose vaccine and medicine share an archetype in FerroBRIDGE’s template, and one composition holding both is placed in both sections, each entry in its own; a medication statement a CDR records as a medication action under the CKM International Patient Summary template’s ad hoc “Medication Summary” section is no longer selected, a medicine missing from the summary unless the member records it as an order, so I-03 stays open at S4, P3 and the instructions for use ask a member to record current medication as orders; I-01, I-02 and I-04 to I-11 read against the family move, which changes what no node receives; the other ratings are unchanged |
| 2026-11-04 | before 0.0.16 | a member’s own laboratory reports passed through the document list (#1404): a member declared native for Laboratory-Reports is asked by its own ehr_id alone with Patient?identifier= and DocumentReference?patient=&type=, left out of the laboratory report query, and each of its DocumentReferences listed as it holds it with only each attachment URL readdressed to a signed Binary/{token} whose read answers the member’s Binary unchanged (A64, A114) | I-18 read against the change: a passed-through report is the member’s own, so the mapping’s losses I-18 names do not arise for it, a member asked at its own endpoint is asked no laboratory query, so no report is listed twice, and the list stays all-or-nothing with every silent member named; the read reaches the member the signed URL names and no other; the ratings are unchanged |
| 2026-11-04 | before 0.0.16 | the interoperability component moves to eehrxf 0.1.152 with fhirconnect 0.1.219 (#1590): a received document’s element that a status table of a manual entry carries counts as read, the imaging and discharge codings carry the HL7 Europe EU Health Data API displays, and the map mode compiles the MPD project’s request context with Project::with_context, writing no file | I-03 read against the change: an element the mapping reads is no longer reported as lost, and content no entry reads is still reported and never dropped; I-17 unchanged, since the request context compiled is the same; the ratings are unchanged |
| 2026-11-05 | 0.0.16 | the release cut: the interoperability component as the published eehrxf 0.1.152, reviewed in the rows above (#1432, #1551, #1590); the laboratory report received, served and passed through (#1555, #1556, #1404); prescriptions and dispensations in the map mode (#1560, #1561); a received dispensation withdrawn (#1468); the logging component unchanged at ehds-logging 0.3.2 | every hazard read against the release in the rows above; I-16 and I-18 stay open at S4, P3 on FerroHEALTH/FerroBRIDGE#1481, I-17 at S4, P2; no other rating changes |
| 2026-11-05 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.156 with fhirconnect 0.1.221 and openehr-mapping-core 0.1.142, and the logging component to ehds-logging 0.3.3 with ihe-atna 0.3.3 (#1605): the component’s laboratory project lists a composition context beside the laboratory result, and the gateway and the testkit compile the result context alone with Project::with_context (#1604); a slot archetype may name a model already on its slot chain, bounded by depth; the logging component’s withheld::Holds register is one the gateway does not build | I-16 and I-18 read against the change: the received and the served laboratory report are mapped by the same result context as before, and a test holds that context to one the shipped project lists; I-03 unchanged; the ratings are unchanged |
| 2026-11-05 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.158 with fhirconnect 0.1.223 and openehr-mapping-core 0.1.143 on the openehr-* family at 0.0.129 (#1581): the map mode’s operational template, built from FerroBRIDGE’s ADL 2 medication template and its ADL 1.4 archetypes, writes each 1.4 term constraint as a local at-code bound to its external code and keeps the 1.4 node codes, so it prints as ADL 2 that parses back, and it is named by the full id of its ArchetypeHrid; the component refuses at load a mapping that pairs an openEHR class with a FHIR type the data-type chapter does not pair, or that writes a reference into a choice element of a type its path does not name, and the shipped projects and the testkit’s mappings load | I-17 read against the change: the node codes the member’s compositions and the MPD project’s paths name are kept, a test holds the template to the id the compositions carry and its printed form to one that parses back as ADL 2, and the mapped prescriptions are unchanged; the ratings are unchanged |
| 2026-11-06 | before 0.0.17 | the hazards whose controls cited FerroHEALTH/FerroBRIDGE#1323 and FerroHEALTH/FerroBRIDGE#1481 read again once both closed (#1667): the received laboratory result’s normal range and order identifier carried and every other range and order declared lost since eehrxf 0.1.149 (lab_mapped/accounted.rs: a_received_reference_range_is_the_normal_range_of_the_result, a_received_order_identifier_is_the_order_the_template_holds, a_typed_reference_range_is_declared_not_carried, the_order_a_result_answers_is_declared_not_carried) | I-03, I-16 and I-18 stay open at S4, P3: I-03 keeps the composition named and not carried and the medication action not selected; I-16’s planned controls are built in the component, with no gateway test of a received range and its rating not yet re-judged; I-18’s served direction has no control or test; each is re-judged on #1668; the other ratings are unchanged |
| 2026-11-06 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.170 with fhirconnect 0.1.227, ferrobridge-validator 0.1.149 and ferrobridge-term 0.1.143, on the one ferrohealth-smart 0.9 line (#1694): Medical-Imaging, Art 14(1)(d), and Discharge-Reports, Art 14(1)(f), become receivable at a [[fhir.receive]] member, held to the HL7 Europe Imaging Report 1.0.0-ballot and Hospital Discharge Report 1.0.0 document profiles and mapped by the component’s shipped IMAGING and HDR projects | I-10 and I-11 read against the two new categories: the receive path is the one every category takes, with the same patient resolution, reference checks and full validation, so both hold at S4, P1; the loss of a received imaging or discharge report’s own content, which I-16 rates for a laboratory report, is not yet rated and is judged with the gateway half of each category (#1405, #1406); the other ratings are unchanged |
| 2026-11-06 | before 0.0.17 | a national contact point’s token checked by the MyHealth@EU contact-point profile of ferrohealth-smart 0.9.2 in place of the gateway’s own Annex check (#1632): the token carries what a conforming eHDSI HP Identity Assertion provides (eHDSI SAML Profile 10.0.0 §2.1, §2.3), bound to the connector’s client certificate (RFC 8705 §3); the professional is named by the assertion’s NameID, and no name or address reaches a node or the access record | L-03 and L-04 read again for a contact point’s caller: the accessor still comes from the verified token alone, now as the crate classified it, and the record names the professional by their identifier, with the assertion’s point of care, facility type and assertion ids beside it (app/ferrofed-server/tests/it/access/relayed.rs, app/ferrofed-server/tests/it/access/accessor.rs, a_contact_point_caller_names_the_foreign_professional_and_provider), so a reviewer still tells which professional acted; a token the profile refuses writes no record and reaches no member (app/ferrofed-server/tests/it/contact_point/admission.rs); the ratings are unchanged |
| 2026-11-08 | before 0.0.17 | the content-loss ratings of the discharge and imaging reports the face writes from a member’s composition, decided under the owner’s delegation (#1668): a served Hospital Discharge Report or Imaging Report that lacks part of the member’s composition, a diagnosis, a medication change, a finding or the impression, can lead a clinician to act on an incomplete record, the harm I-16 and I-18 rate; the mapping declares and relays what it cannot carry and the document is held to the HL7 Europe profiles, but no gateway test shows the served document holding each section of a full composition, the gap that keeps I-18 at P3 | I-19 and I-20 rated open at S4, P3, the rating I-18 carries for the served laboratory report; each is lowered by a gateway test per category showing every section of a synthetic full composition served, or declared lost and relayed, and is then re-judged with I-03, I-16 and I-18; the other ratings are unchanged |
| 2026-11-08 | before 0.0.17 | I-03, I-16 and I-18 re-read against eehrxf 0.1.170 with gateway tests of the laboratory report in both directions (#1668): a received report’s normal range and order filler’s identifier reach the composition written at the member (app/ferrofed-server/tests/it/fhir/receive/laboratory.rs, a_received_normal_range_and_order_reach_the_members_composition), and a served report carries the member’s normal range as Observation.referenceRange and the order as DiagnosticReport.basedOn (a_served_laboratory_report_carries_the_normal_range_and_the_order); a member’s other_reference_ranges are served neither written nor declared (FerroHEALTH/FerroBRIDGE#1694) | I-03, I-16 and I-18 stay open at S4, P3, each by the file’s method: I-03’s two gaps have no built control (#1726, FerroHEALTH/FerroBRIDGE#1696); I-16’s remaining losses arise in normal use and are declared to the sender, never to a reader of the member’s record, so the occurrence scale gives P3; I-18’s undeclared other_reference_ranges arise in normal use; the severity of each stays S4 as rated; the other ratings are unchanged |
| 2026-11-08 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.179 with fhirconnect 0.1.235, openehr-mapping-core 0.1.144, ferrobridge-validator 0.1.151 and ferrobridge-term 0.1.145, on openehr-* 0.0.130 (#1722): the engine pins a split node and reads an openEHR condition’s target by the repeating nodes they share with their input (FerroHEALTH/FerroBRIDGE#1647, FerroHEALTH/FerroBRIDGE#1648), keeps the emptyReason a mapping run writes on a section (FerroHEALTH/FerroBRIDGE#1658), and writes no context into a composition whose template prohibits one (FerroHEALTH/FerroBRIDGE#1692); the map mode’s operational template now names its parent archetype from openehr-adl itself (FerroEHR #5148), so the gateway’s own copy of it is removed | I-01 to I-05 read against the change: the section catalogue, the section queries and the gateway’s interim controls are unchanged, and every test the I-01 to I-05 rows cite passes unchanged on the new pins; the engine’s reading by repeating node narrows which entry a mapping reads and adds no path by which a mixed or dropped entry reaches a document; the ratings are unchanged |
| 2026-11-08 | before 0.0.17 | I-19 and I-20 re-read against eehrxf 0.1.179 with gateway tests of the discharge and imaging reports served (#1727): a full discharge composition, two diagnoses, two procedures and two discharge medicines each with every element the HDR mapping reads, is served with each section, entry and element (app/ferrofed-server/tests/it/fhir/receive/discharge.rs, a_full_discharge_composition_is_served_with_every_section_and_entry), and an imaging composition is served with its report, study and impression (app/ferrofed-server/tests/it/fhir/receive/imaging.rs, an_imaging_composition_is_served_with_the_report_its_study_and_impression); an element the mapping does not read, a diagnosis’s clinical description or an imaging finding, is served neither written nor declared (FerroHEALTH/FerroBRIDGE#1700, FerroHEALTH/FerroBRIDGE#1701) | I-19 and I-20 stay open at S4, P3, each by the file’s method: what the mapping reads is carried and a test holds it, but the content it does not read arises in normal use and no reader is told of it, so the occurrence scale gives P3; the severity of each stays S4 as rated; the other ratings are unchanged |
| 2026-11-08 | before 0.0.17 | the patient summary carries each entry of a composition that holds a context’s start archetype more than once, on the engine’s run per occurrence of eehrxf 0.1.179 (FerroHEALTH/FerroBRIDGE#1254), and the interim control of #1350 that left such a composition out and named it is retired (#1726): two medicines in two entries of one archetype are listed as two entries, each with its own medicine (app/ferrofed-eehrxf/tests/it/summary/medication.rs, a_composition_holding_two_medicines_lists_each_in_its_own_entry), and a vaccine and a medicine in one composition are each listed in their own section only (a_composition_holding_a_vaccine_and_a_medicine_lists_each_in_its_own_section_only) | I-03 re-read: the repeated-entry gap has a built control and a test, so a record is no longer named and left out; the medication action under the ad hoc Medication Summary section is still not selected (FerroHEALTH/FerroBRIDGE#1696) and has no built control, so I-03 stays open at S4, P3 by the acceptability rule; I-02 read against the change: the probable-duplicate mark reads each entry’s own medicine, so two entries of one composition are each compared; the other ratings are unchanged |
| 2026-11-08 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.181 with fhirconnect 0.1.236 (#1742): the patient summary maps FerroBRIDGE’s patient summary template through the EPS project’s root context, running a composition once and taking each section’s entries from its own slot, each held to the section’s crosswalk profiles, and a medication action under the ad hoc Medication Summary section is selected and listed as a medication summary entry (FerroHEALTH/FerroBRIDGE#1696); an element a run leaves unread is declared and counted in the narrative of each section that takes from the record (FerroHEALTH/FerroBRIDGE#1712) | I-03 re-judged by the file’s method: the gap that kept it at P3 has a built control and tests (app/ferrofed-eehrxf/tests/it/template.rs, a_medication_action_is_selected_by_the_section_that_holds_it; app/ferrofed-eehrxf/tests/it/summary/placement.rs, a_medication_action_under_the_medication_summary_is_a_medication_summary_entry, an_element_the_run_leaves_unread_is_counted_and_named), and every other way content is left out is told to the reader, so a silent loss takes two independent faults: closed at S4, P1; I-02 read against the change: the probable-duplicate mark reads the entries each section takes; the other ratings are unchanged |
| 2026-11-09 | before 0.0.17 | the interoperability component moves to eehrxf 0.1.187 with fhirconnect 0.1.241, openehr-mapping-core 0.1.148, ferrobridge-validator 0.1.154 and ferrobridge-term 0.1.148, on openehr-* 0.0.135 (#1754): the laboratory mapping carries a result’s typed reference ranges as other_reference_ranges entries in both directions (FerroHEALTH/FerroBRIDGE#1694, closed by FerroHEALTH/FerroBRIDGE#1704), a link whose FHIR side is the resource an enclosing reference entered is carried and any other side refused when the mapping compiles (FerroHEALTH/FerroBRIDGE#1695); the gateway reads the id a CDR names in an ETag and the stored-query name and version through openehr-its (#1753) | I-16 re-read with a gateway test (app/ferrofed-server/tests/it/fhir/receive/laboratory.rs, a_received_typed_reference_range_reaches_the_members_composition): a sender’s typed range now reaches the member’s structured record with its coded meaning, but a range text, Observation.basedOn and an order named by reference alone still reach it only in the original document and are not told to its reader, so it stays open at S4, P3; I-18 re-judged by the file’s method with gateway tests (a_served_laboratory_report_carries_each_typed_reference_range_with_its_type, a_served_reference_range_named_by_text_alone_keeps_its_text): the one gap that kept it at P3 has a built control, so it closes at S4, P1; I-01 to I-05 read against the change: the section catalogue, the section queries and the gateway’s controls are unchanged, and every test those rows cite passes unchanged on the new pins; the other ratings are unchanged |
| 2026-11-10 | 0.0.18 | the release cut, of the content tagged v0.0.17 and never released: the interoperability component as the published eehrxf 0.1.187 with fhirconnect 0.1.241, openehr-mapping-core 0.1.148, ferrobridge-validator 0.1.154 and ferrobridge-term 0.1.148 on openehr-* 0.0.135, reviewed in the rows above (#1605, #1581, #1694, #1722, #1742, #1754); the discharge and imaging reports served from a member’s composition (#1405, #1406), with the report’s Patient written from the demographics binding and the member named a Composition.author (#1777); a member’s own discharge and imaging documents passed through the document list (#1665, #1672); the discharge report received over the HDR package closure and FerroBRIDGE’s ADL 2 template (#1714), the imaging report still not received (#1716); the logging component on ehds-logging 0.8.0 with ihe-atna 0.8.0, whose record of a relayed contact-point access names the professional by the assertion’s subject-id, the provider acted for as acts-for, every further asserted field as an asserted-field and the relay as relayed-via and relayed-issuer (#1620, #1682, #1748) | every hazard read against the release in the rows above; a passed-through discharge or imaging document is the member’s own, listed as it holds it with only its attachment URL readdressed to a signed Binary/{token}, so the losses I-19 and I-20 name do not arise for it and the list stays all-or-nothing, as for the laboratory report of #1404; a received discharge report takes the receive path every category takes, so I-10 and I-11 hold at S4, P1, and the loss of its own content, which I-16 rates for a laboratory report, is not yet rated (#1794); L-03 and L-04 read against the logging move: the accessor still comes from the verified token alone, and the new fields name only what the token asserts; I-16, I-19 and I-20 stay open at S4, P3, I-17 at S4, P2; no other rating changes |
| 2026-11-10 | before 0.0.19 | the interoperability component moves to eehrxf 0.2.0 with fhirconnect 0.1.249, openehr-mapping-core 0.1.151, ferrobridge-validator and ferrobridge-term 0.2.0, on openehr-* 0.0.136 and ferrohealth-smart 0.15 (#1799): a mapping with no value out of openEHR takes no output instance, so no empty entry is written for it (FerroHEALTH/FerroBRIDGE#1781); the AQL the section queries send prints each comparison with spaces, the same query (FerroHEALTH/FerroBRIDGE#1710); the terminology client runs on the same ferrohealth-smart line as the gateway | I-01 to I-05 read against the change: the section catalogue and the gateway’s controls are unchanged, a mapping that reads no value now writes no empty entry rather than one with no content, and every test those rows cite passes unchanged on the new pins (cargo nextest run --workspace --all-features, 4394 pass); I-16, I-19 and I-20 stay open at S4, P3, I-17 at S4, P2; no rating changes |
| 2026-11-10 | before 0.0.19 | the content a received discharge report can lose, rated by the file’s method (#1794): the HDR mapping’s declared losses read on a full synthetic report at the gateway (a_full_received_discharge_report_reaches_the_members_composition_but_its_course), where every element the mapping reads reaches the member’s composition and the answer relays as not carried only the rules that have no receiving direction (the composition’s author, date, status, title and type, each section’s title and code, the encounter’s profile, class and status, each resource’s profile, a medication request’s status and intent, and a diagnosis’s severity and certainty); the hospital course and every section the template holds no place for are carried in the original document alone and declared to no one | I-21 opened at S4, P3, the rating I-16 carries for the received laboratory report, with its planned control on FerroHEALTH/FerroBRIDGE#1820; the same loss for a received imaging report is rated with its receipt (#1716); I-10 and I-11 unchanged at S4, P1; the other ratings are unchanged |
| 2026-11-10 | before 0.0.19 | the receipt of an imaging report (#1716): a Medical-Imaging [[fhir.receive]] entry loads over the Imaging Report package closure, and a received report is validated in full, mapped and written at its member (app/ferrofed-server/tests/it/fhir/receive/imaging_received.rs); a written imaging report names the member its custodian (#1785) | I-22 added, open at S4, P3: the report identifier, the performing organization and each section’s narrative reach the member only in the original document, undeclared until FerroHEALTH/FerroBRIDGE#1827; I-20 re-read, its rating unchanged |
| 2026-11-10 | before 0.0.19 | I-20 and I-21 re-read against eehrxf 0.3.2 (#1827): the gateway compiles each received category’s mapping with the declarations of the project eehrxf ships for it, so a received discharge report’s hospital course is relayed to the sender as not carried (a_full_received_discharge_report_reaches_the_members_composition_but_its_course); the imaging mapping declares a served composition’s findings unread (FerroHEALTH/FerroBRIDGE#1700), but the written document leaves that declaration out (#1826) | I-21 stays open at S4, P3 with its control implemented, since a reader of the member’s record is still told of no loss, as for I-16; I-20 stays open at S4, P3 |
| 2026-11-10 | before 0.0.19 | a written report names what its mapping declared it did not carry (#1826): each element of the member’s composition a run of the laboratory, discharge or imaging mapping declares is named by its openEHR path, never its value, in the narrative of the section the gateway writes (the results, the hospital course, the imaging study), tested for a discharge medicine’s timing and an imaging finding | I-19 and I-20 closed at S4, P1, as I-03 is: a loss now reaches the reader as a named path, so content is left out silently only through two independent faults |
| 2026-11-11 | before 0.0.19 | I-22 re-read against eehrxf 0.6.1 with ferrohealth-smart 0.18 (#1838): the IMAGING project’s declarations apply at receipt (#1827), and every compile path declares them (FerroHEALTH/FerroBRIDGE#1875), so a received report’s identifier and performing organization are relayed to the sender as not carried, with no carried element declared (a_received_imaging_report_reaches_the_members_composition_and_declares_what_it_loses) | I-22 stays open at S4, P3: each loss is declared to the sender and told to no reader of the member’s record, as for I-21 |
| 2026-11-11 | 0.0.19 | the release cut: the interoperability component as the published eehrxf 0.6.1 with fhirconnect 0.2.1, openehr-mapping-core 0.1.154, ferrobridge-term and ferrobridge-validator 0.5.0 on openehr-* 0.0.136, and the logging component on ehds-logging 0.12.0, reviewed in the rows above (#1716, #1785, #1812, #1826, #1827, #1838) | every rating as the rows above leave it: I-20, I-21 and I-22 open at S4, P3 |
| 2026-11-11 | before 0.0.20 | I-22 re-read against eehrxf 0.6.6 over FerroBRIDGE’s imaging report template at v0.0.24 (#1860): a received report’s Organization performer reaches the Imaging service slot of the examination result as a CLUSTER.organisation.v1 with its name and identifiers, and any other performer is declared to the sender as not carried (FerroHEALTH/FerroBRIDGE#1878, FerroHEALTH/FerroBRIDGE#1888; a_received_imaging_report_reaches_the_members_composition_and_declares_what_it_loses, a_performer_that_is_no_organization_is_declared_to_the_sender) | I-22 stays open at S4, P3: a reader of the member’s record can now ask the performer, but the report identifier, a performer that is no organization and each section’s narrative still reach the record only in the original document, declared to the sender and told to no reader of the record, as for I-21 |
| 2026-11-12 | before 0.0.20 | the interoperability component moves to eehrxf 0.7.0 with ferrobridge-term and ferrobridge-validator 0.6.0, and the logging component to ehds-logging 0.13.0 with ihe-atna 0.13.0, on ferrohealth-smart 0.19 (#1864); the professional, the organisation and the purposes of use a token states are read by ferrohealth-smart from the issuer’s [[auth.attributes]] or [[auth.federation]] table, and the gateway’s own reader of the IHE IUA extension is removed (#1615) | L-03 and L-04 read against the change: the accessor and the professional still come from the verified token alone, and an issuer with no attributes table states no professional and no purpose, so its callers are refused before any record or member (app/ferrofed-server/tests/it/access/accessor.rs, a_person_is_recorded_with_the_professional_and_the_verified_level, a_token_naming_no_professional_records_none; app/ferrofed-server/tests/it/auth/attributes.rs, an_issuer_without_an_attributes_table_states_no_purpose); I-20, I-21 and I-22 read against eehrxf 0.7.0, whose mappings are those of 0.6.6, and every test their rows cite passes on the new pins; no rating changes |
| 2026-11-12 | 0.0.20 | the release cut: the interoperability component as the published eehrxf 0.7.0 with fhirconnect 0.2.1, openehr-mapping-core 0.1.154, ferrobridge-term and ferrobridge-validator 0.6.0 on openehr-* 0.0.136, and the logging component on ehds-logging 0.13.0, reviewed in the rows above (#1615, #1860, #1864) | every rating as the rows above leave it: I-21 and I-22 open at S4, P3 |
Complaints and incidents
This page says how to complain about FerroFED, what its manufacturer records,
and what it does when a released version turns out not to conform or is
involved in a serious incident. It describes the manufacturer’s procedures
under Regulation (EU) 2025/327 on the European Health Data Space (the EHDS
Regulation), Art 30(1)(i) to (o), Art 30(5) and Art 44, and the reports of
an actively exploited vulnerability or a severe incident under Regulation
(EU) 2024/2847, the Cyber Resilience Act (CRA), Art 14. It also says how
the economic operators FerroFED is supplied to are recorded (EHDS Art 35,
CRA Art 23). The
regulatory status page sets out why those articles
reach FerroFED and from which date. The maintainers’ working checklist for
the same procedures is
docs/post-market.md.
Every quotation is from the Official Journal text, vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml
and
docs/specs/eu-cra/reg-eu-2024-2847-en.xhtml.
This page is not legal advice.
The manufacturer
FerroFED’s manufacturer is Cadasto B.V., the Licensor named in
LICENSE:
| Name | Cadasto B.V. |
| Postal address | Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands |
| Single point of contact | info@cadasto.com |
| Website | https://www.cadasto.com/contact/ |
Art 30(1)(g) asks for these details “in the EHR system”, with “a single point
at which the manufacturer can be contacted”. The running gateway shows them
in GET {base}/, in OPTIONS {base}/ under manufacturer, in its startup
banner and in ferrofed --version; the operator console shows them on its
About page, which the footer of every page names the manufacturer and links
to, and both container images carry them as labels.
The family book’s
manufacturer page says what
being the manufacturer of every FerroHEALTH product covers.
Making a complaint
Art 30(1)(n) asks the manufacturer to “establish channels of complaint”. A complaint is any report that FerroFED does not do what its documentation or its intended purpose says, or that it falls short of the Regulation. Pick the channel by what the report contains:
- A vulnerability, or a patient identifier reaching a node: report it
privately through
GitHub private vulnerability reporting,
as
SECURITY.mdsays. Include any evidence that it is being exploited, which starts the 24-hour clock of Actively exploited vulnerabilities. - Anything that may have harmed a person, or could: write to info@cadasto.com with “FerroFED incident” in the subject, so it is handled as a possible serious incident from the first hour (see Serious incidents).
- Any other complaint: write to info@cadasto.com with “FerroFED complaint” in the subject, or open a GitHub issue when the report can be public.
Attach the archive ferrofed report writes (see
The report archive), and say how FerroFED is deployed
and what happened. Where the archive cannot be made, name the version you run
(ferrofed --version prints it). Never send patient data: describe the case,
or build a synthetic one.
Each distributor, importer and authorised representative of FerroFED is told of these channels and of the registers below (Art 30(1)(n), (o)).
The registers
Art 30(1)(o) asks the manufacturer to “keep a register of complaints and a register of non-conforming EHR systems”. Both are kept in the repository, so anyone can read them:
docs/registers/complaints.tsv: every complaint, whatever the channel, with the versions it concerns, how it was classified, the record of the work on it and its outcome.docs/registers/non-conforming-versions.tsv: every released version found not to conform, with the requirement it missed, the corrective action, and when the authorities and the users were told.
No row names the person who complained or carries patient data. The manufacturer keeps the correspondence itself, outside the repository. A vulnerability enters the registers once its advisory is published, so a row never discloses one before its fix.
The register of economic operators
The EHDS Regulation has every economic operator “on request, identify … to the market surveillance authorities, for 10 years from the date when the last EHR system covered by the EU declaration of conformity has been placed on the market: (a) any economic operator that has supplied them with an EHR system; and (b) any economic operator to which they have supplied an EHR system” (Art 35). The CRA asks for “the name and address” of each, the second “where available” (Art 23(1)), “for 10 years after they have been supplied with the product with digital elements and for 10 years after they have supplied” it (Art 23(2)).
Cadasto B.V. keeps a register of every economic operator it supplies FerroFED to under a contract: commercial licensees, resellers, integrators and hosts. Each row names the operator, its address and role, the versions and artefacts supplied, the date, the Member States where the contract has FerroFED made available or put into service, and the contract. A row is entered within one working day of the supply, is never rewritten, and is kept at least 10 years after the supply and 10 years after the last placing on the market under the declaration of conformity of its versions.
The rows are business data, so the register is kept outside the public
repository. Its format is public, in
docs/registers/economic-operators.tsv,
which holds no row, and scripts/checks/economic-operators.sh checks both
copies: the public one for having no row, and the private one, under git,
for every row being kept for its period. A market surveillance authority’s
request goes to the single point of contact above and is answered from the
register (see Cooperation with the authorities).
A release published on GitHub can be downloaded by anyone, and the download is not gated. Cadasto B.V. does not know who downloads it, so a download is not entered in the register. The release is supplied for non-production use and for non-commercial production use under the licence; production use beyond that needs a commercial licence, which is a contract and so a row. Art 35 has no “where available” qualifier. Whether an anonymous user who puts a downloaded release into production is an economic operator Cadasto B.V. supplied is a question for the manufacturer’s counsel; if counsel says it is, downloads move behind a registration and each registration is entered.
Corrective action, recall and withdrawal
When the manufacturer considers, or has reason to believe, that a released version is not, or is no longer, in conformity with “the essential requirements laid down in Annex II”, it takes “without undue delay any necessary corrective action”, or recalls or withdraws the version (Art 30(1)(i)). The steps:
- The version enters the register of non-conforming versions, with the Annex II item or the obligation it misses.
- The fix is an issue on the tracker and ships in a new patch release. A
published release is never changed or deleted, so a non-conforming version
is named in the register, in the security advisory where there is one,
and in the release notes of the version that corrects it.
A withdrawn version is also listed as unsupported in
SECURITY.md, and its<major>.<minor>andlatestimage tags move to the correcting release. Its own version tag and image digest stay, so a deployment pinned to either keeps running it until the deployment moves. - The national authorities of each Member State where the version was made available or put into service are told of the non-conformity, the corrective action “including the timetable for implementation”, and the date the version was “brought into conformity or been recalled or withdrawn” (Art 30(1)(i)).
- Distributors, the authorised representative, importers and users are told of the non-conformity and of the corrective action, recall or withdrawal (Art 30(1)(j)), through the advisory or the release notes, and directly where the manufacturer knows them.
The same channels carry any “mandatory preventive maintenance” and its frequency (Art 30(1)(k)). Where an authority finds that FerroFED “has caused harm to the health or safety of natural persons”, the manufacturer “shall immediately provide information and documentation” to the affected person or user (Art 44(3)), and the corrective action covers every copy placed on the market in the Union (Art 44(4)).
Serious incidents
Art 2(2)(r) defines a serious incident as “any malfunction or deterioration in the characteristics or performance of an EHR system made available on the market that directly or indirectly leads, might have led or might lead to any of the following: (i) the death of a natural person or serious harm to a natural person’s health; (ii) serious prejudice to a natural person’s rights; (iii) serious disruption of the management and operation of critical infrastructure in the health sector”.
The manufacturer handles these as possible serious incidents of a federation gateway: an answer presented as complete while a member that holds the patient’s data was left out, a follow-up read or write routed to the wrong record, and a patient identifier reaching a node.
Art 44(7) sets the report:
- To whom: “the market surveillance authorities of the Member States where such serious incident occurred and of the Member States where such EHR systems are placed on the market or put into service”. Each Member State designates its authority, and “the Commission and the Member States shall make that information publicly available” (Art 43(2)).
- What: the incident, with “a description of the corrective action taken or envisaged by the manufacturer”.
- When: “immediately after the manufacturer has established a causal link between the EHR system and the serious incident or the reasonable likelihood of such a link and, in any event, not later than three days after the manufacturer becomes aware of the serious incident”. The three days run from awareness, so a report goes in on time even while the cause is still being established, and is completed later.
NIS 2. The report is made “without prejudice to incident notification requirements under Directive (EU) 2022/2555” (Art 44(7)). The two duties are separate: a report to a market surveillance authority does not stand in for a notification NIS 2 requires, and a NIS 2 notification does not stand in for this report. Which entities NIS 2 binds, and its own deadlines, are set by that Directive, which is not vendored here; a deployment’s counsel answers them for that deployment.
Personal data. Where a serious incident concerns personal data protection, the market surveillance authority informs the data protection supervisory authorities (Art 44(6)). A deployment’s own duties under data protection law are its own.
Actively exploited vulnerabilities and severe incidents
CRA Art 14 applies from 11 September 2026 (CRA Art 71(2)), and Art 69(3) extends it to “all products with digital elements that fall within the scope of this Regulation that have been placed on the market before 11 December 2027”. Cadasto B.V. places each tagged FerroFED release on the market as its manufacturer, so the duty covers every release.
What is reported
- An actively exploited vulnerability: “a vulnerability for which there is reliable evidence that a malicious actor has exploited it in a system without permission of the system owner” (CRA Art 3(42)), contained in FerroFED (Art 14(1)).
- A severe incident having an impact on the security of FerroFED: an incident that “negatively affects or is capable of negatively affecting the ability of a product with digital elements to protect the availability, authenticity, integrity or confidentiality of sensitive or important data or functions”, or that “has led or is capable of leading to the introduction or execution of malicious code” in FerroFED or in a user’s network and information systems (Art 14(3), (5)).
A patient identifier reaching a node through an attack, or a credential taken from a running gateway, are examples the manufacturer handles under this heading.
Where it goes
Each notification goes through ENISA’s single reporting platform (Art 16(1)) to the CSIRT designated as coordinator in the Netherlands, where Cadasto B.V. has its main establishment, and is visible to ENISA at the same time (Art 14(7)). That CSIRT passes it on to the CSIRTs of the other Member States the manufacturer names (Art 16(2)). The family book has the manufacturer’s procedure, the same for every FerroHEALTH product (actively exploited vulnerabilities and severe incidents).
Commission Delegated Regulation (EU) 2026/881, adopted under Art 14(9), sets when that CSIRT may delay passing a notification on. It binds the CSIRT and puts no duty on the manufacturer. One of its grounds starts with the manufacturer: the CSIRT may hold a notification back where the manufacturer has told it that “an effective risk mitigation measure, such as a security update or user guidance, is expected to be made available within 72 hours”, and passes it on if none is available in that time (its Art 3(a)). The others are the CSIRT’s own: information that would let an attacker build an exploit, a coordinated vulnerability disclosure in which it is the trusted intermediary, or doubt about another CSIRT or the single reporting platform (its Art 3(b) to (d), Art 4 and Art 5). So the notification says when the fix or the mitigation is expected, and the CSIRT decides on any delay.
The three reports
The clock starts when the manufacturer becomes aware of the vulnerability or the incident: an early warning within 24 hours, a notification within 72 hours, and a final report (Art 14(2), (4)). The deadlines and the content of each report are the same for every FerroHEALTH product, and the family book’s Security page lists them. The CSIRT may ask for an intermediate report on status updates (Art 14(6)), and the manufacturer answers it.
Telling the users
Art 14(8): after becoming aware, “the manufacturer shall inform the impacted users of the product with digital elements, and where appropriate all users, of that vulnerability or incident and, where necessary, of any risk mitigation and corrective measures that the users can deploy”, “where appropriate in a structured, machine-readable format”. FerroFED’s channel is a GitHub security advisory on the repository, naming the releases affected, the mitigation a deployment can apply now and the release that fixes it. Cadasto B.V. also writes directly to every user it knows from a contract. A deployment that pulls FerroFED anonymously learns of it through the advisory, so watch the repository’s security advisories. If the manufacturer does not inform users in time, the CSIRT may do so itself (Art 14(8)).
Beside the serious-incident report
The CRA notifications and the EHDS serious-incident report (Art 44(7), above) are two separate duties with different recipients and clocks:
| CRA Art 14 | EHDS Art 44(7) | |
|---|---|---|
| Event | an actively exploited vulnerability, or a severe incident having an impact on security | a serious incident, Art 2(2)(r): a malfunction that leads, or might lead, to death or serious harm to health, serious prejudice to a person’s rights, or serious disruption of critical health infrastructure |
| To | the Dutch CSIRT designated as coordinator, through the single reporting platform, and ENISA | the market surveillance authorities of the Member States concerned |
| By | 24 hours, 72 hours, then the final report | three days after awareness |
One event can meet both definitions. A vulnerability exploited to read patient data through the gateway is a severe incident under the CRA and may be a serious incident under the EHDS. Neither Regulation says that one report stands in for the other, so the manufacturer makes both, each on its own clock, and keeps them consistent. NIS 2 stays separate from both, as above.
The report archive
A serious-incident report is due within three days of awareness (Art
44(7)), and an authority that acts on one passes on “the data necessary for
the identification of the EHR system concerned” (Art 44(5)). Run
ferrofed report on the host the gateway runs on, with the same
configuration, to collect that data in one step:
ferrofed report --config /etc/ferrofed/ferrofed.toml \
--operator-token-file /run/secrets/ferrofed/operator-token \
--out ferrofed-report.tar
The command writes an uncompressed tar archive. Without --out, it is
ferrofed-report-<UTC time>.tar in the working directory. The command never
overwrites a file. Every file sits under ferrofed-report/:
| File | What it holds |
|---|---|
manifest.json | format (ferrofed-report), format_version (1), the time it was made, the product, version and manufacturer, an entries list with each file’s purpose, its source, its size and its SHA-256, and a missing list with each part that could not be read and why |
build.json | the version, the commit and target triple the release build recorded, the platform, whether it is a release build, the Cargo features (the bindings), and the pinned specification and openehr-* releases the startup banner prints |
release.json | the release tag, the tarball and image of this version, the workflows that signed their attestations, and the gh attestation verify commands that check them |
configuration.toml | the effective configuration (the file with every FERROFED__ override applied), redacted |
health/readiness.json | GET {base}/health/readiness, as the gateway answered it |
health/dependencies.json | GET {base}/operator/dependencies: the last observed state of every dependency, read with the operator token |
incidents.json | GET {base}/operator/incidents: every integrity incident kind’s count and the most recent of each, read with the operator token |
metrics.txt | GET /metrics from the admin listener, when metrics.listen is set |
The live files come from the gateway running on this host. When it is
stopped, or a part needs something the deployment did not give (the operator
token, an admin listener), the part is listed under missing with the reason,
and the command prints the same list. The image digest is not visible from
inside the process. For a container, add the output of
docker image inspect --format '{{index .RepoDigests 0}}' <image>.
Redaction fails closed and runs over the parsed configuration, so a comment
or an inline table cannot pass it. Each key is judged by its exact spelling,
so a key in another casing or with another separator counts as unknown. A
key is shown only when it is a configuration field name the redactor knows,
outside a table keyed by data (credentials, members, namespaces,
communities, custodians, holders). Every other key becomes ***1,
***2 and so on. A value stays only under a known key that names no
credential and no file, and only when it is one of these:
- a boolean;
- a number under a timeout, interval, limit, capacity or size key, such as
request_timeout; - the scheme, host and port of a URL under a URL key, such as
urlorissuer, with its userinfo, path, query and fragment replaced by***; - a closed setting or a listen address that has the expected shape, such as
profile,listen,base_path,formatornode_selection.
Every other value is ***. That covers secrets in any casing, file paths
(which can name a person’s home directory), dates, namespaces, user names,
and ehr_ids. A key the configuration gains later is redacted until the
redactor is taught it.
The live files keep only the fields that carry states, counts, kinds, times and routing ids:
- Readiness keeps each indicator’s state without its detail text.
- The incidents keep their kind, time, detection and the endpoints and
nodes involved, without the description, the
ehr_idor thecreating_system_id. - The metrics keep the value of a label only when the gateway itself sets that label.
- A part that cannot be read is named in
missingwithout quoting what was answered.
The archive therefore carries no patient identifier, ehr_id, clinical
payload, credential, personal name, header value or URL userinfo. Read it
before you send it all the same. A secret or a patient identifier found in
an archive is a vulnerability: report it as one.
Cooperation with the authorities
On request, the manufacturer gives a market surveillance authority “all the information and documentation necessary to demonstrate the conformity” of FerroFED, in an official language of the Member State concerned (Art 30(1)(l)), “in paper or electronic form” and “in a language which can be easily understood by that market surveillance authority” (Art 30(5)), and cooperates on any action to bring FerroFED into conformity or to eliminate its risks (Art 30(1)(m), Art 44(1)). A request goes to the single point of contact above. An authority may restrict, recall or withdraw an EHR system whose manufacturer does not cooperate or gives incomplete information (Art 43(5)), and a missing technical documentation, declaration of conformity, CE marking or registration is a finding of non-compliance in its own right (Art 45(1)). The technical documentation says how a request for it, or for a translation of parts of it, is answered within 30 days (Art 37(4)); the declaration is planned under #525. A request to identify the economic operators supplied (EHDS Art 35, CRA Art 23) is answered from the register of economic operators, with each operator’s name and address, and with the statement that the versions concerned were also published for download to recipients the manufacturer does not know.
Data protection
This page is for the people who write a deployment’s data protection impact assessment (DPIA) and its records of processing. It lists the personal data a FerroFED gateway processes, where each item is held, how long it is kept and what protects it. It then states the roles of a deployment, what the gateway offers an entity under the NIS2 Directive, and what the documents claim about the medical device regulation.
The legal texts are cited from the Official Journal:
- Regulation (EU) 2016/679, the General Data Protection Regulation (GDPR), http://data.europa.eu/eli/reg/2016/679/oj;
- Regulation (EU) 2025/327, the European Health Data Space Regulation (EHDS),
vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml; - Directive (EU) 2022/2555, the NIS2 Directive, http://data.europa.eu/eli/dir/2022/2555/oj;
- Regulation (EU) 2017/745 on medical devices (MDR), http://data.europa.eu/eli/reg/2017/745/oj.
This page is not legal advice. It describes what the software does, so that a deployment’s counsel and data protection officer can decide what that means for the deployment. The last section lists what they must confirm.
What the gateway holds
FerroFED holds no clinical data of its own. It passes each member CDR’s answer to the client, merges the answers, and forwards a write to the CDR that owns the record. Clinical content is in the gateway’s memory for the length of one request and is never written to disk or to a log.
What it does keep is routing state, audit records and operational telemetry.
Several of these hold personal data: an ehr_id is a pseudonymous key to one
patient’s record at one node, the caller’s identity names a health
professional, the IHE audit records name the patient, and the access records
name both. Every row of the inventory below names what it carries.
Processing inventory
| Store or channel | Personal data it carries | Where | How long | Protection | Settings |
|---|---|---|---|---|---|
| The client’s request | the patient identifier in the AQL or a parameter; the caller’s access token; for a write, the clinical content | process memory | one request, at most server.request_timeout (30 s) | the identifier is held as a PatientRef whose Debug and Display are redacted, never serialised, logged, traced or measured; the token is verified before anything else is read (Client authentication) | server.request_timeout, server.body_limit |
| Requests to the identity services | the patient identifier, sent to the PIX Manager (ITI-83), the PDQm Supplier (ITI-78 or ITI-119), each XCPD responding gateway (ITI-55) and the NVI (a pseudonymised BSN); to Mitz, the patient identifier with the professional’s UZI number and role and the organisation’s URA and type | in transit to each service you configure | one exchange | https required outside the development profile (What must travel encrypted); optional mutual TLS | [pixm], [pdqm], [xcpd], [nl_gf.nvi], [nl_gf.mitz] |
| Requests to the member nodes | the node’s own ehr_id; the caller’s sub, the issuer that vouched for it, the caller’s organisation, purpose of use and scopes, and the professional’s name, identifier, the agency that issued it and roles where the caller’s token states them, in the signed openEHR-federation-client token; for a write, the clinical content byte for byte | in transit to each member asked | one request | no patient identifier in any part the gateway composes (§5.4.1, N33), checked by the outbound gate (Where the patient identifier stops); the caller’s own token is never forwarded | [credentials."<id>"], [signing] |
| The answers | clinical rows and documents from each member | process memory, then the client | one request | each node decides what it releases (§13.2, N26, N27); a node error quoted in meta.federation has each consumed value masked | none |
| Resolution bindings | the caller (issuer, subject, client) with the {node, ehr_id} pairs its resolutions found | process memory, per replica | federation.binding_ttl after the last resolution that returned the pair (15 minutes by default); lost on restart | keyed by ehr_id, never by a patient identifier; seen by no other caller; bounded by binding_capacity (100 000) | Resolution bindings |
The ehr_id index | which member holds which ehr_id | process memory, per replica | least recently used past ehr_index_capacity (100 000); lost on restart | no caller and no patient identifier | The ehr_id index |
| Integrity incidents | an ehr_id, only when it is a bare UUID, and the endpoints that claim it | process memory (the last 20 of each kind) and one log line | until restart; the log line as long as your log pipeline keeps it | no patient identifier; read on the operator surface with the operator scope (Integrity incidents) | none |
| Per-caller rate buckets | the caller’s issuer and client_id | process memory, per replica | until the bucket refills; at most 10 000 callers | none read outside the process | [server.caller_rate] |
| IHE audit spools | the patient identifier inside each ITI-83, ITI-78, ITI-119 and ITI-55 record; the identities a PMIR message names; the caller’s iss, sub, client_id and purposes of use on a record made for a caller | a directory on disk, one per replica | until the Audit Record Repository accepts the record; a quarantined record until you remove it | directory 0700, files 0600, refused at start when open to other users; bounded by spool_max_size (64 MiB) and spool_max_events (100 000); encryption at rest is the volume’s (The audit trail) | [audit.repository], [xcpd.audit_repository] |
| The ATNA Audit Record Repository | the same records | your repository | your repository’s retention | ITI-20 over TLS: the FHIR Feed over https, syslog over TLS | the repository’s own |
| The access records | for each federated query, stored-query execution, routed read and routed write that reached a node: the caller’s iss, sub, client_id, organisation, professional identification and purposes of use; the professional’s name and identifier (the IHE IUA subject_name and national_provider_identifier), the assurance level of the authentication when the issuer’s mapping established one, and whether a person or a client acted (The person behind an access); the patient identifier and namespace the request named, and each ehr_id reached with its endpoint; the request as the client sent it, which may name the patient; the request id and the address the request came from; each endpoint asked with its node, system_id, outcome and row count; the categories of the data | the [audit] spool, then the Audit Record Repository | as the IHE audit records above | stored before the answer leaves, and an access whose record cannot be stored is answered 503 access-unrecorded with none of the data; the spool and transport protections above; no part reaches a node, the log, a span or a metric label (The access log) | [audit], [audit.repository], [access_log] |
The log audit destination | the profile, the action, the outcome, the server of a request the gateway sent, counts of entities, and on_behalf as caller or gateway | the log | your log pipeline’s retention | no patient identifier, no request content, no sub, client_id or issuer of the caller, and no client of a request the gateway received; refused outside development for a gateway with a registry, because it cannot hold the access records | [audit] destination = "log", [xcpd] audit = "log" |
| The request log and every other log line | the method, route template, status, latency and request id; a security event’s reason; in the edge mode, keyed HMAC references to the subject and client that change at every start; on an admin listener write action, the operator’s issuer and subject | stdout, then your log pipeline | your log pipeline’s retention | no request body, AQL text, header value, request path or patient identifier (What the log records) | [telemetry] filter, format |
| Metrics | none: every label value comes from a closed set or the registry | the admin listener, the OTLP push | your metrics store’s retention | Metrics | [metrics] |
| Trace spans | none: route templates, registry ids, operations, statuses and counts; the trace id is the gateway’s own, never the client’s | your OpenTelemetry collector | your collector’s retention | Tracing | [telemetry] otlp_endpoint, trace_sample_ratio |
| The usage report | none of a patient or a caller: a random instance id, the version, the deployment kind, host size, the member count as a range, the configured services, latency per route group and node request counts; the collector adds the source IP address and its country | FerroPULSE, which Cadasto B.V. runs, at each start and once a day | 13 months at the collector | on by default once state_dir is set, off in CI and with enabled = false; no request path, AQL text, node name or URL (The usage report) | [usage_report] |
| Stored-query definitions | none: parameterised AQL, and a definition that names a patient by a literal is refused | redb, PostgreSQL or read-only files | durable; a stored version is immutable (§12.7, N44) | the bound values of an invocation are never written (Stored queries) | [stored_queries] |
| The PMIR identity feed | the Patient Master Identities each ITI-93 message names, with their identifiers and demographics | process memory for one message, then the audit spool | one message; the audit record as above | https outside development; a feed token from a file; only the kind and count of each change is logged (The identity feed) | [pmir] |
| The operator console’s sessions | the operator’s ID Token claims, access token and refresh token, and the name (name, else preferred_username) the top bar shows | the console’s process memory | idle_timeout_s (1 800) without a request, absolute_timeout_s (43 200) at most, and no longer than the access token unless the provider issued a refresh token; lost on restart | held on the server, known to the browser by an opaque HttpOnly cookie; every page no-store; a query answer is rendered and not kept (The operator console) | [session], [oidc] |
| The operator console’s theme choice | none: the word light, dark or system | an HttpOnly cookie, ferrofed_console_theme, in the operator’s browser | 400 days, or until the operator clears it | the console’s server reads it to draw each page; the browser keeps no other storage (The operator console) | the theme switch |
Two kinds of state hold no personal data: the registry document (organisations,
endpoints, system_id mappings) and the creating_system_id routes the
gateway learns. The signing key and the onward credentials are secrets: the
gateway shows each as *** wherever it renders the configuration, and the
hardening guide has them read from files.
The gateway sends its manufacturer one thing, the usage report above, which
names no patient or caller and which [usage_report] enabled = false stops.
Every other outbound connection goes to a URL your configuration names.
The record of each access
The gateway records every access to patient data it intermediates: each
federated query and stored-query execution that a member was sent, and each
routed read and write a node acted on, the operator console’s queries
included. Each record names the verified caller, the patient and every
ehr_id reached, each endpoint asked and the categories of the data. The
gateway stores it in the [audit] spool before the answer leaves, and
answers an access whose record cannot be stored with 503 access-unrecorded and none of the data; a write’s record is given its
place before any member is asked, so a write is refused before it is made
(The access log). The record follows
Annex II, point 3.2, of the EHDS Regulation, and the library that builds it
is ehds-logging, published from the FerroHEALTH audit library
(FerroAUDIT).
What the record does not cover:
- A definition request, such as a template or a stored-query definition, reaches no patient data and is not recorded.
- Under the development profile, an unset
[audit] destinationrecords nothing, andlogwrites counts that name no one. Outside development, a gateway with a registry refuses every destination butrepository. - Every build records accesses, with the IHE binding, the Dutch binding, both or neither: the access log needs no regional binding.
- The records are reviewed at the Audit Record Repository, which a person and an operator search with ITI-81, the external software Annex II point 3.3 admits (Reading the log). FerroFED offers no review interface of its own. Each record states how long it is kept by its origins and categories, at least three years (point 3.4, How long a record is kept). Your Audit Record Repository decides who reads the records and applies the period each one states.
Each member node still audits who asked: every request carries the verified
caller in the openEHR-federation-client token and the gateway’s request
id (§13.1, N24;
Verifying it at the node).
Retention
The gateway decides the retention of what it holds in memory. Everything it hands to another system is kept as long as that system keeps it.
| Data | Default | Setting | What decides the rest |
|---|---|---|---|
| A resolution binding | 15 minutes after the last resolution that returned it | federation.binding_ttl, binding_capacity | a restart forgets it |
An ehr_id index entry | until 100 000 newer entries push it out | federation.ehr_index_capacity | a restart forgets it |
| An operator console session | 30 minutes idle, 12 hours at most | session.idle_timeout_s, absolute_timeout_s | the access token’s own lifetime, unless a refresh token renews it; a refused refresh ends it |
| An IHE audit record or an access record in the spool | until the repository accepts it | spool_max_size, spool_max_events | a quarantined record waits for you to remove it |
| An audit record at the repository | none from the gateway | none | the repository’s retention policy |
| Log lines, metrics, spans | none from the gateway | none | your log, metrics and trace stores |
| Stored-query definitions | kept | none | they hold no personal data |
GDPR Art 5(1)(e) asks that personal data be “kept in a form which permits identification of data subjects for no longer than is necessary”. Set the retention of the audit repository and of the log pipeline in your records of processing. The EHDS Regulation asks that information on each access to a person’s data through the health professional access service “be available for at least three years from each date of access” (Art 9(2)). That duty belongs to the access service. The gateway’s access records are kept as long as your Audit Record Repository keeps them, so its retention policy decides whether they meet that period. Each record states the period its origins and categories call for, never under three years, for the repository to apply (How long a record is kept).
Roles
The EHDS Regulation takes the definitions of “controller” and “processor” from GDPR Art 4(7) and (8) (EHDS Art 2(1)(a)). A controller “determines the purposes and means of the processing of personal data”; a processor “processes personal data on behalf of the controller”. Two or more controllers that “jointly determine the purposes and means of processing” are joint controllers (GDPR Art 26(1)).
FerroFED is software. Its manufacturer receives no personal data from a deployment, so it is neither the controller nor a processor of a deployment’s processing. Its obligations as the manufacturer of an EHR system are those of the EHDS Regulation (Regulatory status, decided on #519). The operator of a deployment is the party the GDPR roles attach to.
A hospital that runs its own gateway
A hospital that runs FerroFED over its own CDRs, or over the CDRs of a group it belongs to, decides why patient data is federated and by what means. It is the controller of the gateway’s processing. Its hosting provider, if the gateway runs at one, is a processor under a GDPR Art 28 contract. Each member CDR’s operator remains the controller of its own CDR and of what it releases.
A regional operator that runs a gateway for several hospitals
A regional operator that runs one gateway for several healthcare providers can stand in either of two roles, and the facts of the arrangement decide which:
- A processor for each hospital. The hospitals decide the purposes, and the operator runs the gateway on their documented instructions. Each hospital then needs an Art 28(3) contract with the operator, and the operator keeps a record of processing per controller under Art 30(2).
- A joint controller with the hospitals. Where the operator and the hospitals together set the rules of the federation (who may ask, for which purposes, which members take part), they jointly determine the purposes and means, and Art 26 asks for an arrangement that sets out their responsibilities.
FerroFED’s own design points to facts counsel will weigh: the operator chooses the issuers it trusts, the members it admits, the identity services it asks and the audit repository it writes to; each member node still makes its own release and consent decision (§13.2, N26, N27).
The other parties
| Party | What it does with personal data in a federation |
|---|---|
| Each member CDR’s operator | holds the clinical record and decides what it releases to the gateway |
| The PIX Manager, PDQm Supplier, XCPD gateways, NVI and Mitz | receive the patient identifier, and Mitz the professional’s identity, to answer the gateway |
| The caller’s issuer | issues the token that names the professional |
| The Audit Record Repository | stores the IHE audit records and the access records, which name the patient and the professional |
| The log, metrics and trace stores | receive the operational telemetry above |
Each is a recipient in the sense of GDPR Art 30(1)(d) and has a role of its own to settle.
DPIA inputs
GDPR Art 35(3)(b) requires an impact assessment for “processing on a large scale of special categories of data referred to in Article 9(1)”, and data concerning health is one. A gateway that federates a region’s patient records will usually meet that test. These are the facts about the gateway an assessment needs; the deployment adds its own:
- Nature. Intermediation: the gateway resolves the patient through an
identity service, sends each member a query scoped to that member’s
ehr_id, merges the answers and routes follow-up reads and writes. It stores no clinical data. - Scope. Every category of clinical data the member CDRs hold, because the gateway selects nothing by category (Regulatory status). The patients are those the members hold; the professionals are the callers of the trusted issuers.
- Purposes. Patient care by healthcare providers. Each token declares a purpose of use, and a token without one is refused by default (Purpose of use).
- Measures. The threat model names the risks the gateway mitigates, the setting or test behind each mitigation, and the risks the deployment carries. The hardening guide turns them into a checklist.
- The risks to bring into the assessment. The patient identifier leaving for the identity services; the caller’s identity reaching every member asked; the audit spool on disk, which holds the access records until the repository accepts them; the access records at the repository, which name the patient and the professional for every access; and a wrong link at the identity service routing a request to another patient’s EHR.
A template for the record of processing
GDPR Art 30(1) lists what a controller’s record holds. This table fills in what FerroFED determines and leaves the rest to you. A processor’s record under Art 30(2) holds rows (a), (e) and (g), and “the categories of processing carried out on behalf of each controller” in place of (b) to (d).
| Art 30(1) | What to write |
|---|---|
| (a) the controller, joint controller, representative and data protection officer | yours |
| (b) the purposes of the processing | patient care by healthcare providers: answering a professional’s query from the federation’s CDRs and routing the follow-up reads and writes; add the purposes your issuers’ tokens declare |
| (c) the categories of data subjects and of personal data | patients of the member CDRs: patient identifiers, ehr_ids, and health data in transit; health professionals: the token’s subject, issuer, client, organisation, scopes and purposes of use; operators: their console sign-in |
| (d) the categories of recipients | the member CDRs; the identity services; the Audit Record Repository; your log, metrics and trace stores (The other parties) |
| (e) transfers to a third country | none by the gateway; each URL in your configuration decides where data goes |
| (f) the time limits for erasure | Retention, with your repository’s and log pipeline’s periods |
| (g) the technical and organisational measures | the threat model and the hardening guide, with your own controls |
NIS2
NIS2 Annex I, sector 5 “Health”, lists “Healthcare providers as defined in Article 3, point (g), of Directive 2011/24/EU”. The Directive applies to such a provider that is at least a medium-sized enterprise (Art 2(1)), with the exceptions of Art 2(2). One that exceeds the ceilings for medium-sized enterprises is an essential entity (Art 3(1)(a)), and one that does not is an important entity (Art 3(2)). A regional operator that runs systems for hospitals may fall under sector 9, “ICT service management (business-to-business)”, whose “managed service provider” provides “services related to the installation, management, operation or maintenance of ICT products … either on customers’ premises or remotely” (Art 6(39)). Whether either applies is for the entity to establish.
NIS2 puts the duties on the entity, never on its software. What FerroFED offers an entity for them:
| NIS2 | What the gateway offers |
|---|---|
| Art 21(2)(b) incident handling | the security events under ferrofed::security, each counted in ferrofed_security_events_total by event and reason; integrity incidents under ferrofed::integrity; the shipped alert rules, among them FerroFEDCallerRefusals, FerroFEDOutboundGateStopped, FerroFEDAuthenticationUnavailable and FerroFEDIntegrityIncident (Metrics); one request id shared by the client, the log and every node |
| Art 21(2)(c) business continuity | health and readiness probes, a bounded drain, several replicas behind one address (Health probes, Running several replicas); the stored-query store is the one durable store to back up |
| Art 21(2)(d) supply chain security | signed release provenance, a CycloneDX and an SPDX SBOM per binary, image attestations (The container image) |
| Art 21(2)(e) vulnerability handling and disclosure | private reporting through GitHub security advisories, with an acknowledgement within about 5 business days, an initial assessment within about 10, and a fixed release within 7, 30 or 90 days of the confirmed assessment for a Critical, High or Medium vulnerability and with the next release for a Low one (SECURITY.md) |
| Art 21(2)(h) cryptography | the transport rules that refuse a credential or a patient identifier over plain http outside development, and the trust anchors over https (What must travel encrypted); ES256 and ES384 signing keys with rotation |
| Art 21(2)(i) access control | every caller authenticated by token, SMART on openEHR scopes per route, an operator scope for the operator surface and the admin listener’s write actions (Client authentication), and a scrape user’s Basic credentials or mutual TLS for the metrics off loopback (Metrics) |
| Art 21(2)(j) multi-factor authentication | none in the gateway: the caller’s issuer and the console’s OpenID Provider authenticate the person |
Art 23(4) sets the reporting clock: an early warning “within 24 hours of
becoming aware of the significant incident”, an incident notification within
72 hours, and a final report “not later than one month” after that
notification. The gateway reports nothing to a CSIRT or a competent
authority. It supplies the evidence the entity reports from: the alerts, the
security and integrity log lines, and the dependency states of
GET {base}/operator/dependencies. A personal data breach has its own clock
under GDPR Art 33: the controller notifies “where feasible, not later than
72 hours”, and a processor notifies the controller “without undue delay”.
The EHDS Regulation has the manufacturer of an EHR system report a serious incident “not later than three days” after becoming aware of it, “without prejudice to incident notification requirements under Directive (EU) 2022/2555” (Art 44(7)). That is the manufacturer’s duty, and its procedure is on the Complaints and incidents page.
The medical device question
The documents classify FerroFED under the EHDS Regulation alone. They do not claim that FerroFED is a medical device under Regulation (EU) 2017/745, no FerroFED release carries a CE marking under it, and none of the documents states an intended purpose in its terms.
MDR Art 2(1) defines a medical device as software, among other articles, “intended by the manufacturer to be used … for human beings for one or more of the following specific medical purposes”, such as “diagnosis, prevention, monitoring, prediction, prognosis, treatment or alleviation of disease”. Recital 19 adds that software “specifically intended by the manufacturer to be used for one or more of the medical purposes” qualifies, “while software for general purposes, even when used in a healthcare setting … is not a medical device”.
FerroFED’s intended purpose is to find a patient’s records across a federation of CDRs and return them with their origin, and to route follow-up reads and writes to the CDR that holds the record. The reasoning a deployment’s counsel should check is this:
- What FerroFED does to the data. It resolves a patient, rewrites the
query per node, and merges the answers:
DISTINCT,ORDER BY, paging and aggregates across nodes, and deduplication by version identity when the client asks for it (§10). It computes no score, flags no finding and recommends nothing. A row’s values are the node’s own, beside the endpoint they came from, except a selected subject, which the gateway puts back (N5), and an aggregate, which it combines from the members’ answers. - What could still matter. The merge decides which rows a clinician sees
and in which order, and an aggregate is computed over the members’
answers. A missing member is named in
meta.federation, and by default a member that was asked and did not answer fails the whole query (§11.4). Counsel should weigh whether that selection is a medical purpose, against Art 2(1) and the qualification guidance the Medical Device Coordination Group publishes. - Where the EHDS Regulation points. Recital 42 says “certain software components of EHR systems could be considered medical devices” and should then be certified under the MDR as well. Art 27 binds manufacturers of medical devices that “claim interoperability” with the harmonised components; FerroFED makes no such claim. Art 1(5) leaves the MDR untouched for “the security of medical devices … that interact with EHR systems”.
- What a deployment adds. A client application, a stored query an operator publishes, or a change to FerroFED’s code is outside the manufacturer’s intended purpose. Each can bring a medical purpose of its own, and is classified on its own.
If counsel finds that a deployment’s software is a medical device, the MDR classification rules decide its class; Annex VIII, Rule 11, covers “software intended to provide information which is used to take decisions with diagnosis or therapeutic purposes”.
What counsel must confirm
This page states facts about the software. It decides no deployment’s obligations. A deployment’s counsel and data protection officer should at least confirm:
- Who is the controller of the gateway’s processing, and whether a regional operator is a processor under GDPR Art 28 or a joint controller under Art 26.
- The lawful basis under GDPR Art 6 and the condition under Art 9(2) for each purpose of use your issuers’ tokens declare.
- Whether the deployment needs a DPIA under Art 35, and its outcome.
- The retention of the audit repository and the log pipeline, and the access rights to each.
- Whether the deployment’s operator is an essential or important entity under NIS2, and which national transposition applies.
- That neither FerroFED nor anything the deployment adds to it is a medical device under Regulation (EU) 2017/745, or, where something is, its classification and conformity route.
- The EHDS points listed on Regulatory status.
Threat model
This page names what a FerroFED gateway protects, the trust boundaries it sits on, who can attack each boundary and from where, and what stops them. Each mitigation names the setting, the check or the test that provides it. Each risk the gateway does not close is named too, with the issue that tracks it or the control your deployment has to supply. The hardening guide turns this page into a checklist.
The method is STRIDE per boundary: spoofing, tampering, repudiation, information disclosure, denial of service and elevation of privilege. The specification’s own security model is §13: every client is authenticated (§13.1, N25), the gateway authenticates to every node and tells it who asks (N24), each node makes its own access and consent decision (§13.2, N26, N27), and no directly identifying patient identifier reaches a node (§5.4.1, N33). §13.4 leaves five decisions to each deployment, which The §13.4 deployment decisions answers for the gateway. No specification governs the rest of this page: our own design.
Assets
| Asset | Why it matters |
|---|---|
| The patient identifier | it names a person; it may reach the identity services and nothing else |
| Clinical data in transit | the answers a node releases and the writes a client sends |
| The caller’s identity and token | the token admits its holder at the gateway; the identity is what each node audits and decides on |
| The onward credentials and the signing key | they make the gateway’s requests acceptable to every node; the signing key vouches for every caller |
| Routing state | the resolution bindings, the ehr_id index and the creating_system_id routes decide which node a follow-up reaches; a wrong route sends a request to another patient’s record |
| The audit records | the IHE audit trail and the access records name patients and callers, and are evidence after the fact |
| The configuration and the registry | they decide whom the gateway trusts and where it sends data |
| Availability | one federated query becomes a request to every member, so the gateway’s load is every CDR’s load |
Trust boundaries
flowchart LR
client["Client application"] -->|"B1"| proxy["Reverse proxy, TLS"]
proxy -->|"B1, HTTP"| gw["FerroFED gateway"]
browser["Operator's browser"] -->|"B4"| console["Operator console"]
console -->|"B4, operator token"| proxy
gw -->|"B2"| node["Member CDRs and<br/>their token endpoints"]
gw -->|"B3"| idsvc["PIX Manager, PDQm, XCPD,<br/>NVI, Mitz, mCSD"]
pmir["PMIR Registry"] -->|"B3, feed token"| proxy
gw -->|"B7"| issuers["Callers' issuers:<br/>key sets, introspection"]
scraper["Prometheus, operator"] -->|"B5"| admin["Admin listener"]
admin --- gw
gw -->|"B6"| sinks["Audit spool and repository,<br/>log, metrics, traces"]
files["Configuration, registry,<br/>secret files"] -->|"B8"| gw
| Boundary | Between | What crosses it |
|---|---|---|
| B1 | a client and the gateway | the access token, the AQL with the patient identifier, write bodies, answers |
| B2 | the gateway and each member node | the node’s ehr_id, the onward credential, the signed caller token, write bodies, answers |
| B3 | the gateway and the identity services | the patient identifier, the gateway’s credentials, the identities in a PMIR feed message |
| B4 | the operator’s browser, the console and the gateway | the operator’s sign-in, the operator’s token, query answers |
| B5 | a scraper or an operator and the admin listener | metrics; the stored-query distribution |
| B6 | the gateway and its audit and telemetry sinks | audit records that name patients and callers; log lines, metrics and spans that name neither |
| B7 | the gateway and the issuers it trusts | the key sets that decide which tokens are genuine |
| B8 | the host and the gateway | the configuration, the registry document, the credentials and the signing key |
Attacker positions
| Position | Who |
|---|---|
| P1 | anyone who can reach the gateway’s listener without a valid token |
| P2 | an authenticated caller that reaches for more than it was granted: another patient, another operation, the operator surface |
| P3 | someone on the network path of a hop: between the proxy and the gateway, the gateway and a node, the gateway and a service |
| P4 | a member node, or an identity service, that is compromised or misbehaves |
| P5 | an issuer that is compromised, or a party that can substitute an issuer’s keys |
| P6 | someone with read access to the logs, metrics or traces |
| P7 | a workload in the same pod network or on the same host as the gateway |
| P8 | a website the operator visits while signed in to the console |
| P9 | an insider with access to the host, the volumes or the secrets |
B1: client to gateway
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| S | a request without a valid token, a token signed with none or an HMAC key, a token for another audience or from an untrusted issuer | P1 | every request to {base}/v1/ and OPTIONS {base}/ is verified before anything else is read: at+jwt, ES256, ES384, PS256 or RS256, a trusted iss, this gateway’s aud, exp and nbf (RFC 9068, RFC 8725 §3.1, §3.2); there is no unauthenticated mode | [auth], [[auth.issuer]]; app/ferrofed-server/tests/it/auth/token.rs |
| S | a proxy header that claims a caller | P1, P3 | the edge mode takes a signed assertion verified like a token, never a header trusted for where it came from (RFC 7239 §8.1) | trust.mode = "edge", [trust.edge]; app/ferrofed-server/tests/it/auth/edge.rs |
| E | a caller reads another patient, or an operation its scopes do not cover | P2 | SMART on openEHR scopes per route; system/aql-* only for backend_clients; a patient/ grant admits nothing unless its issuer is bound to one member, and then only that patient’s {node, ehr_id} pairs; purpose of use required | Scopes per route, Patient grants; app/ferrofed-server/tests/it/auth/scope.rs, patient.rs, other_patient.rs, purpose.rs |
| S | a client application with no user, or a user authenticated below the level the deployment requires, reading patient data | P1 | a token ferrohealth-smart classifies as a system’s, or that only system/ scopes cover, reaches no patient data whatever it names; an issuer’s [auth.issuer.assurance] refuses a token below its least level (Regulation (EU) 2025/327 Annex II 3.1; RFC 9470 §3) | Professionals and assurance; app/ferrofed-server/tests/it/auth/professional.rs |
| E | the ADMIN API, or the operator surface without the operator scope | P2 | the ADMIN API is refused to every caller; the operator routes need auth.product_scope as one whole scope token | auth.product_scope; app/ferrofed-server/tests/it/operator.rs |
| T | an AQL query that smuggles the patient identifier, a second patient, or text around the subject | P2 | the query is parsed and the node query is printed from the rewritten syntax tree; a subject the rewrite cannot consume exactly is 400 before any dispatch (§5.4.3) | Where the patient identifier stops; app/ferrofed-server/tests/it/hygiene.rs |
| T | an undeclared header or query parameter, or a declared value of the wrong kind, forwarded to a node | P2 | a routed request carries only what its ITS-REST operation declares; Accept, Content-Type and Prefer are composed by the gateway; a value of the wrong kind is 400 with nothing sent | Declared values; app/ferrofed-server/tests/it/declared.rs |
| R | a caller denies a request | P2 | the gateway records each query, read and write that reached a node with the verified caller and its request id, before the answer leaves; the request id travels to every node with the signed caller token, and each node audits who asked (N24) | The access log, Verifying it at the node; app/ferrofed-server/tests/it/access/, conveyance.rs |
| I | the patient identifier in a response, an error or a refusal | P1, P2 | errors name the position of the offending part, never the value; a node’s error quoted in meta.federation has each consumed value masked | app/ferrofed-server/tests/it/errors.rs, facade/query.rs; app/ferrofed-engine/tests/it/dispatch/ |
| D | a flood of requests, a large body, a slow request | P1, P2 | a concurrency limit refused before the caller is verified; a per-caller rate; a body limit (413) and a request timeout (408) | server.max_concurrent_requests, [server.caller_rate], server.body_limit, server.request_timeout; app/ferrofed-server/tests/it/overload.rs |
| I | the token or the patient identifier read on the hop between the proxy and the gateway | P3 | the listener speaks plain HTTP by default: keep that hop inside one host or pod, protect it with a mesh, or set [server.tls] with a client_ca_file that admits the proxy alone | TLS on the listeners; app/ferrofed-server/tests/it/listener_tls/ |
The health family, GET {base}/ and GET {base}/.well-known/jwks.json are
open by design, and none of them names a member. GET {base}/ names the
product version; restrict it at the proxy if your network should not learn
it. The dependency report, which names every member endpoint id and its last
observed state, is served only to an operator, at
GET {base}/operator/dependencies behind the operator scope.
B2: gateway to member node
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| I | the patient identifier reaches a node in the query, the path, the query string, a header or the caller claims | P4 | the rewrite strips or refuses the subject; the outbound gate re-reads every finished request, the caller claims included, and stops one that carries a consumed value (§5.4.1, N33, CP-2, CP-26, CP-38) | app/ferrofed-engine/tests/it/gate.rs; the adversarial track 10, app/ferrofed-server/tests/it/track10/ |
| S | a node replays the caller’s token at another node | P4 | the caller’s token is never forwarded; each node gets its own onward credential and a caller token signed for its endpoint id alone, valid 60 seconds | What a node is told about the caller; app/ferrofed-server/tests/it/auth/token.rs |
| S | someone obtains an onward token and replays it at its node | P3, P4 | onward tokens can be bound to the gateway’s key with DPoP, or the connection to a client certificate with RFC 8705 | [credentials."<id>"] … dpop_key_file, tls.client_identity_file; app/ferrofed-engine/tests/it/dpop/, mtls.rs |
| I | a credential sent to a node in cleartext | P3 | outside the development profile, a node URL that receives a credential must be https, refused at start and on reload | What must travel encrypted; app/ferrofed-server/tests/it/transport/ |
| T | two nodes claim one ehr_id, or a node claims another’s creating_system_id | P4 | a follow-up on a collided ehr_id is 409 and an integrity incident; a conflicting learned route is withdrawn (§12.5.2, §12b.2, N21, N42) | app/ferrofed-server/tests/it/ehr_id_collision.rs; FerroFEDIntegrityIncident |
| E | a node releases more than the caller may see | P4 | each node gets the caller’s scopes and purposes of use and decides what to release (§13.2, N26, N27) | the node’s own enforcement |
| D | a slow or failing node holds the gateway | P4 | per-node and overall budgets; at most max_in_flight_per_node requests to one member at once; a silent member is named in the answer and fails it under the all-or-nothing default (§11) | federation.per_node_timeout, overall_timeout, max_in_flight_per_node; app/ferrofed-server/tests/it/timeouts.rs, overload.rs |
| D | a node answers without end, or with a body too large to hold, so the gateway buffers it on every request | P4 | the gateway reads at most max_node_answer_size of one answer from a node or its token endpoint and drops the rest unread; the member is node-error, named in the answer, and fails it 424 under the all-or-nothing default (§11.1, §11.4) | federation.max_node_answer_size; app/ferrofed-server/tests/it/overload.rs, app/ferrofed-server/src/onward.rs |
B3: gateway to the identity services
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| I | the patient identifier read in transit | P3 | the URL of every PIX Manager, PDQm Supplier, XCPD gateway and NVI must be https outside development; optional mutual TLS | What must travel encrypted, Mutual TLS to the identity services |
| T | a wrong or forged cross-reference routes a query to another patient’s EHR | P4 | none in the gateway beyond the transport: the identity service is trusted for its links | carried by the deployment: the service’s own controls; record the trust in your §13.4 decisions |
| S | a forged ITI-93 message drops bindings | P1 | the feed route takes only a client-credentials token of a client pmir.feed_clients names; a message without one is 401, one of another client or of a person 403, and neither changes anything | pmir.feed_clients, [[auth.issuer]]; app/ferrofed-server/tests/it/pmir/feed.rs |
| T | a stale binding after a merge at the identity source | P4 | each binding lives binding_ttl after the last resolution that returned it; [pmir] drops the stale ones as the change arrives, on the replica that receives it | federation.binding_ttl, [pmir] |
| D | an identity service that does not answer | P4 | budgets per exchange; a resolution that fails is never a pass: under the all-or-nothing default the query fails 424 | [pixm], [pdqm.endpoint] timeout, [federation.localization] on_failure |
| R | a transaction with no audit record | P9 | every record is on disk before the transaction’s answer is used; a record that cannot be stored fails the transaction closed | The spool and the failure policy; app/ferrofed-server/tests/it/feed_audit/, audit_repository.rs |
B4: the operator console
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| S | a forged sign-in, a replayed ID Token | P8 | authorization code with PKCE and a nonce; the ID Token verified against the provider’s key set, issuer, audience and expiry | The operator console; app/ferrofed-console/tests/it/sign_in.rs |
| T | another site signs the operator out or spends the sign-in on a query | P8 | every unsafe request must be same-origin by fetch metadata, Origin or Referer; SameSite=Lax; every action is a POST to its own page | app/ferrofed-console/tests/it/sign_out.rs, query.rs, configure_refusals.rs |
| I | a node’s value runs as script in the console, or the page is framed | P4, P8 | every value is escaped on the server; a nonce CSP with frame-ancestors 'none', X-Frame-Options: DENY, nosniff, no-referrer | app/ferrofed-console/tests/it/server.rs, query.rs |
| I | the operator’s token or an answer kept by the browser | P8, P9 | the token stays on the console’s server; every page is no-store; pages are never compressed | [session] secure_cookie; app/ferrofed-console/tests/it/query.rs |
| D | a flood of GET /login | P1 | pending sign-ins and sessions are separate, bounded pools | max_sign_ins, max_sessions; carried by the deployment: a rate limit for /login at the edge |
The console sends no Strict-Transport-Security; set it at the proxy in
front of it.
B5: the admin listener
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| E | a remote peer runs a write action, such as the stored-query distribution | P7 | every write action needs a token that client authentication verifies and that carries auth.product_scope; the listener is off unless [metrics] listen is set, on loopback unless allow_remote = true, and never under the base path; the operator console calls it from its own server, on an internal address, with the signed-in operator’s own token, and the browser never reaches it | [metrics], auth.product_scope, the console’s [admin]; app/ferrofed-server/tests/it/admin_peer.rs, app/ferrofed-console/tests/it/admin.rs |
| S | a local process on the host or in the pod runs a write action | P7, P9 | the same token is needed from a loopback peer; only profile = "development" admits a loopback peer without one | profile; app/ferrofed-server/tests/it/admin_peer.rs |
| R | an operator denies running a write action | P7 | each admitted write action is logged under ferrofed::security before it has any effect (admin-write-admitted, counted) and when it ends with its outcome (admin-write-finished, abandoned when it never answered), with the operator’s issuer and subject, the action and the time; no log filter quiets the target | app/ferrofed-server/tests/it/admin_record.rs, stored_redistribution.rs; carried by the deployment: keeping the security log |
| I | anyone who reaches the port reads the metrics | P7 | no label carries a request value; off loopback the gateway refuses to start outside development unless a scrape client’s client-credentials token or a client CA authenticate the scrape; the Kubernetes package opens the port to Prometheus and the console alone | [metrics] scrape_clients, [[auth.issuer]], [metrics.tls] client_ca_file; app/ferrofed-server/tests/it/metrics/hygiene.rs, config/admin.rs; deploy/kubernetes/networkpolicy.yaml |
B6: audit and telemetry sinks
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| I | a patient identifier or a caller in a log line, a metric or a span | P6 | the request log carries no body, AQL text, header value or path; labels come from closed sets; spans carry route templates, ids and counts; the trace id is the gateway’s own | app/ferrofed-server/tests/it/request_log.rs, metrics/hygiene.rs, traces/; track10 asserts the logs clean |
| I | the audit spool read on disk | P9 | the spool is 0700 with files 0600, and the gateway refuses to start when it is open to other users | app/ferrofed-server/tests/it/audit_repository.rs; carried by the deployment: an encrypted volume |
| T, R | audit records lost with a spool | P9 | records are delivered in order from disk; a lost spool loses them for good, and audit_feed or audit_repository reads degraded while records wait | Losing a spool; carried by the deployment: durable storage per replica |
| I | metrics or spans pushed in cleartext | P3 | an https:// collector is reached over TLS with the platform’s roots, and the trust_roots_file and client_identity_file of [telemetry.otlp] add private trust roots and a client identity for mutual TLS; an http:// collector is plain gRPC, so run it beside the gateway; a credential in an http:// URL is refused outside development | app/ferrofed-server/tests/it/traces/tls.rs (a_span_reaches_a_mutual_tls_collector_only_with_the_identity_and_the_roots); carried by the deployment: an https:// collector for one off the host (Tracing) |
| R | no record of which caller read which EHR | P2 | every federated query, stored-query execution, routed read and routed write that reached a node is recorded with the verified caller, the patient, each ehr_id and each endpoint asked; the record is stored before the answer leaves, and an access whose record cannot be stored is 503 access-unrecorded with none of the data; outside development a gateway with a registry refuses every [audit] destination but repository; the node audits the conveyed caller too (N24) | The access log; [audit], [access_log]; app/ferrofed-server/tests/it/access/, feed_audit/config.rs |
B7: the callers’ issuers
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| S | a key set fetched in the clear and substituted | P3 | every issuer, the key set its metadata names, and every introspection endpoint is https, or http to loopback, under every profile | What must travel encrypted |
| D | tokens naming unknown keys make the gateway flood the issuer | P1 | an unknown kid reads the set again at most once per jwks_refresh_interval_seconds; an unavailable key set is 503, never a pass | auth.jwks_refresh_interval_seconds; app/ferrofed-server/tests/it/auth/keys.rs |
| S, E | an issuer compromised: any token it signs is admitted | P5 | none in the gateway: a trusted issuer is trusted for every caller it vouches for | carried by the deployment: a short trust list, per-issuer backend_clients and demographic_clients, auth.product_scope granted by your operators’ issuer alone, and removing an issuer at once, which a reload applies without a restart (Reloading the registry) |
B8: configuration and secrets
| Threat | From | Mitigation | Provided by | |
|---|---|---|---|---|
| I | a secret in a log, an error, the banner or a Debug print | P6 | secrets come from _file keys, are redacted in Debug and zeroised after reading; a refused configuration names the key, never the value | Configuration; app/ferrofed-server/tests/it/config/redaction.rs, config/secrets.rs, credentials.rs, banner.rs |
| T | a configuration that weakens transport or resolves from a static table | P9 | the gateway keeps no patient table and refuses a [dev] table at load; the development profile alone admits cleartext credentials to loopback services, takes a restart to change, and prints a red notice; config check exits 78 on a refused file | profile; app/ferrofed-server/tests/it/transport/ |
| S | the signing key leaks | P9 | the key is a file; rotation publishes the next key ahead of signing with it | [signing] next_key_file, previous_key_file; app/ferrofed-server/tests/it/next_key.rs |
Risks the deployment carries
The gateway cannot close these on its own. Each needs a decision or a control from you, or an issue that is open:
- The proxy-to-gateway hop is cleartext unless you set
[server.tls]. Bearer tokens and patient identifiers cross it. Keep it on one host or in one pod, put a mesh with mutual TLS on it, or serve TLS on the listener with a client CA that admits the proxy alone (TLS on the listeners). - The development profile opens the admin listener’s write actions on
loopback. Under
profile = "development"any process that reaches the listener’s loopback address runs a write action without a credential. Never serve real requests in that profile (Metrics). - The access records live at your Audit Record Repository. The gateway stores each record in the spool before it answers, and refuses an access it cannot record, but who may read the records and how long they are kept is the repository’s; each record states the period its origins and categories call for. The records are reviewed there with ITI-81 (Reading the log).
- Bearer replay. An onward token or a caller token stolen at a node can be replayed at that node until it expires (§13.4). DPoP or mutual TLS narrows it for onward tokens; the caller token lives 60 seconds.
- A compromised issuer is admitted for every caller it vouches for
until you remove it from
[auth]and reload (Reloading the registry). - A node that ignores the conveyed caller releases what its own credential allows. Admission is where you check that each node verifies the token (Admitting a node).
- The identity service is trusted for its links. A wrong link routes a query, or a bound patient grant, to another patient’s EHR.
- Per-replica routing state. A PMIR change reaches one replica, and the
others route on a stale binding for up to
binding_ttl(Several replicas). - The audit spool names patients; its encryption at rest and its survival are the volume’s.
- The console’s session ends when the operator’s access token expires, and a refresh is #643; its sign-in has no per-client rate limit of its own.
- Telemetry export is plain gRPC to an
http://collector; name anhttps://one for a collector off the host (A remote collector over TLS). - Egress. The Kubernetes package’s base leaves egress open so the
gateway reaches its members and services wherever they are; its
egresscomponent restricts it to the destinations you list, which are yours to fill in (Kubernetes).
The client contract
A client of a federation gateway is an ordinary openEHR client. This page sets
out what the specification promises that client. Once a registry is
configured, FerroFED serves the federated query at
POST {base}/v1/query/aql, and at its GET form
(the GET form), and routes the EHR resources under a path
ehr_id, {base}/v1/ehr/{ehr_id} and below it, to one node (§7a.1). It
reads an EHR by subject at GET {base}/v1/ehr?subject_id=…&subject_namespace=…
from the one member that resolves the subject
(reading an EHR by subject). A
request under {base}/v1/definition/ goes to the one node you name
(templates and definitions, §12.6). A
deployment that offers the stored-query registry stores queries under
{base}/v1/definition/query/ itself instead, and runs them by name at
GET or POST {base}/v1/query/{name} (stored queries, §12.7).
The DEMOGRAPHIC API under {base}/v1/demographic/ is never federated: it
answers 501, or goes to the one endpoint the deployment declared for it,
with or without a targeting header naming it (demographics, §7a.1, N32). Every other ITS-REST path under
/v1/ answers 501 (N32).
The contract continues on three pages: follow-ups, the reads and writes routed to one node; templates, definitions and demographics; and stored queries.
See how it works: a federated query, end to end.
The base URL
Every path in the client contract is relative to {base}, the base URL the
deployment chose and the registry or service discovery gives you (§4.1, N28). The
specification mandates and reserves no prefix: /rest/openehr is a vendor
convention, so do not hard-code it or any other. A gateway mounted at the
root serves POST /v1/query/aql; one the operator mounted at /fed/openehr
serves POST /fed/openehr/v1/query/aql, OPTIONS /fed/openehr/ and
GET /fed/openehr/v1/ehr/{ehr_id}, and answers 404 for every path outside
that base. The gateway asks each node at the node’s own base URL, so its own
base never reaches a node, and it passes a node’s Location through
unmodified (N31).
What a client sends
A conformant openEHR AQL request to POST {base}/v1/query/aql, where {base}
is the deployment’s ITS-REST base URL; no prefix is mandated (§4.1, N28). The
patient is identified the openEHR way:
SELECT c/uid/value AS composition_id, c/context/start_time/value AS start_time
FROM EHR e CONTAINS COMPOSITION c
WHERE e/ehr_status/subject/external_ref/id/value = '12345'
AND c/archetype_node_id = 'openEHR-EHR-COMPOSITION.encounter.v1'
No federation-specific syntax is needed for a basic patient query (§3.2, N1).
A client that wants to pin a query to named systems can do so in the AQL,
with FROM ENDPOINT … or ORGANISATION …, or beside it, with a request
header (§8).
The body is the ITS-REST AdhocQueryExecute in JSON, the one media type
ITS-REST lists for the operation. Send Content-Type: application/json, with
charset=utf-8 if you like, or none, and the body is read as JSON. Any other
Content-Type is a 415 (media-type-unsupported), and no node is asked
(RFC 9110 §15.5.16). ITS-REST makes the header optional and names no default,
so reading a body without one as the listed media type is the gateway’s own
choice.
The GET form
ITS-REST also defines the ad hoc query as a GET, with the members of the
AdhocQueryExecute body in the query string, and FerroFED serves it (N1):
GET {base}/v1/query/aql?q=SELECT%20c%2Fuid%2Fvalue%20FROM%20EHR%20e%20CONTAINS%20COMPOSITION%20c%20WHERE%20e%2Fehr_status%2Fsubject%2Fexternal_ref%2Fid%2Fvalue%20%3D%20%24patient&patient=P-12345&fetch=10
q,offsetandfetchare the body’s members of the same name, and every other pair is one member ofquery_parameters:patient=P-12345binds$patient. A value that reads as JSON other than a JSON string (a number, a boolean,null) is that value, and anything else is text. So a value of digits alone binds as a number: bind an identifier of digits through thePOSTform, where it stays a string. A+is a literal plus, never a space (RFC 3986 §2.1); send a space as%20.- The request runs the pipeline of the
POSTform unchanged: each node receives the same request, and you get the sameRESULT_SET, status and headers. The headers of pinning a query, completeness and dedup apply as they do to aPOST. ehr_idis dropped, as it is from aPOSTbody: the gateway scopes each node by its ownehr_id(§5.4.1, N33).- A query string the ITS-REST decoder refuses is a
400(body-invalid), the answer to a malformedPOSTbody, and no node is asked: noq, aq,offsetorfetchgiven twice, a query parameter given twice, anoffsetorfetchthat is no integer, or a pair that does not percent-decode to UTF-8 text. A parameter ofnullis a400(parameter-invalid), as in a body. - The request log never records
qor a parameter value, onlyoffsetandfetchwhen they are digits.
Pinning a query to named systems
The gateway accepts both mechanisms of §8.4, and they select nodes the same way (N35). Use either one; there is nothing to discover first, because every conformant gateway accepts both (§7a.2).
Put the directive at the start of FROM, and the gateway asks exactly the
endpoints it lists (§8.1, N11):
SELECT c/uid/value AS composition_id
FROM ENDPOINT p [ "node_1", "node_2" ]
CONTAINS EHR e CONTAINS COMPOSITION c
WHERE e/ehr_status/subject/external_ref/id/value = '12345'
FROM ORGANISATION [ "org-a" ] CONTAINS … asks every endpoint the registry
lists as managed by each organisation (N20). The identifiers are the stable
registry identifiers of meta.federation.endpoints[].id, never URLs (§8.1,
N19), and the keywords are case-insensitive like every AQL keyword.
- The directive does not replace finding the patient. Each listed endpoint
is still asked about its own
ehr_idfor the patient (N7, N11). A listed endpoint where the patient is not known is reportednot-resolved, and the query still answers200(§8.1, §11.3). - Every registry endpoint the directive does not list is reported
excluded. It is not asked and does not clearmeta.federation.complete(§11.1). - No node receives the directive. The gateway sends each node standard AQL, so the node query is the same as for the undirected request (N7).
- An identifier the registry does not know is refused
400withendpoint-unknownororganisation-unknown, before any node is asked (§8.4.1). A registry document refuses an organisation that manages no endpoint (§8.1: “an Organization manages 1..* Endpoints”). A selection that still holds no endpoint, such as an organisation whose endpoints left an mCSD directory, leaves the request without a destination,404withno-destination(§11.2). - A query directed at one endpoint may use what a fan-out refuses: an aggregate, which goes to the node unchanged (N14, §11.6.3), and a function AQL does not define. Pinned to more than one endpoint, the same query follows the rules for an undirected one.
- The variable (
pabove) may be selected or ordered on, as an ENDPOINT attribute (§9.3). Using it inWHEREor inside a function, or binding its name again inFROM, is refused400withendpoint-variable.
Or leave the AQL as it is and send the node set in a header (§8.4). This is the form for a stored query or a query a user wrote, since the query text stays the same however it is targeted:
POST {base}/v1/query/aql
openEHR-federation-endpoint: node_1, node_2
Content-Type: application/json
openEHR-federation-organisation: org-a is the organisation form. Each
header carries a comma-separated list of the same registry identifiers, and
may be sent as several field lines; empty list elements are ignored (RFC 9110
§5.6.1).
- The header selects nodes exactly as the directive does:
not-resolvedfor a listed endpoint that does not know the patient,excludedfor every other endpoint,400withendpoint-unknownororganisation-unknownfor an identifier the registry does not know or a header with no identifier in it, and404withno-destinationwhen the selection holds no endpoint. A query directed at one endpoint by the header may use an aggregate, as one directed by the AQL may. - Send the directive and a header, or both headers, only when they select the
same endpoints. Then the request proceeds. When they differ, it is refused
400withtargeting-conflict, naming both sets; the gateway never merges them and never picks one (§8.4.1, N35). - No node receives either header, and no query parameter targets anything:
?endpoint=and?organisation=on the federated query have no effect (§8.4). - The headers apply to every federated request, the routed requests of follow-ups included; the directive applies to AQL only (§8.4).
Provenance columns: ENDPOINT attributes
A directed query can ask for the endpoint each row came from. Select an attribute through the directive’s variable, and the gateway fills it in for every row from the registry entry of the endpoint that answered (§9.3, N12):
SELECT p/id AS endpoint_id, p/system_id AS system_id, c/uid/value AS composition_id
FROM ENDPOINT p [ "node_1", "node_2" ]
CONTAINS EHR e CONTAINS COMPOSITION c
WHERE e/ehr_status/subject/external_ref/id/value = '12345'
| Path | Value in the row |
|---|---|
p/id, p/endpoint_id | the endpoint id, as meta.federation.endpoints[].id reports it |
p/organisation, p/organization_id | the endpoint’s managing organisation (N20) |
p/system_id | the openEHR system_id of the endpoint’s node, the follow-up routing key (§12) |
p/url | the CDR base URL the registry holds |
- Each value is a JSON string. No node is asked for it, and no node receives the variable or the directive (§8.1).
- A query that selects no attribute gets exactly the columns and rows a single CDR would return (N17).
columns[]is the gateway’s rendering of your query: an attribute column is named by its alias, and its path keeps the AQL variable as you wrote it,p/idforp/idandc/uid/valueforc/uid/value, as the §9.4 example renders it (§9.2).- An alias keeps an attribute apart from an EHR-derived column of the same
name:
p/system_id AS node_system, e/system_id/value AS system_idreturns both (N18). Giving the attribute and an EHR-derived column the same alias is refused400withendpoint-name-collision, so no column is shadowed (CP-35). Any other path through the variable, such asp/nameorp/id/value, is refused400withendpoint-attribute-unknown. - Under
SELECT DISTINCTthe attributes count as selected columns: two rows of two endpoints are two rows when their attributes differ (N13). - An attribute beside an aggregate over more than one endpoint would count per
endpoint, so it is refused
400withindecomposable-aggregate; pinned to one endpoint, the aggregate row carries the attribute (§11.6.3). - You can order on an attribute, alone or among other keys:
ORDER BY p/organisation DESC, c/context/start_time/value LIMIT 10. The gateway orders on it itself, in the place you wrote it, and compares its values as it compares any string. No node receives the key: each node is sent your other keys and yourLIMIT, and since an attribute has one value per endpoint, each node’s first rows under those keys are still its share of the federated first rows (§8.1, §11.6.1). Rows tied on every key are ordered by endpoint id, then by uid (§11.6.1). - Under
SELECT DISTINCT, an attribute you order on must also be selected, as a path must (order-not-selected, N13). Under version-identity dedup with aLIMIT, ordering on an attribute is refused400withendpoint-order-dedup: the copies of one version at two endpoints differ on it (§10.2, §11.6.1).
What a client gets back
An openEHR RESULT_SET exactly as ITS-REST 1.1.0 defines it, rows as ordered
arrays matched to columns (§9.1). A client that parses the AQL response of a
single CDR parses a federated one. Everything the federation adds lives in one
member of the open meta object, meta.federation:
complete, whether the answer covers every node in scope (§11.4);endpoints[], one entry per node with its status and provenance (§9.5, §11.1);timeout, the budget that applied (§11.5);dedup, the de-duplication policy that applied (§10);localization, only when a configured localizer did not answer:localization.errorcarries its error, which every member also carries (§14.1). Under the default fail-closed policy no member is asked, every member isnot-localized, andcompletestaystrue, so this member is how you tell “the localizer is down” from “no node holds this patient”.consent, only when the consent pre-filter could not answer:consent.errorcarries its error. Every candidate was then asked and each node applied its own consent check, socompleteand the status are what the nodes made them (Consent).
By default you get every row every node returned, duplicates included
(§10.1, N15). Send openEHR-federation-dedup: version-identity to get one
copy of a composition version held at several nodes: the copy from the CDR
that created it when that CDR answered, else the one from the lowest
endpoint id. Version ids and system ids that differ only in case name the
same thing (openEHR BASE), and the kept row comes back as its node sent it.
The gateway then orders the version uid without regard to case too, in an
ORDER BY on it and as the tie-break, so the page a LIMIT cuts does not
depend on the case a node wrote it in (§11.6.1).
meta.federation.dedup then names the endpoints whose
copies were dropped and counts the rows (§10.2, §10.3). A write you derive
from a kept row is routed like any other versioned write (see
Writing a new version): it reaches the CDR that
created the version, and it does not update the copies the dedup dropped.
Two versions of one
composition are two rows either way, and none states the default
explicitly. Any other value is refused 400 with the code dedup-invalid.
An aggregate across nodes comes back as one row in your query’s columns
(Aggregates across nodes).
AVG answers in the type of its input (AQL 1.1.0 §3.9.1.5): over integers,
the integer nearest the federation’s mean, a tie going to the even one, and
over reals, a real.
Two more request headers shape the answer (Queries and API areas):
openEHR-federation-completeness: partialasks for the rows of the nodes that answered when another node failed, with200andcomplete: false, where the deployment offers it;all, the default, fails the query instead (§11.4, N37).Prefer: wait=<seconds>shortens the time budget for this request, andPreference-Appliednames it when it did (§11.5, N38; RFC 7240 §4.3).
Read whether an answer is complete from meta.federation.complete, never
from the status code; the gateway emits no FHIR OperationOutcome, because
its answer is an ITS-REST RESULT_SET (§11.4, N17).
The answer also names who answered in two response headers (§7a.3, N31):
openEHR-federation-endpoint carries registry endpoint identifiers and
openEHR-federation-system-id the system_ids of their nodes, position for
position, each as a comma-separated list in the form of the targeting header
(§8.4):
HTTP/1.1 200 OK
openEHR-federation-endpoint: node_1, node_3
openEHR-federation-system-id: cdr1.example.org, cdr3.example.org
- A query the gateway sent to a single node names that node whatever it
answered, zero rows, a
424or a504included (N31). That is a query you directed at one endpoint, with the header orFROM ENDPOINT, a query whose patient resolves at one member alone, and a query scoped to oneehr_id, which names the node it was routed to. A query that asked no node, because the patient isnot-resolvedwherever it was looked for, carries neither header. - An answer from several nodes lists the endpoints that contributed rows, in registry
order. An endpoint contributed when it answered
activewith arow_countabove 0, the count §9.5 takes beforeDISTINCT, dedup andLIMIT. An endpoint that answered no rows, failed, timed out, or was skipped,excludedornot-resolvedis not listed, and an answer that fails (424,504) lists none. meta.federation.endpoints[]stays the record of every endpoint and its status (§11.1); the headers are a convenience. No specification fixes the list form, so FerroFED uses the form of the targeting header.- The headers carry registry identifiers and nothing your request sent.
A request that fails answers the status §11.2 names and a stable code; the errors and status codes page lists every one.
A node’s error in endpoints[]
A member that answered with an error is node-error, and its error carries
the node’s own failure (§9.5, §11.1): the node’s HTTP status, then an excerpt
of the node’s message, the ITS-REST Error.message when the body is one and
the body’s text otherwise.
{ "id": "node_3", "status": "node-error", "latency_ms": 41,
"error": "the node answered 400 Bad Request: unknown archetype path in WHERE" }
- The excerpt is at most 512 characters, with
…where it was cut. - Control characters and invisible format marks (line breaks, tabs, the bidirectional overrides, zero-width marks) become one space.
- The patient identifier your query resolved on never comes back: where the
node echoed it, raw or as an AQL string literal, it reads
[withheld], and where the node echoed it in a form the gateway cannot replace in place, percent-encoded for one, the whole message reads[withheld]. - A node that sent no message, or nothing printable, is reported by its status alone.
- A node reported
offlineortime-outin a federated query carries the HTTP client’s reason it was not reached, held to the same three rules.
One rule covers every per-member record FerroFED writes: a federated query, the fan-out template upload, and the stored-query distribution and drift check. A node’s body that is no error, an accepted upload’s body or a member’s copy of a stored query, is never copied.
Writes through a gateway with several replicas
A deployment may run several gateway replicas behind one address. Each
replica holds its own resolution bindings and ehr_id index in memory, and
shares neither with the others
(Running several replicas).
A write under a path ehr_id that names no node is routed by those two
alone (§12.5.1, N41), so it depends on which replica the balancer sends it
to: the replica that answered your query routes it, and another replica
answers 400 with the code target-required. Nothing reaches a node in
that case, so repeating the write is safe. A read of a UUID ehr_id does
not fail this way: a replica without the binding probes for the owner
instead (Routing a path ehr_id).
Two remedies hold for any number of replicas:
- Name the node on every write. Send
openEHR-federation-endpointwith theendpoint_idyour result row carried (§8.4). The header routes on every replica, the first step of §12.5.1 decides, and no state is consulted. This is the recommended form, and the only one that holds across a restart of the replica you were talking to. - Ask the operator for affinity. A balancer that keeps your requests on
one replica keeps your bindings within reach, such as a Kubernetes Service
with
sessionAffinity: ClientIP(Session affinity). Affinity narrows the window and does not close it: a replica that restarts, a scale-down, the affinity timing out, or a change of your client’s address each move you to a replica without your bindings, and a write that names no node is then400again.
Self-description
OPTIONS {base}/ returns what the gateway does and which members stand
behind it, as JSON that validates against the specification’s
options-root.schema.json (§7a.2, N30). It needs no patient identifier and
carries none. It answers only an authenticated caller, and 401 to any other
(§7a.2, §13; Client authentication); the
caller needs no scope and no purpose of use for it. Every value comes from the running
configuration, so the body says what the gateway does today:
| Member | What FerroFED declares |
|---|---|
federation.id | the deployment’s federation.id |
federation.spec_version | 1.0, the major.minor of the pinned specification release |
aql.fan_out | true: an undirected query asks every member (§4.3, N4) |
dedup | default: "none", modes: ["none", "version-identity"], and the request header openEHR-federation-dedup (§10, N15) |
timeout | the configured per_node_ms and overall_ms, with policy: "abandon-and-mark": a node past its budget is abandoned and reported time-out (§11.5, N38) |
completeness | default: "all-or-nothing"; best_effort and, when it is offered, opt_in naming openEHR-federation-completeness: partial (§11.4, N37) |
paging | offset_strategy: "bounded" with the configured max_window, or "reject"; never "cursor", because no cursor is offered (§11.6.2, N39); and max_node_rows, the most rows read of one node for a query with no LIMIT (§11.5) |
aggregates.decomposable | the configured functions, of COUNT, SUM, MIN, MAX and AVG; an empty list means none (§11.6.3) |
definition | fan_out_template_upload as federation.fan_out_template_upload sets it (false by default); stored_query_registry is true while [stored_queries] is set and false otherwise; stored_query_fan_out is true while federation.fan_out_stored_queries is set beside the registry and false otherwise (N43, N44, §12.7) |
localization.on_failure | "closed", the default: a localizer that does not answer leaves every member not-localized with its error, and nothing is asked; "ask-all" only where the deployment set federation.localization.on_failure (§14.1, N30) |
localization.mode | present with a localizer configured: the binding that localizes: "xcpd", "nl-gf-nvi" for the NVI of the Dutch Generic Functions, or "pixm" |
localization.audit | present with the XCPD localizer: where its ITI-55 audit messages go: "repository", an ATNA Audit Record Repository over ITI-20, "log", or "off" in a development deployment (ITI TF-2 §3.55.5.1, §3.20) |
timeout.localization_ms | present with a localizer configured: the localizer’s own budget, a part of overall_ms |
timeout.demographics_ms | present with [pdqm] configured: the demographics step’s own budget, a part of overall_ms |
timeout.consent_ms | present with a consent pre-filter that asks a service, Mitz: the pre-filter’s own budget, a part of overall_ms |
consent | present with a consent pre-filter configured: its prefilter mode ("nl-gf-mitz"), on_unavailable: "pass-to-node", and disclose: true, the default, where a member the pre-filter excludes is consent-denied (N27a), or false where it is reported not-resolved like a member that does not know the patient, for Regulation (EU) 2025/327 Art 8 (Withholding consent exclusions) |
auth.jwks_uri | the absolute URL of the JWK Set the gateway serves at {base}/.well-known/jwks.json, with the keys it signs the caller token and its client assertions with; every federating gateway has [signing], so it is always declared (§13.1, N25, N30) |
its_rest | query federated, ehr routed to the one node that owns the ehr_id (§12.5.1), definition routed-single-node, to the one endpoint the targeting headers name, naming the template upload fan-out where it is offered, with stored queries held at the gateway registry when it is offered and routed with the rest when it is not (§12.6, §12.7, §7a.2), and demographic unsupported (501), or routed-single-node naming the endpoint a request names when federation.demographic_endpoint is set; never federated (§7a.1, §12.6, N32) |
endpoints[] | every registry endpoint with its id, its managing organisation, its status (active, or suspended for one the operator took out of service), its node_id and system_id, the node’s product and version where the registry holds them, and latency_ms_p50 and latency_ms_p95, the median and the 95th percentile of the last 100 requests the gateway sent that endpoint, in milliseconds, where it has sent any since it started (The latency in the self-description) |
manufacturer | FerroFED’s manufacturer, beside federation and endpoints: its name, postal_address, email (the single point of contact) and website, as Regulation (EU) 2025/327 Art 30(1)(g) asks of an EHR system; FerroFED’s own member at the open top level of the schema |
What is absent is absent on purpose:
- No targeting mechanism and no patient-resolution carrier. Both forms of each are mandatory at every gateway, so there is nothing to choose (§7a.2, N33, N35).
- No asynchronous queries. The schema has no member for them, and the gateway does not offer them (§11.7).
- No latency for an endpoint the gateway has not sent a request to since it started: both statistics are left out rather than reported as zero.
latency_ms_p95is FerroFED’s own member inside the open endpoint object, named for its statistic as §7a.2 asks.paging.max_windowis FerroFED’s own member inside the openpagingobject: the specification names no member for the bound of theboundedstrategy.
OPTIONS on a path under {base}/v1/ answers 204 with the methods served
there in Allow: GET, POST, OPTIONS for /v1/query/aql, and the ITS-REST
methods of the resource for an EHR resource under a path ehr_id, such as
GET, PUT, OPTIONS for /v1/ehr/{ehr_id}, and GET, POST, OPTIONS for
/v1/ehr. A definition resource answers the
ITS-REST methods of the resource, such as GET, POST, OPTIONS for
/v1/definition/template/adl1.4. Where the stored-query registry is
offered, a stored query answers GET, POST, OPTIONS. Where the DEMOGRAPHIC area is
routed, a DEMOGRAPHIC resource answers its ITS-REST methods, such as
GET, PUT, DELETE, OPTIONS for /v1/demographic/person/{uid_based_id}. The
gateway answers it without asking a node. A path the gateway does not serve
answers 501.
Follow-ups
A composition id in a result row is an OBJECT_VERSION_ID, which already
carries the creating_system_id of the CDR that created it (§12.2). A new
object is always created on one node the client names; creation across
nodes is refused (§2.3, N23).
See how it works: follow-ups and writes.
Reading one version
A read whose path names one version, such as
GET {base}/v1/ehr/{ehr_id}/composition/{uid_based_id}, or a version_uid
under versioned_composition, ehr_status, versioned_ehr_status or
directory, goes to the node you name in openEHR-federation-endpoint, or
else to the node the path ehr_id resolves to, in the order below (§12.5.1,
N41). The version’s creating_system_id does not route it: a request under a
path ehr_id is routed on that ehr_id (§12a.1), and the gateway never
rewrites the path for another node (N22). A copy imported into another node
carries the same version, so reading it where the row came from returns the
same content. To read the copy at the node that created it, send that node’s
ehr_id for the patient, which a row from that node carries.
The gateway still learns which node holds versions of each
creating_system_id the registry document does not map (§12.2, N21). It
reads every version uid in a federated query’s rows (as c/uid/value, as a
uid, or as the uid of a selected COMPOSITION or VERSION) and every
version uid a routed read names or answers with in its ETag. A system seen
at two nodes raises an integrity incident for the operator. These learned
routes never route a read under a path ehr_id, and never make a node that
holds a copy the CDR that controls it.
Routing a path ehr_id
An ehr_id carries no system component, so a request to
{base}/v1/ehr/{ehr_id}/… does not say on its face which node holds the EHR
(§12.5). The ehr_id in the path must be an openEHR HIER_OBJECT_ID; any
other value is a 400 (ehr-id-invalid) and nothing is routed. The gateway
then finds the node in this order, and takes no later step once one names
exactly one node (§12.5.1, N41):
- The targeting headers. Name the node yourself in the
openEHR-federation-endpointheader, with theendpoint_idthe result row carried (§8.4), or in theopenEHR-federation-organisationheader, when the organisation manages that one endpoint. This is the recommended way. Together the headers select exactly one registry endpoint: an unknown identifier, several endpoints or two headers that disagree are a400, and an organisation that manages no endpoint is a404(no-destination). A query parameter such as?endpoint=names no node here either; it is one the operation does not declare, so it is a400(query-parameter-refused) and nothing is sent. - A resolution binding of your session: the node one of your earlier
queries, or a read of an EHR by subject, resolved that
ehr_idat. Your session is the caller the gateway verified (Client authentication): the token’s issuer, subject and client together. Another caller, or the same subject through another client, never sees your bindings. A binding lives forfederation.binding_ttlafter the last of your resolutions that returned it, and a consent denial of a member drops every binding of yours that names it. A binding records only where anehr_idwas resolved, never a consent decision: the node still decides what it releases. - The gateway’s
ehr_idindex, which it learns from resolutions and from the nodes’ successful answers. - For a read only, an ask-all probe: the gateway sends
GET {base}/v1/ehr/{ehr_id}to every member at once, within its per-node timeout and overall budget (§11.5). The one member that answers with the EHR, while every other member answers404, gets your read; a read of the EHR itself is answered from that probe. When every member answers404, the read is a404(no-destination). When two members hold theehr_id, the read is a409(ehr-id-collision) that lists them, and neither is read (§12.5.2, N42). When a member does not answer in time, cannot be reached, or answers an error, the owner is unknown and the read fails:504(node-timeout,node-unreachable) or424(node-error,node-refused), naming the member. The probe is sent only for anehr_idthat is a bare UUID. Any otherHIER_OBJECT_IDform, such as an ISO OID, could be a patient identifier, and the probe would carry it to every member, so the read is a400(probe-requires-uuid) and no member is asked (§5.4.1, N33). Such anehr_idis routed by the first three steps only: name its node in the endpoint header, and the gateway forwards it to that node alone.
A binding or an index entry that names two members is a collision, and the
gateway never picks one of them: the request, a read or a write, is a 409
(ehr-id-collision) that lists them, nothing is probed, and neither member
is sent it (§12.5.2, N42). The explicit target of step 1 still routes such an
ehr_id to the node you name. A write that none of the first three steps routes is a 400
with the code target-required, and nothing is probed, because the gateway
never finds a write’s destination by trial (§12.5.1, N41). Steps 2 and 3
are held by each gateway replica on its own, so behind several replicas a
write that names no node can be routed by one replica and refused by the
next. Name the node on every write
(Writes through a gateway with several replicas).
Querying one EHR by its ehr_id
An AQL query can address one EHR the way the path does, in either form (N29), and the gateway treats the two as the same query:
SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c
WHERE e/ehr_id/value = '7d44b88c-4199-4bad-97dc-d78268e01398'
SELECT c/uid/value
FROM EHR e[ehr_id/value='7d44b88c-4199-4bad-97dc-d78268e01398'] CONTAINS COMPOSITION c
The node receives the first form, the canonical one of §7.1, whichever you
sent, so both answer the same rows. An ehr_id belongs to the node that
issued it (§12.5), so a query scoped to one ehr_id and naming no endpoint
goes only to the node that owns it, found in the order above: your session’s
binding, the ehr_id index, then the ask-all probe, a query being a read.
meta.federation.endpoints[] reports that node and every other member as
excluded, and the openEHR-federation-endpoint header names the node
(N31). The answers of the probe apply as they do to a path: two members
holding the ehr_id are a 409 (ehr-id-collision) and neither is queried,
an ehr_id no member holds is a 404 (no-destination), an ehr_id that
is not a bare UUID and that no earlier step routes is a 400
(probe-requires-uuid), and one that is not an openEHR HIER_OBJECT_ID is a
400 (ehr-id-invalid). A query that names its endpoints, by the directive
or a header, goes to those endpoints as named. The query is scoped only when
every top-level ehr_id predicate names the same ehr_id, joined by AND;
a query that names two ehr_ids, or one under OR, is answered as a query
over every member.
A routed request reaches the node as you sent it:
- the body byte for byte, a
DV_IDENTIFIERin a committedCOMPOSITIONincluded, because a commit body is clinical content the gateway has no right to alter (§5.4, N33); - the method and the path, under the node’s own base URL;
- the request headers the ITS-REST operation you address declares (of
Accept,Content-Type,If-Match,Preferand theopenehr-*commit headers, the ones that operation lists), and no other header.If-Matchreaches the node on aPUT, for example, and never on aGET. YourAuthorizationnever reaches a node: the gateway authenticates to each node with that node’s own credentials, and tells the node who you are in anopenEHR-federation-clienttoken it signs itself (§13.1, N24, What a node is told about the caller). Neither does yourX-Request-Id: the node receives the gateway’s own id for the request. Nor do the targeting headers, which mean nothing at a node (§8.4); - the query string, when the operation declares every parameter in it (such
as
version_at_timeon a read, orpathon a directory read). Any other parameter is a400(query-parameter-refused) and nothing is sent, because the gateway cannot tell an identifying value from any other (§5.4.1, N33); Accept,Content-TypeandPreferas the gateway composes them from yours: the media type the operation lists that yourAcceptprefers (the first listed for*/*or noAccept), the listed media type yourContent-Typenames, with acharset=utf-8dropped, and only the listed preferences of yourPrefer. AnAcceptthat admits no listed media type is a406(media-type-not-acceptable), and aContent-Typethat names none is a415(media-type-unsupported), as a node would answer. A body never reaches a node without aContent-Type: when you send none, the node receives the one media type ITS-REST declares the operation’s body in, and an operation that declares several is a415;- each path identifier and each other declared value only when it is what
the operation declares: a
version_uidthat is anOBJECT_VERSION_ID, aversioned_object_uidthat is a UUID, aversion_at_timein the extended ISO 8601 format. Any other value is a400(parameter-value-invalid) and nothing is sent. A value of free text, such asIf-Matchor a directorypath, travels as you sent it (see Declared values).
The answer is the node’s: its status, its body, and its Location and ETag
unmodified, since openEHR uids are never rewritten (N22, N31). Every routed
answer, POST, PUT and DELETE included, names the acting endpoint in
openEHR-federation-endpoint and its node’s system_id in
openEHR-federation-system-id (§7a.3, §9.6). A node’s Location is the
node’s own URL or path, so following it bypasses the gateway; send the
follow-up to the gateway with the version uid instead.
Writing a new version
A versioned write amends a version that exists: an update of a composition,
of the EHR_STATUS or of the directory, a delete of the directory, each
naming the version it amends in If-Match, and a delete of a composition,
naming it in the path. It goes to the CDR that controls that version, the one
whose system_id equals the version’s creating_system_id, and to no other
node (§12.4, §12a.1, N23). Writing at a node that holds only an imported copy
would fork the object (§10.3).
The gateway routes the write by its path ehr_id, in the order above, and
never by ask-all (§12a.1, N41). It then checks that node against the version:
the registry must map the version’s creating_system_id to it, as the
node’s own system_id or as a [[creating_system]] mapping the operator
registered. A mapping the gateway learned from answers never counts, because
a node that holds versions of a system need not have created them. The
outcomes:
- The path node controls the version: the write goes there once, as you sent
it, and the node’s answer comes back with its
ETagandLocation. - Another member controls it, or no member is known to: the write is a
409(controlling-system-unreachable), and no node is sent it. The message names the controlling system: the version’screating_system_idas the registry spells it, with the controlling node and its endpoint. When no member is known to control the version, the message names the version’screating_system_idas your request spells it, says the controlling system is outside the federation, and points at the place in your request that names the version (If-Match, the path, or the version of aCONTRIBUTIONby its position). It quotes no other part of your request. The pathehr_idbelongs to the node it routes to, so the gateway cannot send the write to the controlling node instead; send it there under that node’s ownehr_idfor the patient. - The write names no single version:
If-Matchis absent, repeated, a list,*, a weak tag, unquoted, or noOBJECT_VERSION_ID, or a composition delete’s path is noOBJECT_VERSION_ID. That is a400(preceding-version-invalid), and nothing is sent.
A CONTRIBUTION names the versions it amends in its body, as each version’s
preceding_version_uid (ITS-REST 1.1.0 contribution_create). The gateway
reads the body to find them, routes the request by its path ehr_id as above,
and holds the node to every amended version by the same rule: one version
another member controls, or no member is known to, makes the whole
CONTRIBUTION a 409 (controlling-system-unreachable), and no node is sent
it. A version with no preceding_version_uid creates an object, so a
CONTRIBUTION of creations alone goes where its path ehr_id routes it. The
node receives the body byte for byte as you sent it.
The gateway reads a CONTRIBUTION in the representation its Content-Type
selects:
application/json, or noContent-Type: canonical JSON;application/openehr.wt.flat+jsonorapplication/openehr.wt.structured+json: a canonical envelope whose versions’datais FLAT or STRUCTURED (ITS-REST 1.1.0contribution_create). The gateway reads the envelope for eachpreceding_version_uidand leavesdatato the node, so such aCONTRIBUTIONroutes exactly as a canonical one does, and the node receives it under the media type you declared.
A body it cannot read as the representation you declared, or a
preceding_version_uid that is no OBJECT_VERSION_ID, is a 400
(preceding-version-invalid), because the gateway cannot tell which
versions it amends (§12.4), and nothing is sent. A CONTRIBUTION in
canonical XML (application/xml) is a 415 (media-type-unsupported),
answered before it is routed, as a node answers it: contribution_create
takes its body in JSON alone (ITS-REST 1.1.0 EHR API), and no openEHR
specification defines an XML CONTRIBUTION.
The same rule covers a write against a row that de-duplication kept
(§10.3, N36). Suppose node A created a composition and node B holds an
imported copy under its own ehr_id. The kept row names node A’s endpoint,
and meta.federation.dedup names node B’s. A write through node B’s
ehr_id is a 409 naming node A’s system and endpoint, and node B is sent
nothing. That holds while node A is down too: the gateway never asks node A
before refusing, and it never falls back to writing at the copy. Send the
write through node A’s ehr_id. Its answer names node A alone in
openEHR-federation-endpoint and openEHR-federation-system-id. Copies do
not converge: node B keeps the old version until it imports again.
Creating an EHR
A new EHR has no owner yet, so neither a binding nor the index can name its
node, and nothing is probed for it. POST {base}/v1/ehr and
PUT {base}/v1/ehr/{ehr_id} go only to the one endpoint you name in
openEHR-federation-endpoint or openEHR-federation-organisation (§12.4,
N23). Without a header the request is a 400 (target-required); headers
that select several endpoints are a 400 (endpoint-several), because an
EHR is created at one node only (§2.3). The body, its EHR_STATUS subject
included, reaches that node byte for byte, and the node’s Location and
ETag come back unmodified. A composition or a directory created inside an
existing EHR is routed by its path ehr_id like any other request under it.
PUT {base}/v1/ehr/{ehr_id} chooses its ehr_id, and the gateway checks it
against what it already knows before sending anything. When your session’s
resolution bindings or the ehr_id index place that ehr_id at a member
other than the one you name, the request is a 409 (ehr-id-held), and no
node is sent it: the create would put one ehr_id at two members, the
collision of §12.5.2. The message names the holding endpoints and the one
you named, never the ehr_id. When they place it at the member you name,
the request goes there, and that node answers its own 409 for an ehr_id
it already uses (ITS-REST 1.1.0 ehr_create_with_id). An ehr_id the
gateway does not know goes to the member you name, and once that member
answers with a success, the ehr_id index holds the ehr_id there. Two
creates of one ehr_id at two members that race past the check both reach
their nodes, and the second to succeed raises the index-insert alarm of
§12b.2 for the federation operator; from then on a request under that
ehr_id is an ehr-id-collision until you name the endpoint.
Reading an EHR by subject
GET {base}/v1/ehr?subject_id=…&subject_namespace=… names the patient in
its query string, and no node may receive a directly identifying identifier
(§5.4.1, N33). The gateway therefore consumes both parameters as resolution
input: it resolves the subject at the members through the cross-reference
service (§5.2) and sends the member that holds it
GET {base}/v1/ehr/{ehr_id} under that member’s own ehr_id, with no query
string and no client header the operation does not declare. The node’s
EHR, ETag included, comes back as the node sent it, with the acting
endpoint in openEHR-federation-endpoint and its system_id in
openEHR-federation-system-id (N31, §9.6).
- Several members hold the subject: a patient can have an EHR at more
than one member, and this operation returns one. The gateway never picks
one by where the patient resolved (§12.5.2), so the answer is a
400(subject-several) listing the endpoints, which requires an explicit target (N41). Name one inopenEHR-federation-endpointto read its EHR; the header limits the resolution to that endpoint (§8.4). - No member holds the subject, or not the one the header names: the
operation’s own
404for a subject with no EHR (no-destination). This request reads one EHR resource, so the200with no rows that §11.3 sets for a query does not apply. - The cross-reference cannot answer for a member: a
424(resolution-unavailable), never a404, because that member may hold the EHR. - A localizer is configured (
node_selection = "localized"): the read names the patient and no node, so it is localized as an undirected query is (N4, §5.2, §14.1). The subject is resolved only at the members the localizer names, and every other member learns nothing of the request. A localizer that answers that no member holds the patient leaves the404. A localizer that does not answer is a424(localization-unavailable) with no member asked, under the default fail-closed policy; underon_failure = "ask-all"every member is a candidate. A read withopenEHR-federation-endpointis never localized (§8). - The consent pre-filter denies a member: the subject is never resolved
there and the member is sent nothing, as on a federated query (N27a). If
no other member holds the subject, the answer is a
403(consent-denied) naming the denied endpoints, never a404, because a denied member may hold the EHR. A pre-filter that cannot answer leaves every member to its own consent check (Consent). subject_idandsubject_namespaceare each given once; anything else in the query string is a400, and nothing is resolved or sent.
No error body and no log line carries the subject.
Templates, definitions and demographics
A request under the definition area goes to the one node you name, and one under the DEMOGRAPHIC area to the one node the deployment declares. The one exception is a template upload, which a deployment may offer to fan out to several members.
Templates and definitions
A template lives at the node it was uploaded to, and a COMPOSITION built on
it validates only there. So every request under {base}/v1/definition/ (an
ADL 1.4 or ADL 2 template upload, the template list, one template, its
example composition, and stored-query management where the gateway holds no
registry) goes to the one endpoint you name in openEHR-federation-endpoint
or openEHR-federation-organisation (§7a.1, §12.6, N43). The gateway never
picks a node for you and never probes for one:
- Without a header the request is a
400(target-required). - Headers that select several endpoints are a
400(endpoint-several), and*is no endpoint the registry knows, so it is a400(endpoint-unknown) like any other unknown id. A template upload is the one exception, and only where the deployment offers fan-out template upload. - The body reaches that node byte for byte, and only the headers and query parameters the ITS-REST operation declares travel with it (§5.4.1, N33).
- The answer is that node’s answer, its status, body,
LocationandETagas the node sent them, withopenEHR-federation-endpointandopenEHR-federation-system-idnaming who acted (§7a.3, N31). A template list is one node’s list: the gateway never combines two nodes’ templates into one catalogue (§12.6). - A node’s own error comes back as the node sent it. A
404for a template the node does not hold, or a400for a template it rejects, is never masked or rewritten (§12.6, §11.2).
Without the registry, both stored-query PUTs are routed, the versioned
PUT {base}/v1/definition/query/{name}/{version} included, and OPTIONS
lists PUT on both paths. The query text reaches the node byte for byte,
always as text/plain, the media type ITS-REST declares for it: send that
Content-Type, or none and the gateway sets it.
A malformed value of a header or query parameter the operation declares is
refused before the target is read: a 400 (parameter-value-invalid), or
the 406 or 415 a node would answer an Accept or a Content-Type it
cannot serve, never target-required.
Where the gateway offers the stored-query registry, the registry answers every
request under {base}/v1/definition/query/ itself, with or without a header,
and templates still go to the one node you name (stored
queries, §7a.2).
Fan-out template upload
A deployment may offer one template upload applied to several members, so
that a later COMPOSITION commit validates wherever it lands (§12.6, N43).
It is off by default, and OPTIONS {base}/ declares it as
definition.fan_out_template_upload (§7a.2). Where it is offered:
- Only an ADL 1.4 or ADL 2 template upload
(
POST {base}/v1/definition/template/adl1.4or.../adl2) fans out, and only when you ask for it:openEHR-federation-endpoint: *names every active member, and a header that selects several endpoints names those. An upload that names one endpoint goes to that node alone and comes back as that node answered; an upload that names none is still a400(target-required). Every other definition request routes to one node, and*staysendpoint-unknownthere. A list naming a suspended endpoint, or*with no active member, is a404(no-destination) and nothing is sent;*leaves a suspended member out and reports itexcluded. - Each member is sent the upload on its own, the body byte for byte, with only the headers the operation declares. A member that accepts keeps the template whatever the others answer: nothing is rolled back.
- The answer is a JSON body holding
meta.federation, in the shape of a federated result set’s (§9.5):complete, thetimeoutin force, and oneendpoints[]entry per registry member. A member that accepted isactive, one that failed isnode-errorwith the node’s HTTP status and an excerpt of its message inerror(see A node’s error inendpoints[]), ortime-outoroffline, and one you did not name isexcluded. No other part of a node’s body, and noLocation, is copied into it. - The status is
200when every member you named accepted, and207withcomplete: falsewhen some accepted and others failed: a partial success is never reported as success (§12.6). When none accepted, the status is504if a member timed out or could not be reached, and424otherwise (§11.2). openEHR-federation-endpointandopenEHR-federation-system-idlist the members that accepted, comma-separated in registry order (§7a.3, N31).
POST {base}/v1/definition/template/adl1.4
openEHR-federation-endpoint: *
Content-Type: application/xml
<template xmlns="http://schemas.openehr.org/v1">…</template>
Demographics
The federation keeps demographics outside the CDRs: the gateway resolves the
patient through its identity binding, and never federates the openEHR
DEMOGRAPHIC API (§5.1, §7a.1, N32). Read its_rest.demographic in
OPTIONS {base}/ to see which of two behaviours a gateway runs:
unsupported: 501, the default. Every request under{base}/v1/demographic/answers501(not-implemented), and no node is asked.routed-single-node, naming one endpoint. The deployment keeps its demographics at that member, and every DEMOGRAPHIC request goes there alone, whether or not you name that endpoint inopenEHR-federation-endpoint: the operator fixed the one destination and declared it, which is the explicit choice §12.6 asks for (§7a.1, §12.6, N32). The request carries the body byte for byte, only the headers and query parameters the operation declares, and the node’s answer comes back as it sent it, withopenEHR-federation-endpointandopenEHR-federation-system-idnaming who acted (§7a.3, N31). A header naming another endpoint is a400(targeting-conflict) naming both, and several endpoints,*or an unknown id are refused as on any routed request (endpoint-several,endpoint-unknown). Nothing is fanned out or merged.
Stored queries
A deployment that sets [stored_queries] offers the federated stored-query
registry, and OPTIONS {base}/ declares it as
definition.stored_query_registry: true (§12.7, N44). The gateway then holds
each definition itself: it is authoritative for it, and a query invoked by
name runs over every member exactly as if you had sent its text to
POST {base}/v1/query/aql.
Store a query with its AQL as the text/plain body, at a version:
PUT {base}/v1/definition/query/org.example::compositions/1.0.0
Content-Type: text/plain
SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c
WHERE e/ehr_status/subject/external_ref/id/value = $patient
AND e/ehr_status/subject/external_ref/namespace = 'urn:oid:2.999.1'
- The name is
[{namespace}::]{query-name}overa-z,A-Z,0-9,_,.and-, and the query name is neveraql(ITS-REST). The version ismajor.minor.patch(ITS-REST’s semver path segment, §12.7). Anything else is a400(query-name-invalid,query-version-invalid). The body istext/plain: send thatContent-Type, withcharset=utf-8if you like, or none. Any other is a415(media-type-unsupported), and nothing is stored.query_type, when sent, isAQLin any case. - A
PUTwith no version,PUT {base}/v1/definition/query/{name}, stores the query 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 it holds, newest by number, so after1.9.0and1.10.0it assigns1.10.1. It never replaces a held version, because changing a definition means a new version (§12.7; ITS-REST “Stores a new query, or updates an existing query”).Locationnames the assigned version (the Location of a stored query). Every other rule of this list applies to it as to aPUTat a version, the literal-patient refusal first, so a refused definition takes no version. - A stored version is immutable. A second
PUTto a name and version the registry holds is a409(stored-query-held), the held text stands, and so does the refusal after the gateway restarts. Store a change as a new version (§12.7, N44). This holds for every secondPUT, the same query naming members included; a member that misses a version is sent it by the operator (repairing drift). - The gateway analyses the text as it analyses a query you send, with every
$parameterstanding in for a value you will bind. A text it would refuse whatever you bind is refused now,400with the same code a query would get (not-aql,unreducible, and so on). A refusal that depends on how you target the query, such as an aggregate across several members, is left to each invocation. - Name the patient through a
$parameter. A definition that names the patient by a literal identifier is refused400(subject-literal), because the registry would hold that identifier at rest (§5.4.1, N33). The message never quotes it, and the security log records only the position of the predicate. - The answer is
200withLocationnaming the stored version (the Location of a stored query). - The namespace
eu.ferrofed.eehrxf, and every namespace nested under it, in any letter case, holds the gateway’s own queries (the gateway’s own queries). APUTthere is a409(stored-query-reserved) at any version, and nothing is stored. - A deployment may run the registry read-only, its definitions published by
its operator. Every
PUTthere is a405(stored-query-read-only) withAllow: GET, OPTIONS, andOPTIONSon the path lists noPUT; reading and running the definitions it holds work as below. - Behind several replicas sharing one registry, a version one replica
stored is read and run at every other, and of two
PUTs of the same new version at once exactly one is stored; the other is a409(stored-query-held). TwoPUTs of one name with no version at once each store a version of their own. A replica that loses the race for the next version eight times in a row answers409(stored-query-held) and stores nothing; send thePUTagain.
The gateway holds the canonical print of the parsed query, so a comment or
any other text the parser drops is not kept, and GET returns the query in
that form.
Read it back with GET on the same path, which answers the ITS-REST
StoredQuery (name, type, version, saved, q), or 404
(stored-query-unknown). GET {base}/v1/definition/query/{pattern} lists
every version of every stored query whose name starts with the pattern.
Run it by name, binding its parameters in the ITS-REST Query body:
POST {base}/v1/query/org.example::compositions
Content-Type: application/json
{"query_parameters": {"patient": "12345"}}
- Without a version, the highest version runs.
/{version}picks one: an exactmajor.minor.patch, or a{major}or{major}.{minor}prefix that runs the highest version it matches (ITS-REST). A name or version the registry does not hold is a404(stored-query-unknown). - Every rule of an inline query applies unchanged:
offsetandfetch, the completeness and dedup headers,Prefer: wait, and the targeting headers. A definition that carriesFROM ENDPOINTorORGANISATIONis targeted by it, and a header that selects other endpoints is a400(targeting-conflict). - A parameter you do not bind, or bind and the query does not use, is a
400(parameters) naming it, never its value. - The
Querybody is JSON, under the sameContent-Typerule as an inline query:application/jsonor none, and any other is a415(media-type-unsupported) that asks no node. - The answer is the ordinary federated
RESULT_SET, withnamenaming the gateway’s stored query andqits stored text (§9.1, §12.7). No node receives your patient identifier, and no node receives the stored query by name: each gets the standard AQL of an inline query. GET {base}/v1/query/{name}[/{version}]runs it the same way with the members in the query string, as the GET form of an inline query does:offsetandfetchby name, and every other pair a query parameter. The storedGETforms declare noq, soq=…binds$q.ehr_idis dropped, and a query string the decoder refuses is a400(body-invalid).
Templates go to the one node you name (templates and
definitions). Without the registry, GET and
POST {base}/v1/query/{name} answer 501.
See how it works: definitions and stored queries.
The Location of a stored query
Location names the URL of the stored query,
{base}/v1/definition/query/{name}/{version} (ITS-REST Location_Query):
- With
gateway.public_urlset (the public base URL), it is that absolute URL, such ashttps://gateway.example.org/v1/definition/query/org.example::compositions/1.0.0. - Without it, the gateway does not know the scheme and host you reach it at
(§4.1), so it is a reference relative to the request URL: the version,
1.0.0, after aPUTat a version, and./{name}/{version}after aPUTwith no version. Resolved against your request URL (RFC 3986 §5.2), each is the stored query’s URL.
The gateway’s own queries
Wherever the registry is offered, it also holds the gateway’s own stored
queries, read-only, under the namespace eu.ferrofed.eehrxf at the one
version 1.0.0. ITS-REST names a stored query [{namespace}::]{query-name},
the namespace “in a form of a reverse domain name”, so the gateway’s sits
under the reverse of ferrofed.eu. They are the laboratory report’s query,
eu.ferrofed.eehrxf::laboratory-report; the discharge report’s,
eu.ferrofed.eehrxf::discharge-report; the imaging report’s,
eu.ferrofed.eehrxf::imaging-report; the medication queries,
eu.ferrofed.eehrxf::medication-orders, every composition that holds a
medication order and no feeder_audit, so an imported copy is left out,
and eu.ferrofed.eehrxf::medication-actions, every composition that holds a
medication action; and the patient summary’s section
queries, one per section openEHR content feeds:
eu.ferrofed.eehrxf::patient-summary-{section}, where {section} is
allergies-and-intolerances, problems, medication-summary,
medical-devices-and-implants, procedures, immunisations,
social-history, pregnancy-history, advance-directives,
observation-results or care-plans.
-
List them with
GET {base}/v1/definition/query/eu.ferrofed.eehrxf::and read one as any other;savedis the day its version was fixed. -
Run one by name, binding the patient and the namespace that issued the identifier:
POST {base}/v1/query/eu.ferrofed.eehrxf::patient-summary-problems Content-Type: application/json {"query_parameters": {"patient": "12345", "namespace": "urn:oid:2.999.1"}}Each row is one composition that contains one of the section’s archetypes: the composition, its uid and its template id, from every member, with each member’s status in
meta.federation. No node receives the identifier. -
No
PUTstores into the namespace, and a version there never changes (§12.7, N44). A store that holds a definition there, from a restore, a manual insert or a definition file, refuses the start, andferrofed config checkrefuses such a definition file, naming the definition and never its text.
Which archetypes select each section, and the sections no query feeds, are listed in the clinical safety risk file.
Distributing a stored query
A deployment that sets federation.fan_out_stored_queries beside the
registry also distributes a definition to the members you name, and
OPTIONS {base}/ declares definition.stored_query_fan_out: true (§12.7,
N44). It is off by default, and it is never declared without the registry.
Where the registry is offered without it, a stored-query PUT or a GET of
a version that carries openEHR-federation-endpoint or
openEHR-federation-organisation is a 400
(stored-query-fan-out-unsupported): nothing is stored or read, so a
request for distribution is never answered as a plain one. Where it is
offered:
- Ask for it on the
PUT:openEHR-federation-endpoint: *names every active member, and a header that selects endpoints names those. APUTthat names none is stored at the registry alone, as above. A list naming a suspended endpoint, or*with no active member, is a404(no-destination) and nothing is stored. - The registry stores the definition first, under every rule above. Each
named member is then sent the registry’s copy, its canonical AQL, with
ITS-REST
PUT /definition/query/{name}/{version}andquery_type=AQL, on its own. APUTwith no version sends the version the registry assigned. No header of yours is sent. A member that fails never removes the registry’s definition, and a member that accepted is never sent a rollback. - The answer is the registry’s
StoredQuery(name,type,version,saved,q) withmeta.federationbeside it, oneendpoints[]entry per registry member in the shape of a federated result set’s (§9.5), andLocationnaming the stored version, andmeta.registry: "stored"says the request stored it. The statuses are those of the template fan-out:200when every member you named accepted,207withcomplete: falsewhen some did, and504or424when none did. Whatever the status, the registry holds the definition. A member that failed carries its HTTP status and an excerpt of its message inerror, as in a query’s answer (A node’s error inendpoints[]); no other part of a node’s body is copied into the answer. - A definition whose AQL carries a
FROM ENDPOINTorORGANISATIONdirective is refused400(definition-endpoint-targeted) and nothing is stored: a node cannot run a directive that names members of the federation (§12.7, §8.1). Store it without naming members and it runs federated, targeted by its directive. - An invocation always runs the registry’s copy, inline, whatever a member holds under the same name (§12.7).
PUT {base}/v1/definition/query/org.example::compositions/1.0.0
openEHR-federation-endpoint: *
Content-Type: text/plain
SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c
WHERE e/ehr_status/subject/external_ref/id/value = $patient
AND e/ehr_status/subject/external_ref/namespace = 'urn:oid:2.999.1'
A member’s copy can drift from the registry’s: a failed distribution, a
local PUT at the node, a restore, or a member admitted later (§12.7). To
check, GET the version naming members in the same headers:
GET {base}/v1/definition/query/org.example::compositions/1.0.0
openEHR-federation-endpoint: *
- Each named member is asked for its copy of that version with ITS-REST
GET /definition/query/{name}/{version}. A{major}or{major}.{minor}prefix selects the registry’s version first, and the members are asked for that one. - The answer is the registry’s
StoredQuerywithmeta.federation. A member whose copy is the same query isactive; layout and comments do not count, because both sides are compared as their canonical prints. A member whose copy differs isnode-errorwitherror.code: "definition-differs", and one that holds none isnode-errorwitherror.code: "definition-missing". A member that fails or does not answer is reported as in the distribution. No member’s copy is copied into the answer. - The status is
200when every member you named matches, and207withcomplete: falseotherwise.openEHR-federation-endpointandopenEHR-federation-system-idlist the matching members. - Without a header the
GETanswers from the registry alone, as above.
Repairing drift
A member that missed a distribution, or was admitted after it, does not get
the version through the ITS-REST surface: every second PUT of a held
version is a 409 (§12.7). The specification gives drift repair no
request, so the gateway offers it to its operator alone, on the
admin listener beside the metrics. Where
[metrics] listen is unset, the action does not exist. It answers a
caller whose access token carries auth.product_scope, from
any address; without a token it is 401 (unauthenticated), and without
the scope 403 (scope-insufficient)
(Who the admin listener serves).
Run the drift check above to find the members, then name them in the same
targeting headers:
POST /admin/stored-queries/org.example::compositions/1.0.0/distribute
Authorization: Bearer <an access token with the operator scope>
openEHR-federation-endpoint: node-c-pub
- The request carries no body. Each named member is sent the registry’s
held copy of that exact version, as in a first distribution, and the
registry’s copy, its
savedtime included, does not change. - The answer has the shape and the statuses of a first distribution (
200,207withcomplete: false,504or424), andmeta.registry: "held"says the registry held the version and stored nothing. - With
gateway.public_urlset,Locationnames the stored version’s URL under it. Without it, the answer carries noLocation: the admin listener is another origin, and no reference from it reaches the client listener. - It is refused before any node is asked:
404(stored-query-unknown) for a version the registry does not hold;400for a request naming no member (target-required), a body (body-invalid), a deployment withoutfederation.fan_out_stored_queries(stored-query-fan-out-unsupported), or a definition carrying aFROM ENDPOINTorORGANISATIONdirective (definition-endpoint-targeted). - A read-only registry answers it
405(stored-query-read-only) with an emptyAllow: its operator publishes the definitions, and the gateway distributes none of them (RFC 9110 §10.2.1).
The patient summary over FHIR
Regulation (EU) 2025/327 Annex II 2.1 asks an EHR system for “an interface
enabling access to the personal electronic health data processed by it in
the European electronic health record exchange format”. FerroFED serves that
interface as a FHIR R4 face on a base of its own, {fhir-base}, beside the
ITS-REST face at {base}. No ITS-REST path changes: {base} stays a
conformant ITS-REST surface (Federation Tier §4.1, N1, N28).
The face answers six requests today:
| Request | Answer |
|---|---|
GET {fhir-base}/metadata | the face’s CapabilityStatement |
GET {fhir-base}/Patient?identifier=system|value | the patient lookup: the Patient the identity binding holds, when a member holds the patient (Looking a patient up) |
GET or POST {fhir-base}/Patient/$summary | the patient summary, an HL7 Europe Patient Summary document Bundle |
POST {fhir-base}/Bundle | a document received in the exchange format, written to the member its category is declared for |
GET {fhir-base}/DocumentReference | the document list: the summary the gateway writes on request and each member’s own one (The document list) |
GET {fhir-base}/Binary/{token} | a member’s own document, at the URL the document list names |
The gate refuses every other path under {fhir-base}, and every other
method, with a 403: a variant of a path, in another letter case, with a
percent-encoded character or a trailing or doubled slash, is refused, never
served.
Looking a patient up
Xt-EHR D5.1 §3.4.6 api-producer-patient asks every producer EHR system for
“a patient lookup API sufficient for a consumer to unambiguously identify
the patient EHR in the producer’s system”. The face answers it with the
FHIR R4 Patient search by one parameter, identifier, the patient’s
identifier with its system:
GET {fhir-base}/Patient?identifier=urn:oid:2.999.1|SYNTHETIC-1
The gateway asks the PDQm Supplier of [pdqm] for the patient first, as
for a summary header, then localizes and resolves the patient as it does
any subject, through the configured identity binding (Federation Tier
§5.2). It asks no member anything, so no patient identifier reaches a node
(§5.4.1, N33). When the patient resolved at a member, the answer is a
searchset with one entry: a Patient that carries the identifier it was
looked up by and the names, birth date, gender, addresses and telecoms the
Supplier holds, each as the Supplier gives it, under a urn:uuid: full URL
minted for the answer. A patient with no name is still answered; only a
summary requires one. Use the identifier, not the minted id, in the requests
that follow: the face keeps no Patient resource to read by id.
An identifier the Supplier does not know, a patient no member holds and a
patient every member restricts answer alike, with the empty searchset, so
the answer never shows a restriction (Regulation (EU) 2025/327 Art 8;
§11.3). Under all-or-nothing, a member the resolver could not answer for
fails the lookup with 424; under partial it is named in an outcome
entry beside the Patient.
A search by demographics, by _id, with a modifier, with a second
parameter beside identifier, or with no identifier is refused with 400
and an OperationOutcome that names identifier=system|value as the
parameter the face takes. FerroFED’s input is an identifier, never
demographics (§5.4.3, N32); D5.1 admits “other forms of patient identity”.
metadata declares the lookup: the Patient resource with the
search-type interaction and the one search parameter identifier,
defined by http://hl7.org/fhir/SearchParameter/Patient-identifier, beside
the $summary operation.
Asking for a summary
The request is the International Patient Summary 2.0.0 $summary operation
on Patient, at the type level. Name the patient by an identifier with its
system:
GET {fhir-base}/Patient/$summary?identifier=urn:oid:2.999.1|SYNTHETIC-1
Authorization: Bearer <token>
or send the same parameters as a FHIR Parameters body, with
Content-Type: application/fhir+json:
POST {fhir-base}/Patient/$summary
Content-Type: application/fhir+json
{"resourceType":"Parameters","parameter":[
{"name":"identifier","valueString":"urn:oid:2.999.1|SYNTHETIC-1"}]}
identifieris required, once, assystem|value. The system is the namespace the gateway resolves the patient in (§5.2), as it is for an AQL query’sexternal_ref/namespace.profile, when sent, ishttp://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps, with or without|1.0.0-ballot._format, when sent, isjson,application/jsonorapplication/fhir+json.- Any other parameter is a
400. The face never searches by demographics: a national connector identifies the patient against its own identity services first and asks by the confirmed identifier. - A request by the patient’s logical id,
Patient/{id}/$summary, is refused: the face holds noPatientresource.
What the gateway does
The summary is built from the gateway’s own section queries, the eleven
stored queries under eu.ferrofed.eehrxf
(the gateway’s own queries):
- The header is asked of the demographics binding, the PDQm Supplier of
[pdqm], by the identifier you sent, before any member is asked. The members federate no demographics (Federation Tier §2.3, N32), and nothing of the header reaches a node. - Every section query is read as the façade reads a stored query, the
patient bound through
$patientand$namespacealone. - The patient is localized, checked against the consent pre-filter and resolved once, at every member (§5.2, §14). Each section query is then planned over that one resolution, so the identity services are asked once per summary.
- Every section query goes to every member that holds the patient, scoped
to that member’s own
ehr_id, through the same rewrite and outbound identifier-hygiene gate as any query. No patient identifier reaches a node (§5.4.1, N33). The queries run together under one budget (§11.5). - Each composition a section query selected is mapped by every FHIRconnect mapping the deployment supplies for that section and the composition’s template. A composition no mapping covers is counted in the section’s narrative, never dropped in silence.
- The document is written: the composition, the patient, the gateway’s
Deviceand the operator’sOrganizationas its authors, each member whose data a section holds as an author of that section, oneProvenanceper mapped composition naming the composition and the member, and every mapped resource.
The section queries run from the code that defines them, so the face works
whether or not the deployment sets [stored_queries]. The text is the one
the registry lists at version 1.0.0.
A member that serves its own summary
A member the registry declares in the native mode for patient summaries
(a member’s own exchange-format endpoint)
is resolved with every other member, then left out of the section queries.
The gateway asks its own endpoint instead, at the same time and under the
same budget:
GET {member-fhir-base}/Patient/$summary?identifier=<ehr_id_system>|<ehr_id>&profile=http://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps
Accept: application/fhir+json
The request names the member’s own ehr_id and nothing else about the
patient (§5.4.1, N33). It passes the outbound identifier-hygiene gate, and
carries the onward credential and the signed caller identity of the
member’s openehr-rest-query endpoint. The member’s document comes back as
it sent it, byte for byte. The gateway’s own document maps none of that
member’s data, and every section names it: “Not mapped here: the records of
the member, which serves its own patient summary.” A section with no entry
is then unavailable, never nilknown, because the gateway did not read
that member’s data. The document list names the member’s own document,
which a client reads through the gateway
(The document list).
A member whose own endpoint answers 404 holds no summary of the patient,
and the sections say so. Any other answer, or none, counts as a member that
did not answer, as below.
The document
The answer is a Bundle of type document, application/fhir+json, that
claims the bundle-eu-eps profile. Its first entry is the Composition, and
every entry is named under the absolute URL of {fhir-base}.
- The five sections the EPS composition requires are always present: problems, allergies and intolerances, medication summary, procedures, and medical devices. The other six sections the section queries feed follow.
- The medical alert and the functional status, which no openEHR content is
named to feed, are always present too, each with the empty reason
unavailableand a narrative saying the gateway does not supply it, so no reader takes their absence for “none”. - Every section has a generated narrative that says it is not exhaustive, names every member that gave no answer, counts what no mapping covers, and names, by path and member, every element a mapping could not carry into the section.
- When two members record a medicine with the same code, strength and route, the medication summary lists both entries and its narrative marks them as a probable duplicate, naming both members; nothing is merged.
- A composition goes to every section whose query selects it, and each mapping places it by its own explicit criterion: a medication use is a medicine or a vaccination by what its mapping reads, never by inference.
- The medication entries are selected by the template section that holds
them, as the interoperability component places them
(
eehrxfcrosswalk::patient_summary::SOURCES): the medication summary takes a medication order under an ad hoc section (openEHR-EHR-SECTION.adhoc.v1), and the immunisations take a medication action under the vaccination list section (openEHR-EHR-SECTION.immunisation_list.v0), never by the archetype alone, so a vaccine recorded as a medication action never reaches the medication summary. FerroBRIDGE’s patient summary template, vendored underdocs/specs/ferrobridge-medication/, records them so, and a[[fhir.mapping]]may name it as an ADL 2templatewith thearchetypesit specialises (app/ferrofed-eehrxf/tests/it/summary/placement.rs:one_composition_holding_a_medicine_and_a_vaccine_lists_each_in_its_own_section_only). - A section with no entry carries the empty reason
nilknownonly when every member answered and none holds content for it, andunavailableotherwise. - Each member’s data are listed as mapped, beside every other member’s,
never merged with them. A composition two members hold under one full
version id, an imported copy (Federation Tier §10.3), is listed once: the
copy at the member whose
system_idis the version’screating_system_id, else the copy at the lowest endpoint id, and the section’s narrative names the members whose copy it left out. The MyHealth@EU requirements forbid information present identically more than once in an exchanged document (Requirements Catalogue v10.0.0, 10.03). - The
Patientcarries the identifier you asked by, and the header the demographics binding holds (eHN PS A.1.1, A.1.2): the names, the birth date, the administrative gender, the addresses and the phone numbers and email addresses, each as the PDQm Supplier gives it. An element the Supplier does not hold is left out, never filled in; the birth date, which the EPSPatientrequires, then carries adata-absent-reasonofunknown. The country of affiliation, the preferred professional, the contact person and the insurance (A.1.1.6, A.1.2.2, A.1.2.3, A.1.3) are not in the header.
The tests hold every document the face writes to a structural check against
the vendored bundle-eu-eps, composition-eu-eps and patient-eu-eps
profiles, and CI runs HL7’s FHIR Validator over every document those tests
write and fails on an error
(#688).
The document list
A DocumentReference search names every patient summary that exists for a
patient: the one the gateway writes on request, and the one each member in
the native mode serves itself. It is the search of the HL7 Europe EU
Health Data API Document Access Provider, with its patient.identifier and
type parameters:
GET {fhir-base}/DocumentReference?patient.identifier=urn:oid:2.999.1|SYNTHETIC-1&type=http://loinc.org|60591-5
Authorization: Bearer <token>
patient.identifieris required, once, assystem|value, one patient alone. The patient is resolved as for a summary (§5.2).type, when sent, is a token:60591-5,http://loinc.org|60591-5orhttp://loinc.org|. Atypethat admits no document type the list serves (the patient summary, and the laboratory, discharge and imaging documents when the list names them) matches nothing: the answer is an emptysearchset, and nothing is resolved or sent._format, when sent, isjson,application/jsonorapplication/fhir+json. Any other parameter or modifier is a400.
The answer is a Bundle of type searchset, each entry a DocumentReference
that claims the EehrxfMhdDocumentReference profile, typed LOINC 60591-5
in the Patient-Summaries category, its subject the identifier you sent:
- The summary the gateway writes on request, its custodian the operator of
[fhir.operator]. The gateway does not write it to list it: its attachment points at the request that writes it, relative to{fhir-base},Patient/$summary?identifier=<system>|<value>. - One entry per member in the
nativemode whose own endpoint answered with a document, its custodian the member’s organisation. Itsattachment.urlisBinary/{token}, relative to{fhir-base}, where the gateway serves the document (Reading a member’s document), never a URL of the member’s.contentTypeis the media type the member answered with (application/octet-streamwhen it named none),hashthe base64 of the SHA-1 digest of the bytes the list fetched, andsizetheir number. ItsmasterIdentifieris the document’s ownBundle.identifier.
To list, the gateway asks each native member’s own endpoint, by the
member’s own ehr_id alone, as for a summary
(A member that serves its own summary),
under the same budget, onward credential and signed caller identity. It runs
no section query and maps nothing. It does not ask the demographics binding
for the summary header either: the summary written on request is listed
whenever the patient resolves at a member, and Patient/$summary can still
refuse it later when the header cannot be written. A member whose own endpoint answers
404 holds no summary and has no entry. A patient no member holds matches
nothing, an empty searchset, as does a patient whose members may not
disclose a restriction.
A native member that does not answer fails the list under
all-or-nothing, 424 or 504, with an OperationOutcome naming it, as for
a summary. Under partial, the list answers with the entries it has, and
an OperationOutcome entry with search.mode outcome names each member
that did not answer and its §11.1 status, so the list is never presented as
whole.
A member the identifier cross-reference could not answer for counts as a
member that did not answer, named not-resolved. When that leaves no
member the patient resolved at, the list fails with 424 under
all-or-nothing, and under partial it holds an outcome entry for each
member and no summary written on request. It is never the empty
searchset of a patient no member holds.
Laboratory reports in the list
The list also names the patient’s laboratory reports, the ones the gateway writes from the members’ compositions and the ones a member serves itself (Laboratory reports over FHIR), and the discharge reports and imaging documents a member serves itself (Discharge reports over FHIR, Imaging reports over FHIR).
metadata declares the search: the DocumentReference resource with the
search-type interaction, its supported profile and the two parameters,
and the Binary read.
Reading a member’s document
GET {fhir-base}/Binary/{token} answers a member’s document as the member
sends it, with the member’s media type. The token is a compact JWS the
gateway signs with its [signing] key, of its own JOSE type, so the gateway
takes no other token it signs for one. It is valid for
fhir.document_url_lifetime, 300 seconds unless set, names the
gateway’s FHIR base as its audience, and carries the member’s endpoints,
the member’s own ehr_id, the category and profile, and the caller it was
listed for. It carries no patient identifier (§5.4.1, N33).
The read takes the scopes of a summary. The gateway verifies the token and
holds the caller to the one it names; an expired, altered or foreign token,
or one listed for another caller, is a 404 that names nothing. It then
asks the member again, by its own ehr_id alone, as the list did, and
stores nothing. A member that does not answer is a 424 or 504 naming
it, as for a summary, and one that answers 404 holds the document no
longer. A member that writes its summary anew on every request answers
bytes whose hash differs from the one the list gave. Every read that
reached a member writes one access record, naming the member’s endpoint and
the ehr_id, and the patient behind it as the record gate finds them.
Completeness
A summary is all-or-nothing by default, as a federated query is (§11.3,
§11.4). A member that does not answer one of the section queries, or a
member in the native mode whose own endpoint does not answer, fails the
summary: the answer is the status the query would take, 424 or 504, with
an OperationOutcome that names each such endpoint and its §11.1 status.
Where the deployment sets federation.best_effort = true, send
openEHR-federation-completeness: partial to take the summary of the members
that answered. Every section then names the members that did not, and none
of them is nilknown.
A patient no member holds is a 404, and so is a patient whose members may
not disclose a restriction, or one the demographics binding does not know:
they answer alike. A patient the identifier cross-reference could not
answer for at any member is not one of them: the summary fails with 424,
naming each member not-resolved, and under partial every section names
each member and none of them is nilknown.
The header is asked before any member, and a summary whose header cannot be written asks no member:
| The demographics binding | The answer |
|---|---|
| knows no patient for the identifier | 404 not-found |
| matches several patients | 422 multiple-matches, none of them picked |
| holds the patient with no family name, given name or text | 422 required: the EPS Patient requires a name (ips-pat-1) |
| is not asked about identifiers of the system you sent | 422 not-supported |
| does not answer within its budget | 504 timeout |
| fails, or its exchange cannot be audited | 502 exception |
Authentication and the access log
Every request to the face passes the same client authentication as the
ITS-REST face (§13.1, N25). metadata takes a verified caller. A summary
runs the eleven section queries, so it takes the SMART on openEHR aql-
search permission on each of them: user/aql-*.s, or a pattern that covers
the reserved namespace, such as user/aql-eu.ferrofed.eehrxf::*.s. No
specification defines the scopes of this face: this is our own design until
one does. As for any query that reaches patient data, the caller must state
a purpose of use, a client must act for a professional it names at the
assurance level the issuer requires, and a national contact point’s
connector must relay the attributes its issuer declares. The handler holds
the caller to the scopes the gate found covering the summary once more
before any member is asked.
The document list takes the same scopes as a summary: it names the summary and carries the members’ own documents.
The patient lookup finds whether members hold the patient’s EHRs, as the
ITS-REST read GET {base}/ehr?subject_id= does, so it takes what that read
takes: the composition- read of every template, user/composition-*.r.
A token confined to one patient looks up that patient alone; another
patient’s identifier is 403 (patient-confinement) before the PDQm
Supplier is asked.
A person reads their own summary through an electronic health data access
service (Regulation (EU) 2025/327 Art 3(1), Art 4). The service holds a
token confined to the person
(Patient grants), with the
scope patient/aql-eu.ferrofed.eehrxf::*.s, and asks for the summary by the
person’s identifier as any caller does. The gateway resolves that
identifier at the member the token’s issuer is bound to and compares it
with the token’s patient before the PDQm Supplier or any member is asked.
The person’s own identifier gets the summary from every member that holds
them; another patient’s is 403 (patient-confinement), an
OperationOutcome of type forbidden, with nothing sent. A professional’s
request is unchanged.
Every summary that reached a member writes one access record (Annex II 3.2),
naming the verified caller, the patient, every endpoint asked and the
ehr_id at each, and the categories: Patient-Summaries by construction
(Art 14(1)(a)), beside every category the deployment’s map gives the
compositions the members answered with. A document list that asked a
member’s own endpoint writes one record too, naming every such endpoint
and the ehr_id it was asked by, and the members whose document it
carries. A list that lists the gateway’s summary tells you that members
hold the patient’s record, which is health data in itself (GDPR recital 35),
so it writes one record even when it asked no member, because none is in the
native mode: the record names the members the patient resolved at, each at
the endpoint it is asked through and by its ehr_id, as origins that
contributed nothing (fhir/list.rs:
a_list_that_asked_no_member_is_recorded_once_naming_where_the_patient_resolved).
A list of a patient no member holds lists nothing and writes no record.
A lookup that finds the patient writes one record on the same ground, naming
the members the patient resolved at, each by its ehr_id, with the
category no-category and the resource kind EHR, since it reached no
composition; a lookup that finds no patient writes none
(fhir/lookup.rs). The record of a person’s
own summary names the person twice under Art 9: as the caller, the token’s
sub acting in person with the purpose it states, and as the data subject,
by the identifier the service asked with and the ehr_id at each member.
Errors
Every error under {fhir-base} is a FHIR OperationOutcome, served as
application/fhir+json. A refusal of the gateway’s own, such as a
client-authentication refusal, keeps its status and its headers, and its
code and message become the issue’s diagnostics:
| Status | Issue type |
|---|---|
400 | invalid |
401 | login |
403 | forbidden |
404 | not-found |
405, 406, 415, 501 | not-supported |
408, 504 | timeout |
424 | incomplete |
429 | throttled |
503 | transient |
| any other | exception |
Receiving a document
Annex II 2.2 and 2.3 ask an EHR system to be able to receive personal
electronic health data in the exchange format. Send a document Bundle to
the face’s Bundle end-point (FHIR R4 documents §3.3.4):
POST {fhir-base}/Bundle
Content-Type: application/fhir+json
Authorization: Bearer <token>
{"resourceType":"Bundle","type":"document", ...}
The gateway stores nothing itself. It writes the document as one openEHR composition to the one member the deployment declares for the document’s category (Federation Tier §2.3, N23):
- The document is read under the FHIR R4 document rules, and its category
is the one whose HL7 Europe document profile fixes a coding of its
Composition.type: LOINC60591-5for a patient summary. A category no member receives is a422that names it. - It is validated in full by FerroBRIDGE’s validator against the
category’s
Bundleprofile, over the package and the dependency closure the deployment supplies: every constraint of that profile, of the profile each entry’s slice names and of each profile an entry claims, invariants and terminology bindings included (Receiving a document). A document with an error is a422with one issue per error, located by its FHIRPath. A warning, and each constraint the validator cannot evaluate, is listed as awarningin the answer, never passed silently. - Every coding that carries no
displayis given one by the terminology server’s$lookup, because the openEHR side needs the text of every coded term. With no terminology server configured, such a coding is a422naming where it is. - It is mapped into one composition by the category’s FHIRconnect mapping.
The composition keeps the whole document, as it was received, in its
FEEDER_AUDIT.original_content. A document names no openEHRCOMPOSITION.category, so the composition takes the one the category’s template decides: its assumed value, or its one admitted code, orevent(openehr433) when the template leaves the category open. A template that admits several category codes and assumes none refuses the mapping, naming<root>/category|code; give the template’scategoryan assumed value (The template’s category). - The patient is resolved from every identifier of the document’s
Patientin a namespacefhir.receive_namespacesnames, at the receiving member alone (§5.2). Every one must name the sameehr_idthere. A patient the member holds no EHR for, or identifiers that name two patients, are a422, and nothing is sent to any member. - The composition is committed with ITS-REST
composition_createto the member, located by its ownehr_id. No patient identifier is in the path, the query string or the headers (§5.4.1, N33). The body carries the received document as the clinical content you sent.
A written document answers 200 with an OperationOutcome whose first issue
names the category, the member, the endpoint and the new version’s uid. A
member that refuses the composition is a 502 that names its status, one
that does not answer in time is a 504, and identity services that cannot
answer give a 424. A terminology server that refuses or does not answer
is a 502 that names its status. None of them is ever a success.
Receiving takes the SMART on openEHR create permission on compositions of
every template, user/composition-*.c, because the template follows from
the document. A read scope is refused. A patient/ grant, where the issuer
is bound to a member, writes only into its own patient’s EHR. Every receipt
that reached the member writes one access record, an action C with the
document’s category by construction. A receipt whose record has no place in
the spool is refused 503 before the member is asked, so sending it again
writes it once (Failing closed).
The capability statement, GET {fhir-base}/metadata, declares the
create on Bundle only when a member receives a category, as it lists a
prescription resource only when a member serves it. Its documentation then
names the categories the deployment receives and those it has no receiving
member for, by their HL7 Europe codes, and its supported profiles are the Bundle profiles of the
categories received. ferrofed config check prints the same categories,
each received one with its member
(Running it).
The template’s category
COMPOSITION.category is mandatory in the openEHR RM, and a received
document never names one. The mapping takes it from the template’s
constraint on /category. When that constraint admits more than one code,
persistent (431) and event (433) for example, the template must say
which one a mapped composition carries, with an assumed value on the
C_CODE_PHRASE (AOM 1.4 assumed_value):
<children xsi:type="C_CODE_PHRASE">
<rm_type_name>CODE_PHRASE</rm_type_name>
<!-- occurrences and node_id as the template has them -->
<assumed_value>
<terminology_id><value>openehr</value></terminology_id>
<code_string>433</code_string>
</assumed_value>
<terminology_id><value>openehr</value></terminology_id>
<code_list>431</code_list>
<code_list>433</code_list>
</children>
Without one, every document of that category is refused: the mapping fails
with MissingRequiredSuffix on <root>/category|code, and the document is
a 422 with the issue code processing, with nothing sent to any member. A template that admits
one code, or leaves the category open, needs no change.
Prescriptions and dispensations
The face also serves electronic prescriptions and dispensations, Art 14(1)(b) and (c), resource by resource: the search and the read of a patient’s prescriptions, and the receipt and the read of a dispensation, each at the member that holds it (Prescriptions and dispensations over FHIR).
Terminology
A code passes as it was recorded, and a coding is added beside it only where it means the same thing; Terminology on the FHIR face gives the rules, the terminology server and the required bindings.
Configuring the face
[gateway]
public_url = "https://gateway.example.org" # the face names its entries under it
[fhir]
base = "/fhir" # {fhir-base}, never {base} or a path under {base}/v1
[fhir.operator]
name = "Example Health Network" # the operator, an author of every summary
identifier_system = "urn:oid:2.999.9" # optional, with identifier_value
identifier_value = "operator-1"
[[fhir.mapping]]
section = "allergies-and-intolerances" # the slug of a section query
template = "/etc/ferrofed/mappings/allergy.opt"
files = [
"/etc/ferrofed/mappings/allergy.yml",
"/etc/ferrofed/mappings/allergy.context.yml",
]
context = "example_allergy.context" # the context mapping's metadata.name
[[fhir.receive]]
category = "Patient-Summaries" # the Art 14(1) category, by its HL7 Europe code
member = "node-a" # the node id of the member that stores it
package = "/etc/ferrofed/packages/hl7.fhir.eu.eps-1.0.0-ballot.tgz"
dependencies = [ # the package's dependency closure, each a .tgz or a directory
"/etc/ferrofed/packages/hl7.fhir.r4.core-4.0.1.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.base-2.0.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.base-2.0.1.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.extensions.r4-1.3.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.extensions.r4-1.3.1.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.ips-1.1.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.ips-2.0.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.ipa-1.1.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.smart-app-launch-2.0.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.extensions.r4-5.2.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.extensions.r4-5.3.0.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.xver-r5.r4-0.1.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-5.0.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-6.2.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-6.3.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-6.5.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.1.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.3.0.tgz",
"/etc/ferrofed/packages/fhir.dicom-2022.4.20221006.tgz",
"/etc/ferrofed/packages/ihe.pharm.mpd.r4-1.0.0-comment-2.tgz",
"/etc/ferrofed/packages/myhealth.eu.fhir.mvc-package-9.1.0.tgz",
]
template = "/etc/ferrofed/mappings/received-summary.opt" # an OPT 1.4, or an ADL 2 template with archetypes
files = [
"/etc/ferrofed/mappings/received-summary.yml",
"/etc/ferrofed/mappings/received-summary.context.yml",
]
context = "example_received_summary.context"
language = "en" # the composition's language, ISO 639-1
territory = "NL" # the composition's territory, ISO 3166-1
[[fhir.serve]]
category = "Electronic-Prescriptions" # Electronic-Prescriptions or Electronic-Dispensations
members = ["node-a"] # node ids, each with its own MPD endpoint
[[fhir.serve]]
category = "Electronic-Dispensations"
members = ["node-a"] # each also declared for Electronic-Prescriptions
The terminology server, when you configure one:
[eehrxf.terminology.endpoint]
url = "https://terminology.example.org/fhir/" # the server's FHIR base
timeout = "2s" # one operation's budget; 2s unless set
[eehrxf.terminology.endpoint.credentials]
user = "ferrofed"
password_file = "/run/secrets/terminology-password"
[eehrxf.terminology.endpoint] is ferrohealth-smart’s outbound table.
Its tls sub-table gives the mutual TLS the server asks for:
client_identity or client_identity_file, trust_roots_file and
min_version. Its credentials take HTTP Basic or a client credentials
grant under oauth, and its retry table the tries of one call, once
unless retry.max_attempts says more, since the face fails closed on the
first refusal. Outside
profile = "development" the URL must be https, because the server is sent
the codes of the documents you receive.
For [[fhir.receive]] and for Electronic-Dispensations, set
receive_namespaces under [fhir] as well, for example
receive_namespaces = ["urn:oid:2.999.1"]: the identifier systems a
received document’s or dispensation’s patient is resolved in. Configuration
load refuses either without it.
[fhir]needs a demographics binding,[pdqm], because every summary names its patient and the members federate no demographics: configuration load refuses the face without one. The Supplier is asked by the identifier in the system[pdqm.namespaces]maps its namespace to, in the master domain, or in the system you sent when that is an absolute URI.[fhir]needs a registry andgateway.public_url, and refuses a base on a path the gateway serves under{base}, or one that overlaps the PMIR feed route (pmir.path).- Each
[[fhir.mapping]]is one FHIRconnect 1.0.0 context mapping, compiled when the configuration is read. It must map to a profile the crosswalk admits for an entry of its section, for exampleallergyIntolerance-eu-corefor the allergies. A mapping that does not compile, or maps elsewhere, refuses the start andferrofed config check. Itstemplateis an OPT 1.4.opt, or an ADL 2 template besidearchetypes, the ADL files it specialises, whose operational template the gateway builds at start. - A section a template feeds through several profiles takes one entry per profile.
- The section slugs are those of the section queries:
allergies-and-intolerances,problems,medication-summary,medical-devices-and-implants,procedures,immunisations,social-history,pregnancy-history,advance-directives,observation-resultsandcare-plans. - Each
[[fhir.receive]]declares one category, once. Its member must be a node of the registry and a resolver must be configured, or the start andferrofed config checkare refused. The package is the HL7 Europe package whose profiles the category’s documents are held to, anddependencieslists every package its manifest and its dependencies’ manifests name, at the exact versions they name, with the MyHealth@EU value sets its profiles bind: the start andferrofed config checkrefuse a set that lacks one, naming it, and one that does not carry the category’s profiles. Each is the.tgzthe FHIR package registry serves (https://packages.fhir.org/<name>/<version>) or an unpacked directory; the list above is the closure ofhl7.fhir.eu.eps1.0.0-ballot, whichscripts/vendor/eehrxf.shandscripts/vendor/fhir-packages.shfetch, each pinned by sha256.templateis an OPT 1.4.opt, or an ADL 2 template besidearchetypes, every archetype it specialises or fills a slot with. The mapping compiles when the configuration is read. - Each
[[fhir.serve]]declares one category, once, with at least one member, each named once. Configuration load refuses any other category and a member ofElectronic-Dispensationsthat is not also declared forElectronic-Prescriptions, because a dispensation is written only at the member that owns its prescription. The start andferrofed config checkare refused for a member that is no node of the registry, one with nohl7-fhir-restendpoint of its own whosecategoriesname the category, and a face with no resolver. With no entry, both categories are answered as not served (Prescriptions and dispensations over FHIR). [eehrxf.terminology]needs[fhir]: configuration load refuses it alone, and refuses a URL that carries a user name or a password.- A change to
[fhir]or to[eehrxf.terminology]takes a restart.
Laboratory reports over FHIR
Medical test results are a priority category of Regulation (EU) 2025/327
(Art 14(1)(e)). The document list
names a patient’s laboratory reports beside the patient summary, each at a
Binary/{token} URL the gateway signs for you
(Reading a member’s document).
Laboratory reports in the list
When a [[fhir.receive]] entry receives Laboratory-Reports
(A laboratory report),
or a member serves the category at its own endpoint
(A member’s own laboratory reports),
the list also names each member’s laboratory reports. The gateway compiles
the laboratory mapping eehrxf ships against that entry’s template at
start, and config check refuses a template it does not compile against.
A type of 11502-2, http://loinc.org|11502-2 or http://loinc.org|
admits them, and a type that admits only them lists no summary.
The gateway runs the reserved stored query
eu.ferrofed.eehrxf::laboratory-report on every member the patient
resolves at, by each member’s own ehr_id alone, and lists one entry per
laboratory composition: typed LOINC 11502-2 in the Laboratory-Reports
category, its custodian the member’s organisation, its masterIdentifier
urn:uuid: and the UUID of the composition’s versioned object, its
format the Bundle-eu-lab profile, and its attachment.url a
Binary/{token} the gateway signs for you, naming the member, its ehr_id
and the composition. A composition two members hold under one version id
is listed once, from the member whose system_id created it. A member
that does not answer the query fails the list under all-or-nothing, 424
or 504 naming it, and is named in an outcome entry under partial
(fhir/receive/laboratory.rs:
a_members_laboratory_composition_is_listed_and_read_as_a_laboratory_report,
a_member_silent_at_the_laboratory_query_fails_the_list_and_is_named).
Reading that URL asks the member alone for the composition
(GET {base}/ehr/{ehr_id}/composition/{uid}) and writes it as an HL7
Europe Laboratory Report 2.0.0 Bundle-eu-lab document: the gateway’s
Device and the operator author it, the member’s organisation is its
custodian, its
DiagnosticReport-eu-lab and results are the mapping’s, its results are
listed in a Laboratory studies section, one Provenance names the
member, and its Patient is the identifier the identity binding names
behind the ehr_id in fhir.receive_namespaces (424 when it names
none). The Patient carries the header the demographics binding holds, as
the patient summary’s does: its names, birth date, gender, addresses and
telecoms. A name or a birth date the binding does not hold is written with
a data-absent-reason of unknown, which HL7 Europe Base
patient-eu-core admits in place of each (eu-pat-1); a binding that
cannot answer answers 502 or 504, and one whose answer names no one
patient 422, with no report written. The report is written on each read
and kept nowhere. Each list and read is recorded
under Laboratory-Reports in the access log.
The component work behind these reports is FerroBRIDGE’s, through the
published eehrxf crate: the
laboratory report crosswalk
to the HL7 Europe Laboratory Report profiles, the
mapping out of openEHR
the read writes the report through, and
receiving a laboratory report
into openEHR, which the gateway’s
receipt uses.
A member’s own laboratory reports
A member can serve its laboratory reports itself, as an HL7 Europe EU
Health Data API Document Access Provider. Declare an hl7-fhir-rest
endpoint of the member with categories = ["Laboratory-Reports"], the
Bundle-eu-lab profile and the identifier system of its ehr_id, as for a
member that serves its own summary.
The gateway then asks that member at its own endpoint and leaves it out of
the laboratory report query, so no report is listed twice. It lists the
member’s reports with or without a [[fhir.receive]] entry for the
category.
The gateway asks the member by its own ehr_id alone, with the
interactions the Document Access Provider capability statement makes
SHALL:
GET {endpoint}/Patient?identifier={system}|{ehr_id}for the logical id of the member’sPatient. An emptysearchsetmeans the member holds no report of the patient, and nothing more is asked.GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|11502-2for its laboratory reports, eachnextpage followed under its own base.
The list names each DocumentReference the member answers as the member
holds it, with one change: each content.attachment.url becomes a
Binary/{token} the gateway signs for you, naming the member, its
ehr_id and the logical id of the member’s Binary. The member’s own URL
is one you cannot reach, and a direct link would pass the gateway’s access
check and access record by. Every other element keeps its value, numbers
keep the text the member wrote, and JSON member order is not kept, since
RFC 8259 gives it no meaning. Reading that URL asks the member alone for
GET {endpoint}/Binary/{id} and answers its bytes and media type as the
member sent them.
An attachment URL that names no Binary under the member’s endpoint, or a
Patient search that names more than one patient, makes the member one in
node-error. A member that does not answer fails the list under
all-or-nothing, 424 or 504 naming it, and is named in an outcome
entry under partial. Each list and read is recorded under
Laboratory-Reports in the access log, naming the member’s own endpoint
and its ehr_id (fhir/list/own_laboratory.rs).
Discharge reports over FHIR
Discharge reports are a priority category of Regulation (EU) 2025/327
(Art 14(1)(f)). The document list
names the discharge reports of a member that serves them itself, beside
the patient summary, each at a Binary/{token} URL the gateway signs for
you (Reading a member’s document).
The gateway also writes a discharge report from a member’s openEHR composition when you declare the template those compositions are of (Discharge reports the gateway writes). It also receives a discharge report and writes it to the member you declare for the category (Discharge reports the gateway receives).
Discharge reports in the list
The HL7 Europe EU Health Data API 1.0.0-ballot types a discharge report by
the LOINC codes of eehrxf-document-type-discharge-report-vs: 18842-5
(discharge summary) and 100719-4 (surgical oncology discharge summary).
A type of either code, alone or in the LOINC system, or
http://loinc.org|, admits them, and a type that admits only them lists
no summary. With no type, the list names every document type it serves.
Discharge reports the gateway writes
Declare the template of the members’ discharge compositions in a
[[fhir.report]] entry:
[[fhir.report]]
category = "Discharge-Reports"
template = "/etc/ferrofed/templates/ferrobridge_discharge_report.adlt"
archetypes = [
"/etc/ferrofed/archetypes/openEHR-EHR-COMPOSITION.transfer_summary.v1.adl",
"/etc/ferrofed/archetypes/openEHR-EHR-EVALUATION.problem_diagnosis.v1.adl",
"/etc/ferrofed/archetypes/openEHR-EHR-ACTION.procedure.v1.adl",
# and every archetype the template specialises or fills a slot with
]
templateis an OPT 1.4.opt, or an ADL 2 template beside thearchetypesit specialises or fills a slot with. The shipped HDR mapping ofeehrxfmaps FerroBRIDGE’s discharge report template,openEHR-EHR-COMPOSITION.ferrobridge_discharge_report.v1.0.0, a transfer of care summary whose context is the hospital stay, with a diagnostic summary, significant procedures and discharge medications section.- The configuration refuses an entry of a category the gateway writes no
report of, a category named twice, a file it cannot read, and a template
the mapping does not compile against. A
Laboratory-Reportsentry takes the place of the template of the category’s[[fhir.receive]]entry, and aMedical-Imagingentry declares the imaging reports the gateway writes. fhir.receive_namespacesnames the identifier systems the report names its patient in.
The list then runs the reserved stored query
eu.ferrofed.eehrxf::discharge-report on every member the patient resolves
at that does not serve its discharge reports itself. It selects every whole
composition of openEHR-EHR-COMPOSITION.transfer_summary.v1, by the
member’s own ehr_id alone. The list names one DocumentReference per
composition, typed 18842-5, in the category Discharge-Reports, its
format bundle-eu-hdr and its master identifier the composition’s
versioned object as a urn:uuid:. A search whose type admits only
100719-4 asks no member this query. A composition two members hold under
one version id is listed once, from the member that created it.
Reading the URL asks that member alone for the composition and writes it
as an HL7 Europe Hospital Discharge Report 1.0.0 bundle-eu-hdr in the
composition’s language: a composition-eu-hdr authored by the gateway’s
Device and the operator, its custodian the member’s organisation, about
a Patient named by the
identifier the identity binding holds behind the member’s ehr_id and
described by the demographics binding’s header as the laboratory report’s
is, with the stay as its
encounter-eu-hdr and each diagnosis, procedure and discharge medicine in
its section. A hospital course section says the member’s record holds none
the gateway writes, since composition-eu-hdr requires a discharge
summary or a hospital course. One Provenance names the member for every
mapped entry. A composition that does not map answers 424, and a member
whose binding names no patient in fhir.receive_namespaces answers 424.
Completeness and the access record follow the laboratory report’s
(Laboratory reports the gateway writes).
Discharge reports the gateway receives
Declare the member that stores received discharge reports in a
[[fhir.receive]] entry, with the HL7 Europe Hospital Discharge Report
1.0.0 package, its dependency closure, FerroBRIDGE’s ADL 2 discharge report
template with its archetypes, and the HDR project eehrxf ships:
[[fhir.receive]]
category = "Discharge-Reports"
member = "node-a"
package = "/etc/ferrofed/packages/hl7.fhir.eu.hdr-1.0.0.tgz"
dependencies = [
# the Patient Summary's closure, as in the patient summary example, and:
"/etc/ferrofed/packages/hl7.fhir.uv.ips-2.0.1.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.2.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.4.0.tgz",
"/etc/ferrofed/packages/xtehr.eu.ehds.models-1.0.0.tgz",
]
template = "/etc/ferrofed/templates/ferrobridge_discharge_report.adlt"
archetypes = [
"/etc/ferrofed/archetypes/openEHR-EHR-COMPOSITION.transfer_summary.v1.adl",
# and every archetype the template specialises or fills a slot with
]
files = [
"/etc/ferrofed/mappings/hdr/ferrobridge_eu_hdr.context.yml",
# and every other file of the HDR project, in its order
]
context = "ferrobridge_eu_hdr.context"
language = "en"
territory = "NL"
- The Xt-EHR models package
xtehr.eu.ehds.models1.0.0, which the HDR package names, declares FHIR 5.0.0. FerroBRIDGE’s validator holds it in the set unread, so none of its own R5 dependencies is needed. - A document whose
Composition.typeis LOINC34105-7is held to every constraint ofbundle-eu-hdrandcomposition-eu-hdr, and of the profiles its entries claim, before anything reaches a member. A document that breaks one is refused422and no member is asked. - The document is mapped into one composition of the template, the report
kept byte for byte in its
FEEDER_AUDIT.original_content, and written to the declared member by its ownehr_idalone, as a received patient summary is. Each receipt is recorded once underDischarge-Reportsin the access log.
A member’s own discharge reports
A member can serve its discharge reports itself, as an HL7 Europe EU Health
Data API Document Access Provider. Declare an hl7-fhir-rest endpoint of
the member with categories = ["Discharge-Reports"], the bundle-eu-hdr
profile (http://hl7.eu/fhir/hdr/StructureDefinition/bundle-eu-hdr) and
the identifier system of its ehr_id, as for a
member that serves its own summary.
The gateway asks the member by its own ehr_id alone, with the
interactions the Document Access Provider capability statement makes
SHALL:
GET {endpoint}/Patient?identifier={system}|{ehr_id}for the logical id of the member’sPatient. An emptysearchsetmeans the member holds no report of the patient, and nothing more is asked.GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|18842-5,http://loinc.org|100719-4for its discharge reports, eachnextpage followed under its own base. Thetypenames the codes of the value set your search admits, ORed with a comma as FHIR R4 search allows of every parameter.
The list names each DocumentReference the member answers as the member
holds it, with one change: each content.attachment.url becomes a
Binary/{token} the gateway signs for you, naming the member, its ehr_id,
the Discharge-Reports category and the logical id of the member’s
Binary. Reading that URL asks the member alone for
GET {endpoint}/Binary/{id} and answers its bytes and media type as the
member sent them. The rules are those of
a member’s own laboratory reports:
an attachment URL that names no Binary under the member’s endpoint, or a
Patient search that names more than one patient, makes the member one in
node-error, and a member that does not answer fails the list under
all-or-nothing, 424 or 504 naming it, and is named in an outcome
entry under partial. Each list and read is recorded under
Discharge-Reports in the access log, naming the member’s own endpoint
and its ehr_id (fhir/list/own_discharge.rs).
The component behind the category
The component work for discharge reports is FerroBRIDGE’s, through the
published eehrxf crate: the
discharge report crosswalk
to the HL7 Europe Hospital Discharge Report profiles, and
what the component accepts
when it receives a discharge report.
Imaging reports over FHIR
Medical imaging studies and related reports are a priority category of
Regulation (EU) 2025/327 (Art 14(1)(d)). The
document list names the imaging
documents of a member that serves them itself, beside the patient summary,
each at a Binary/{token} URL the gateway signs for you
(Reading a member’s document).
The gateway also writes an imaging report from a member’s openEHR composition when you declare the template those compositions are of (Imaging reports the gateway writes). It also receives an imaging report another system sends and writes it to the member you declare (Imaging reports the gateway receives).
Imaging documents in the list
The HL7 Europe EU Health Data API 1.0.0-ballot types an imaging document by
the LOINC codes of eehrxf-document-type-medical-imaging-vs: 85430-7
(diagnostic imaging report) and 18748-4 (diagnostic imaging study). A
type of either code, alone or in the LOINC system, or
http://loinc.org|, admits them, and a type that admits only them lists
no summary. With no type, the list names every document type it serves.
Imaging reports the gateway writes
Declare the template of the members’ imaging compositions in a
[[fhir.report]] entry, as for a
discharge report:
[[fhir.report]]
category = "Medical-Imaging"
template = "/etc/ferrofed/templates/ferrobridge_imaging_report.adlt"
archetypes = [
"/etc/ferrofed/archetypes/openEHR-EHR-COMPOSITION.report-result.v1.adl",
"/etc/ferrofed/archetypes/openEHR-EHR-OBSERVATION.imaging_exam_result.v1.adl",
]
The shipped imaging mapping of eehrxf maps FerroBRIDGE’s imaging report
template, openEHR-EHR-COMPOSITION.ferrobridge_imaging_report.v1.0.0, a
report composition holding one openEHR-EHR-OBSERVATION.imaging_exam_result.v1.
The configuration refuses a template the mapping does not compile against.
The list then runs the reserved stored query
eu.ferrofed.eehrxf::imaging-report on every member the patient resolves
at that does not serve its imaging documents itself. It selects every whole
composition that contains openEHR-EHR-OBSERVATION.imaging_exam_result.v1,
by the member’s own ehr_id alone. The list names one DocumentReference
per composition, typed 85430-7, in the category Medical-Imaging, its
format BundleReportEuImaging and its master identifier the composition’s
versioned object as a urn:uuid:. A search whose type admits only
18748-4 asks no member this query.
Reading the URL asks that member alone for the composition and writes it
as an HL7 Europe Imaging Report 1.0.0-ballot BundleReportEuImaging: a
CompositionEuImaging authored by the gateway’s Device, whose owner is
the operator, its custodian the member’s organisation, about a Patient named by the identifier the identity binding holds behind
the member’s ehr_id and described by the demographics binding’s header
as the laboratory report’s is, with the DiagnosticReportEuImaging and the
ImagingStudyEuImaging the mapping writes. The imaging study section lists
the study, and the order, history and procedure sections the profile
requires are empty as unavailable. One Provenance names the member.
Completeness, the access record under Medical-Imaging and the 424
answers follow the discharge report’s.
Imaging reports the gateway receives
Declare the member that stores received imaging reports in a
[[fhir.receive]] entry, with the HL7 Europe Imaging Report 1.0.0-ballot
package, its dependency closure, FerroBRIDGE’s ADL 2 imaging report
template with its archetypes, and the imaging project eehrxf ships:
[[fhir.receive]]
category = "Medical-Imaging"
member = "node-a"
package = "/etc/ferrofed/packages/hl7.fhir.eu.imaging-1.0.0-ballot.tgz"
dependencies = [
# the discharge report's closure, as in its example, and:
"/etc/ferrofed/packages/hl7.fhir.eu.health-data-api-1.0.0-ballot.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.base-2.0.0-ballot.tgz",
"/etc/ferrofed/packages/hl7.fhir.eu.extensions.r4-1.2.0.tgz",
"/etc/ferrofed/packages/hl7.terminology.r4-7.0.1.tgz",
"/etc/ferrofed/packages/hl7.fhir.uv.xver-r5.r4-0.0.1-snapshot-2.tgz",
"/etc/ferrofed/packages/fhir.dicom-2025.3.20250714.tgz",
"/etc/ferrofed/packages/ihe.iti.mhd-4.2.3.tgz",
"/etc/ferrofed/packages/ihe.iti.pdqm-3.2.0.tgz",
"/etc/ferrofed/packages/ihe.iti.balp-1.1.4.tgz",
"/etc/ferrofed/packages/ihe.formatcode.fhir-1.4.0.tgz",
]
template = "/etc/ferrofed/templates/ferrobridge_imaging_report.adlt"
archetypes = [
"/etc/ferrofed/archetypes/openEHR-EHR-COMPOSITION.report-result.v1.adl",
"/etc/ferrofed/archetypes/openEHR-EHR-OBSERVATION.imaging_exam_result.v1.adl",
]
files = [
"/etc/ferrofed/mappings/imaging/ferrobridge_eu_imaging.context.yml",
# and every other file of the imaging project, in its order
]
context = "ferrobridge_eu_imaging.context"
language = "en"
territory = "NL"
- FerroBRIDGE’s validator refuses a package set that lacks a package a manifest names, so the list holds every package the imaging package and the HL7 Europe Health Data API package it names depend on, at the versions their manifests name.
- A document whose
Composition.categoryisMedical-Imagingis held to every constraint ofBundleReportEuImagingandCompositionEuImaging, and of the profiles its entries claim, before anything reaches a member. A document that breaks one is refused422and no member is asked. - The document is mapped into one composition of the template, the report
kept byte for byte in its
FEEDER_AUDIT.original_content, and written to the declared member by its ownehr_idalone, as a received patient summary is. Each receipt is recorded once underMedical-Imagingin the access log.
A member’s own imaging documents
A member can serve its imaging documents itself, as an HL7 Europe EU Health
Data API Document Access Provider. Declare an hl7-fhir-rest endpoint of
the member with categories = ["Medical-Imaging"], the
BundleReportEuImaging profile
(http://hl7.eu/fhir/imaging/StructureDefinition/BundleReportEuImaging)
and the identifier system of its ehr_id, as for a
member that serves its own summary.
The gateway asks the member by its own ehr_id alone, with the
interactions the Document Access Provider capability statement makes
SHALL:
GET {endpoint}/Patient?identifier={system}|{ehr_id}for the logical id of the member’sPatient. An emptysearchsetmeans the member holds no imaging document of the patient, and nothing more is asked.GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|85430-7,http://loinc.org|18748-4for its imaging documents, eachnextpage followed under its own base. Thetypenames the codes of the value set your search admits, ORed with a comma as FHIR R4 search allows of every parameter.
The list names each DocumentReference the member answers as the member
holds it, with one change: each content.attachment.url becomes a
Binary/{token} the gateway signs for you, naming the member, its ehr_id,
the Medical-Imaging category and the logical id of the member’s Binary.
Reading that URL asks the member alone for GET {endpoint}/Binary/{id} and
answers its bytes and media type as the member sent them. The rules are
those of
a member’s own laboratory reports:
an attachment URL that names no Binary under the member’s endpoint, or a
Patient search that names more than one patient, makes the member one in
node-error, and a member that does not answer fails the list under
all-or-nothing, 424 or 504 naming it, and is named in an outcome
entry under partial. Each list and read is recorded under
Medical-Imaging in the access log, naming the member’s own endpoint and
its ehr_id (fhir/list/own_imaging.rs).
The component behind the category
The component work for imaging is FerroBRIDGE’s, through the published
eehrxf crate: the
imaging crosswalks
to the HL7 Europe Imaging Report profiles, and
what the component accepts
when it receives a document.
Terminology on the FHIR face
The patient summary the FHIR face writes, and a document it receives, carry their codes by the rules below.
Regulation (EU) 2025/327 Art 2(2)(f) defines interoperability as an exchange “without changing the content of the data”. The interoperability component therefore sends a code as it was recorded, and adds a coding beside it only where the coding means the same thing:
- A code in a preferred code system passes as recorded, displayed with the text the record holds.
- An openEHR
TERM_MAPPINGwhosematchis=, an equivalent term, adds a coding after the original. - A mapping whose
matchis>(broader),<(narrower) or?(unknown) adds no coding, because a FHIR coding states the concept itself. It is written into the narrative. - The text of every coded item is in the narrative, as Art 10(3) of the eHealth Network Guidelines on Patient Summary asks.
- A code of the archetype-local terminology (
local, anatoraccode) is displayed with the text the operational template defines for it in the archetype the item sits in, in the language asked. The coding is kept as recorded, and the term’s text opens the narrative. A template that holds the term only in another language leaves the code to Art 10(7); no other language is substituted. A local code the archetype does not define is refused, because the item does not conform to its template. - Any other code that is in no preferred system follows Art 10(7) of those guidelines: it is “transferred encoded, preferably displayed in English, and/or in narrative form”. The original coding is kept with its display, and its text is in the narrative.
The preferred code systems are those §4 of the guidelines names for its
data elements. The component ships thirteen of them, and the rules prefer
them by default. Twelve are the system URIs the MyHealth@EU Master Value
Sets Catalogue 9.1.0 includes them by: SNOMED CT, ICD-10, Orphacode, ATC,
the EDQM Standard Terms, UCUM, LOINC, ISO 3166, and HL7 Administrative
Gender, RoleClass, RoleCode and ObservationCategoryCodes. The catalogue
includes no NPU, so NPU is http://npu-terminology.org, the URI the HL7
Europe Laboratory Report 2.0.0 includes it by.
EMDN is not shipped. The catalogue’s medical device value set includes one
system by an OID, urn:oid:1.3.6.1.4.1.12559.11.10.1.3.1.44.6, but no
vendored text says which code system that OID is, and no vendored package
names a URI for EMDN. EMDN is added once a MyHealth@EU catalogue release,
an HL7 Europe guide or the publisher of EMDN names its system URI. IDMP is
not shipped either, because §4 lists it for use when available. A caller
may name its own set instead.
A coding carries the version of its terminology, the part of the openEHR
terminology id in parentheses, as its Coding.version only in the form
that code system’s versions take:
- SNOMED CT names a version by a URI of its edition, such as
http://snomed.info/sct/900000000000207008/version/20260201, as the vendored HL7 France Core 2.2.0 and HL7 Europe Laboratory Report 2.0.0 write it. A version in that form passes as recorded. Any other, such asSNOMED-CT(20260201), names no edition, so the coding carries no version: the edition cannot be inferred without changing the data. - HL7 Administrative Gender, RoleClass, RoleCode and
ObservationCategoryCodes name a version by dot-separated digits, such as
4.0.0, as the MyHealth@EU catalogue includes them. A version in that form passes as recorded, and any other is left out. - For ICD-10, Orphacode, ATC, the EDQM Standard Terms, UCUM, LOINC, NPU and ISO 3166, no vendored package shows how the system writes a version, so the version passes as recorded. The form of each is added once a vendored source shows it.
These rules are built into the component. The summary the face writes
carries the codings its FHIRconnect mappings write: the original code as
recorded, and a coding after it for each TERM_MAPPING whose match is
'='. A mapping whose match is '>', '<' or '?' is written as no
coding, and its section’s narrative carries it as one line, such as
AllergyIntolerance.code: broader term SNOMED-CT 762952008, from Hospital A.
(#926). A code in no
preferred system is translated through the configured terminology server,
as below, and with none is kept as recorded (Art 10(7))
(#1213).
The terminology server
A FHIR R4 terminology server is optional. When [eehrxf.terminology] names
one, the component asks it for three operations:
ConceptMap/$translate, for a code in no preferred system. Only a match whose equivalence isequivalentorequalbecomes a coding; awider,narrower,inexactor other match never does. In the patient summary, eachCodeableConceptof a mapped resource none of whose codings is in a preferred system is asked once per coding and preferred system, and every accepted match is added after the original coding, which stays as recorded. A translation the server refuses or does not answer fails the summary with500, and is never taken as no translation. A bareCodingholds one code, so it is kept as recorded.CodeSystem/$lookup, for the display of a received coding that carries none.ValueSet/$validate-code, for arequiredbinding whose value set no vendored package expands.
With no server configured, nothing is translated and a code in no preferred
system follows Art 10(7). A received coding with no display is refused, and
a required binding no vendored expansion decides is listed as not
evaluated. A server that refuses an operation, or does not answer within
timeout_ms, is an error that names the operation and the server’s status,
never a coding left out or a binding taken as held.
FerroTERM is the terminology server of the Ferro family. The gateway reaches it, or any other FHIR R4 terminology server, over HTTP, and links no part of it. The EDQM Standard Terms are not shipped with FerroFED: a deployment loads them into its terminology server under its own licence with the EDQM.
Required bindings
A required binding means the concept “SHALL be from the specified value
set” (FHIR R4). The validator holds each coded value that a binding of the
document’s profiles covers to its value set, read from the packages the
deployment supplies: the FHIR R4 core package and HL7 Terminology among
them, so the value sets of FHIR itself are decided offline. A concept
outside a required value set is a 422 finding, and one outside an
extensible value set a warning. A CodeableConcept holds when one of
its codings is in the value set.
A value set no package enumerates, such as one over SNOMED CT or LOINC, is
asked of the terminology server’s ValueSet/$validate-code. A server that
refuses or does not answer the check of a required binding refuses the
document with 502, since the gateway cannot say it conforms. Without a
terminology server, such a binding is listed as a warning that names the
value set as not evaluated.
Prescriptions and dispensations over FHIR
Electronic prescriptions and electronic dispensations are two of the
priority categories of Regulation (EU) 2025/327, Art 14(1)(b) and (c)
(vendored as
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml).
The FHIR face serves them resource by resource in the profiles of the
HL7 Europe Medication Prescription and Dispense (MPD)
guide 1.0.0, MedicationRequest-eu-mpd and MedicationDispense-eu-mpd
(vendored as
docs/specs/eu-hl7-mpd/hl7.fhir.eu.mpd-1.0.0.tgz).
The member CDR that created a prescription serves it on its own
hl7-fhir-rest endpoint
(A member’s own exchange-format endpoint),
and the face passes on what that member says. The member decides whether a
prescription can still be dispensed and counts the dispensations it has
left. The gateway computes neither, holds no lock and stores no
prescription.
Who reads which part:
- A client (a pharmacy or prescribing system, or a national contact point): the routes from Searching a patient’s prescriptions to Every answer, and The locator ids.
- An operator: Declaring the serving members, The map mode, Access records and the counter and The capability statement.
- A member CDR’s operator: The member contract and Remaining dispensations.
Each rule below names the test that holds it where one exists. The tests
are under app/ferrofed-server/tests/it/ unless another path is given.
Declaring the serving members
You declare in [[fhir.serve]] the members that meet
the member contract, one entry per category:
[fhir]
base = "/fhir"
receive_namespaces = ["urn:oid:2.999.1"] # needed once dispensations are served
[[fhir.serve]]
category = "Electronic-Prescriptions"
members = ["node-a", "node-b"] # node ids, each with its own MPD endpoint
[[fhir.serve]]
category = "Electronic-Dispensations"
members = ["node-a", "node-b"] # each also declared for Electronic-Prescriptions
receive_namespaces names the identifier systems a dispensation’s patient
is resolved in. Configuration load refuses Electronic-Dispensations
without it. Each member also needs an [[endpoint]] of connection type
hl7-fhir-rest whose categories name the category, whose profiles
carry its MPD profile, and whose ehr_id_system names the system the
member reads its ehr_id in.
The start and ferrofed config check are refused, naming the entry, for:
- a category other than
Electronic-PrescriptionsandElectronic-Dispensations, a category declared twice, an entry with no member, and a member named twice; - a member of
Electronic-Dispensationsthat is not also declared forElectronic-Prescriptions, because a dispensation is written only at the member that owns its prescription (fhir/serving/start.rs:a_dispensation_member_that_serves_no_prescription_is_refused); - a member the registry does not hold, one with no endpoint of its own
serving the category, an endpoint whose profiles leave out the MPD
profile, and a face with no resolver (
fhir/serving/start.rs:a_member_the_registry_does_not_hold_refuses_the_start,a_member_with_no_endpoint_of_its_own_refuses_the_start,a_member_whose_endpoint_serves_another_category_refuses_the_start,an_endpoint_without_the_mpd_profile_refuses_the_start).
config check prints one note: line per declared category, naming its
members, and one per category declared for no member
(Running it). A change to
[fhir] takes a restart. The rest of [fhir], its base and its
authentication are described with the patient summary
(Configuring the face).
A category no member is declared for is not served. Every route of it
answers 404 with an OperationOutcome of the issue type not-supported
that names the category, and no member is asked. The answer is never an
empty search result, which a client could read as a patient with no
prescriptions (fhir/serving/mod.rs:
with_no_member_declared_every_route_answers_not_served_and_asks_no_member,
a_served_category_is_never_answered_as_not_served):
| Route | Category |
|---|---|
GET {fhir-base}/MedicationRequest?patient.identifier= | Electronic-Prescriptions |
GET {fhir-base}/MedicationRequest/{id} | Electronic-Prescriptions |
POST {fhir-base}/MedicationDispense | Electronic-Dispensations |
GET {fhir-base}/MedicationDispense/{id} | Electronic-Dispensations |
GET {fhir-base}/MedicationRequest/{id}/_history/{vid} | Electronic-Prescriptions |
GET {fhir-base}/MedicationDispense/{id}/_history/{vid} | Electronic-Dispensations |
The map mode
A member that serves no FHIR endpoint of its own can serve its
prescriptions from its openEHR data. You declare it as a table with
mode = "map" and the operational template its medication orders are
recorded in:
[fhir]
receive_namespaces = ["urn:oid:2.999.1"] # needed once a member is in the map mode
[[fhir.serve]]
category = "Electronic-Prescriptions"
members = [
{ member = "node-a", mode = "map", template = "/etc/ferrofed/ferrobridge_medication.adlt",
archetypes = ["/etc/ferrofed/openEHR-EHR-INSTRUCTION.medication_order.v3.adl"] },
"node-b", # on its own MPD endpoint, as above
]
A member written by its node id alone, or with mode = "native", is asked
at its own endpoint. A template ending in .opt is read as an OPT 1.4
operational template; any other is read as an ADL 2 template, built with
the archetypes it specialises into its operational template. The face
compiles the MPD project the
eehrxf crate ships
(ferrobridge_eu_mpd_request.context) against it when the configuration
is read. The start and config check are refused, naming the entry and
the member, when the project does not compile against the template, a
file cannot be read, a map-mode member names no template, or a member of
Electronic-Dispensations is in the map mode (fhir/serving/start.rs:
a_map_mode_member_whose_template_the_mpd_project_does_not_map_is_refused,
a_map_mode_member_with_no_template_is_refused,
a_map_mode_member_of_dispensations_is_refused). A map-mode member needs
no hl7-fhir-rest endpoint.
The search asks each map-mode member the patient resolves at the reserved
stored query eu.ferrofed.eehrxf::medication-orders over ITS-REST, by its
own ehr_id alone, beside the native members and under the same budget.
The query selects every composition holding
openEHR-EHR-INSTRUCTION.medication_order.v3 with no feeder_audit. Of
what the member answers, only a composition whose uid’s
creating_system_id is the member’s own system_id is its prescription,
so an order another system created is never listed as this member’s. Each
is mapped into MedicationRequest-eu-mpd with the Medication-eu-mpd it
references, and its patient is named by the identifier the search names
(FHIR R4 Reference.identifier). The searchset lists it with one
Provenance naming the member, as a native member’s
(fhir/prescriptions/mapped.rs:
a_map_mode_members_own_orders_are_mapped_beside_a_native_members). A
map-mode member that does not answer, or whose composition does not map,
fails the search under all-or-nothing and is named under partial, as a
native member is (a_silent_map_mode_member_fails_the_search_under_all_or_nothing).
The id of a mapped prescription names the member’s Query API endpoint, its
ehr_id, the composition’s versioned object and the prescription’s
position in it (The locator ids). The read asks that
member alone for that composition
(GET {base}/ehr/{ehr_id}/composition/{uid}), maps it again, and names
the patient as the identity binding names it behind the ehr_id, in the
systems receive_namespaces lists. A composition another system created,
or one with a feeder_audit, answers 404 as an id with no owner
(a_mapped_prescription_is_read_at_its_member_by_its_composition,
a_mapped_id_whose_composition_another_system_created_has_no_owner). A
patient the binding cannot name answers 424, since a mapped prescription
names its patient (MPD MedicationRequest.subject 1..1).
A map-mode member that also receives dispensations is named, by its node
id, in the Electronic-Dispensations entry, and its table names the
language and territory each composition is written in (RM
COMPOSITION.language and territory are 1..1); config check refuses
such a member without them (fhir/serving/start.rs:
a_map_mode_member_receiving_dispensations_with_no_language_is_refused).
The owner of a dispensation is found as for a native member, among the
map-mode member’s mapped prescriptions too, and in the map mode the
dispensation names one prescription. The face reads the owner’s
medication actions with eu.ferrofed.eehrxf::medication-actions, and a
dispensation one of them keeps under the received one’s business
identifier refuses the write 409 with nothing written. Otherwise the
dispensation is mapped into one composition of the owner’s template: its
ACTION names the order it carries out by the OBJECT_VERSION_ID of the
order’s composition and the order’s path
(INSTRUCTION_DETAILS.instruction_id, activity activities[at0001]),
and the received dispensation is kept in its
FEEDER_AUDIT.original_content. The face creates it once with
composition_create at the owner by its ehr_id. A created composition
answers 201 naming the member and the version it created, with no
Location, since the face names no id to read it by; the owner’s refusal
is 409, its silence 504 saying the outcome is unknown
(fhir/prescriptions/mapped_dispense.rs:
a_dispensation_of_a_mapped_prescription_is_one_composition_at_its_owner_naming_the_order,
a_dispensation_the_owner_already_keeps_is_refused_with_nothing_written,
the_owners_refusal_is_a_conflict_naming_it,
an_owner_that_does_not_answer_is_asked_once_and_the_outcome_is_unknown).
The member contract
FerroFED relies on each declared member for what the gateway does not
compute. The instructions for use
ask the operator to confirm this with each member’s operator at admission.
A member that serves Electronic-Prescriptions:
- is the member that created each prescription it serves, never one holding an imported copy;
- holds the prescriber’s signature, and states each prescription’s dispensing status and the dispensations it has left (Remaining dispensations);
- answers
GET {own}/MedicationRequest?patient.identifier={ehr_id_system}|{ehr_id}with asearchsetofMedicationRequest-eu-mpdresources, each with a logicalid, and withnextlinks under its own base URL; - answers the same search with
&_id={its id}with that one prescription.
A member that also serves Electronic-Dispensations:
- creates a dispensation on
POST {own}/MedicationDispenseand answers201with aLocationof the created resource, or with the resource and itsidin the body; - answers
GET {own}/MedicationDispense?patient.identifier={ehr_id_system}|{ehr_id}&_id={its id}with that one dispensation; - refuses, with a
4xxstatus other than401, a dispensation its own state no longer allows, such as one beyond the remaining dispensations; - refuses a second dispensation carrying a dispense identifier
(
MedicationDispense.identifier) it already holds, so a pharmacy that sends a dispensation again after a time-out cannot have it written twice.
The member still decides what each caller may read and write: every
request carries the onward credential, the DPoP proof and the signed
caller identity of the member’s openehr-rest-query endpoint, and passes
the same outbound gate as an ITS-REST request
(A member’s own exchange-format endpoint).
The member’s ehr_id and its own id of a resource are the only values
about the patient a request carries; no patient identifier a client sent
reaches a member (§5.4.1, N33).
Concurrent dispensations of one prescription at two pharmacies are the member’s to serialise. The gateway refuses a dispensation the owner already holds under the same business identifier, so a dispensation sent again is never written twice (Receiving a dispensation, hazard I-12 of the clinical safety risk file).
Searching a patient’s prescriptions
GET {fhir-base}/MedicationRequest?patient.identifier=system|value names
the patient by an identifier with its system, as $summary does, and takes
the composition- search scope over every template
(user/composition-*.s, for example). The search takes patient.identifier,
_count and _format alone, and -offset on a page the gateway linked;
any other parameter, or a second patient, is a 400
(fhir/prescriptions/restricted.rs:
a_search_naming_no_patient_or_another_parameter_is_refused_and_nothing_is_sent).
The gateway resolves the patient once, then asks each declared member that
holds the patient at its own endpoint, with
GET {own}/MedicationRequest?patient.identifier={ehr_id_system}|{ehr_id},
and asks no member that does not hold the patient
(fhir/prescriptions/mod.rs: each_member_is_asked_by_its_ehr_id_alone,
a_member_that_does_not_hold_the_patient_is_not_asked). It follows each
member’s next links until its last page, at most 64 pages a member, and
only links under the member’s own base URL (fhir/prescriptions/paging.rs:
a_members_next_page_is_followed_under_its_own_base,
a_next_link_outside_the_members_base_fails_the_search_and_is_not_followed).
The answer is one searchset:
- each member’s
MedicationRequestas the member sent it: every element and extension kept, and every number in the text the member wrote, such as1.50; only the order of an object’s members may differ, which JSON gives no meaning (RFC 8259 §4). Entries are in endpoint order, then in the member’s own order. Itsidis the locator id, and itsfullUrlthe read,{fhir-base}/MedicationRequest/{id}; - one
Provenanceper prescription, whose agent is the member’sOrganization; the gateway adds no author, signs nothing and merges nothing (fhir/prescriptions/mod.rs:each_members_prescriptions_are_answered_as_sent_with_a_provenance_naming_it); - a
warningoutcomeentry when two members hold a prescription under the same identifier; each member’s prescription stays listed (a_prescription_identifier_two_members_hold_is_listed_once_per_member_and_warned); Bundle.total, the number of prescriptions over every page, and aselflink naming the parameters the search used.
With _count=n the gateway pages the answer itself: each page holds at most
n prescriptions, and its next link carries the gateway’s own -offset
parameter, so a page holds no state between requests. Each page asks the
members again (fhir/prescriptions/paging.rs:
the_gateway_pages_its_answer_by_count).
A patient that no declared member holds, and a patient whom a consent
decision hides, both get the same empty searchset
(fhir/prescriptions/restricted.rs:
an_unknown_patient_and_a_restricted_one_answer_alike_and_nothing_is_sent).
A member that does not answer is handled as Completeness
says.
Reading one prescription
GET {fhir-base}/MedicationRequest/{id} reads one prescription by the
locator id the search gave it, and takes the
composition- read scope over every template (user/composition-*.r, for
example). The gateway asks the member the id names alone, with
GET {own}/MedicationRequest?patient.identifier={ehr_id_system}|{ehr_id}&_id={member's id}
(fhir/prescriptions/read.rs:
the_member_is_asked_by_its_ehr_id_and_its_own_id_alone). A caller whose
grant is confined to one patient (patient/) reads that patient’s
prescriptions alone: an id naming another patient’s ehr_id is refused
403 and nothing is sent
(a_caller_confined_to_one_patient_reads_that_patients_prescription_alone).
Where a consent pre-filter is configured, the read is held to it as the
search is. The read names no patient, so the gateway asks the identity
binding for the patient behind the id’s ehr_id, in the namespaces
[access_log] patient_namespaces names, and asks the pre-filter about the
member before anything is sent. A member it denies is asked nothing, and the
read answers as an id the face never wrote, with no trace of the
restriction (Regulation (EU) 2025/327 Art 8, Art 11(5);
fhir/prescriptions/restricted.rs:
a_read_at_a_member_the_prefilter_denies_answers_as_an_unknown_id_and_asks_nothing).
A denial set aside for an emergency purpose is asked and named in the access
record. When the binding cannot name the patient, or no namespace is
configured, the pre-filter has nothing to decide and the member stays the
gate, as when the pre-filter gives no signal. The same holds for a
dispensation read and a version read.
The answer is the member’s MedicationRequest as it sent it, with its id
the one you read, as FHIR R4 asks of a read
(a_prescription_the_search_lists_is_read_at_its_member_as_sent). The
openEHR-federation-endpoint and openEHR-federation-system-id headers
name the member that answered. A meta.versionId that is a valid FHIR id
is answered as a weak ETag (W/"3" for version 3), and a
meta.lastUpdated that is a valid FHIR instant as Last-Modified, an
HTTP-date in GMT, so you can make a conditional read. Either one absent, or
of another form, gives no such header; the gateway logs the second case by
its shape and never logs the value (fhir/prescriptions/version.rs). A
member that answers another prescription than the one asked for is never
passed through
(fhir/prescriptions/read.rs: a_member_answering_another_prescription_is_never_passed_through).
Receiving a dispensation
POST {fhir-base}/MedicationDispense with application/fhir+json takes one
MedicationDispense, the FHIR R4 create interaction, and the
composition- create scope over every template (user/composition-*.c,
for example). The gateway writes it to the one member that owns the
prescription it names, and to no other member.
The dispensation is checked before any member is asked. A body that is no
MedicationDispense is a 400; one that breaks MedicationDispense-eu-mpd,
or a contained Medication that breaks Medication-eu-mpd, is a 422 with
one issue per finding (fhir/prescriptions/dispense.rs:
a_dispensation_that_breaks_its_profile_asks_no_member). Its subject
names the patient by an identifier, either a logical reference or a
contained Patient that subject references by #id, and each identifier
whose system is in receive_namespaces is resolved. Every one of them must
name the same ehr_id at every member, or the dispensation is refused
422. The dispensation must carry its own business identifier, an
identifier with a system and a value, or it is refused 422
required, naming MedicationDispense.identifier
(a_dispensation_with_no_business_identifier_is_refused_naming_the_element):
it is how the gateway tells a dispensation sent again from a new one.
Each authorizingPrescription names a prescription by its business
identifier, and may carry the face’s reference MedicationRequest/{id}
beside it, which must then name the same prescription
(a_dispensation_by_identifier_and_the_faces_reference_reaches_its_owner,
a_reference_and_an_identifier_that_disagree_write_nothing). A
prescription named by the face’s reference alone is refused 422, “name
the prescription by its identifier; the member cannot resolve the
gateway’s reference”: the member receives your body unchanged, so it can
link the dispensation only by the identifier it holds
(a_dispensation_naming_its_prescription_by_the_faces_reference_alone_is_refused).
The gateway then asks every member declared for Electronic-Prescriptions
that holds the patient for the patient’s prescriptions, as the search does,
by the member’s own ehr_id alone, and finds the named prescriptions in the
answers itself. The prescription identifier never reaches a member. Every
member asked must answer, because a silent one could be the owner
(a_member_silent_at_the_lookup_refuses_the_dispensation). The dispensation
is first checked against the owner’s dispensations: the gateway asks the
owner’s own endpoint for the patient’s dispensations, by its own ehr_id
alone, and looks for one with the received dispensation’s business
identifier itself, so the identifier never reaches the member in a query. A
dispensation the owner already holds is refused 409 duplicate, naming
the one already written, and nothing is written; this is how a dispensation
you send again after a time-out is never written twice
(a_dispensation_the_owner_already_holds_is_refused_as_a_duplicate_with_nothing_written,
another_dispensation_of_the_patient_is_no_duplicate_and_is_written). An
owner that does not answer that read refuses the dispensation, because a
duplicate cannot be ruled out
(an_owner_that_does_not_answer_the_duplicate_check_writes_nothing). The
dispensation is then created at the owner’s own endpoint with
POST {own}/MedicationDispense, once, its body exactly as you sent it
(a_dispensation_reaches_the_owner_alone_by_its_ehr_id_with_its_body_unchanged).
The member decides whether its prescription still admits the dispensation.
The gateway holds no lock, and it never sends a dispensation again
(an_owner_that_does_not_answer_is_asked_once_and_the_outcome_is_unknown).
An openEHR-federation-endpoint header is a check only: when it names an
endpoint of another member than the owner, nothing is written
(a_header_naming_another_member_writes_nothing).
A dispensation with no owner answers as an unknown patient does, and
nothing is written in any of the refusals below
(no_owner_answers_as_an_unknown_patient_does,
a_prescription_two_members_hold_is_refused_with_an_integrity_incident,
two_matches_at_one_member_are_refused_with_no_incident,
prescriptions_held_by_different_members_write_nothing). The owner’s own
refusal is passed on as 409, naming the member and its status
(the_owners_refusal_is_a_conflict_naming_it).
A dispensation sent again by POST under the same business identifier with
status entered-in-error is a duplicate like any other: it is refused
409 duplicate and nothing reaches the owner
(a_withdrawal_sent_again_is_refused_as_a_duplicate_with_nothing_written).
To withdraw a dispensation, update it as
Withdrawing a dispensation describes.
Withdrawing a dispensation
PUT {fhir-base}/MedicationDispense/{id} with application/fhir+json
withdraws the dispensation the locator id names, the
FHIR R4 update interaction, and takes the composition- update scope over
every template (user/composition-*.u, for example). The face takes an
update of a dispensation only as its withdrawal.
The body is the dispensation with status entered-in-error, checked
before any member is asked as a receipt is: a body that is no
MedicationDispense is a 400, and one that breaks
MedicationDispense-eu-mpd a 422 with one issue per finding
(fhir/prescriptions/withdraw.rs:
a_withdrawal_that_breaks_its_profile_asks_no_member). Its id must be the
locator id in the path, as FHIR R4 asks of an update, or it is refused 400
invalid (a_body_whose_id_is_not_the_requests_is_refused_asking_no_member).
Any other status is refused 422 business-rule
(an_update_that_withdraws_nothing_is_refused_asking_no_member).
The gateway reads the dispensation at the member the id names first, as
Reading a dispensation does, so a withdrawal of a
dispensation that member does not hold for its patient answers as a read
of it does and writes nothing
(a_withdrawal_of_a_dispensation_its_member_does_not_hold_writes_nothing).
It then sends the withdrawal to that member’s own endpoint with
PUT {own}/MedicationDispense/{member's id}, once. The body is the one you
sent, byte for byte, except for its top-level id, which the gateway
replaces with the member’s own id, since an update names its resource by
the id of the server it reaches
(a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_id).
This is the inverse of the one change a read makes. A withdrawal the member
made answers 200 with an OperationOutcome naming the member, the
member’s ETag and Last-Modified, and the member named in
openEHR-federation-endpoint.
The member decides whether its state still admits the withdrawal. Its
refusal is passed on as 409, naming the member and its status, with the
member’s own issues after the gateway’s
(the_members_refusal_is_a_conflict_naming_it_with_its_own_issues). A
member that does not answer in time is 504, saying whether it withdrew
the dispensation is unknown; the gateway never sends the withdrawal again
(a_member_that_does_not_answer_is_asked_once_and_the_outcome_is_unknown).
Reading a dispensation
GET {fhir-base}/MedicationDispense/{id} reads a dispensation by the
locator id its receipt’s Location named, and takes the
composition- read scope over every template. The gateway asks the member
the id names alone, with
GET {own}/MedicationDispense?patient.identifier={ehr_id_system}|{ehr_id}&_id={member's id},
and answers the member’s MedicationDispense as it sent it, with its id
the one you read, its ETag and Last-Modified from the member’s
meta.versionId and meta.lastUpdated, and the member named in
openEHR-federation-endpoint (fhir/prescriptions/dispensed.rs:
the_dispensation_a_receipt_located_is_read_at_its_member_by_its_ehr_id,
a_dispensation_id_with_no_owner_is_not_found_and_asks_no_member,
a_member_that_does_not_answer_the_dispensation_read_is_named). Its other
answers are a prescription read’s, with “dispensation” in their text.
Reading a version
GET {fhir-base}/MedicationDispense/{id}/_history/{vid} and
GET {fhir-base}/MedicationRequest/{id}/_history/{vid} are the FHIR R4
vread, so the Location of a receipt that carries the member’s version
reads back the dispensation it named. The gateway reads the resource as
the read does, with the same scope, headers, answers and access record,
and answers it when the member’s meta.versionId is {vid}. The gateway
keeps no history, so any other version, and a resource with no
meta.versionId, is 404 not-found. A {vid} that is no FHIR id is
refused before any member is asked (fhir/prescriptions/vread.rs:
the_location_a_receipt_answered_reads_back_the_dispensation_it_located,
a_version_the_member_does_not_hold_now_is_not_found,
a_version_that_is_no_fhir_id_asks_no_member,
a_prescription_is_read_at_the_version_it_holds_and_no_other).
The locator ids
The id the face gives a prescription, and the id in a received
dispensation’s Location, name three things: the member’s own endpoint,
the member’s ehr_id for the patient and the member’s own id of the
resource. The gateway packs them into the 64 characters a FHIR R4 id
allows: a form byte, the first 6 bytes of the SHA-256 of the endpoint’s
endpoint_id, then the ehr_id and the member’s id, each as its 16 bytes
when it is a UUID and as its text otherwise, written in the base64url
alphabet (RFC 4648 §5) with . in place of _. The gateway holds no state
between the search and the read, and the same prescription always gets the
same id (app/ferrofed-server/src/fhir_wire/locator.rs:
an_id_is_a_fhir_id_and_reads_back_to_its_parts,
the_id_is_the_same_each_time).
A mapped prescription (The map mode) has its own form,
which no member’s own prescription shares: the member’s Query API endpoint
in place of its own endpoint, the UUID of the composition’s versioned
object in place of the resource id, and one byte more for the
prescription’s position among those the composition maps to
(a_mapped_prescription_reads_back_to_its_composition_and_position,
a_mapped_prescription_and_a_native_one_never_share_an_id).
The id only locates a resource. It carries no patient identifier, and every
read is still held to the caller’s grant and asked of the member by its
ehr_id. An id the gateway did not write, or one naming an endpoint not
declared for the category, answers 404 with the text every id with no
owner gets. An id whose endpoint part matches two declared endpoints is a
409, and the gateway never chooses between them.
A prescription whose parts do not fit in 64 characters, or whose member
id is no FHIR id, keeps the member’s own id in the search and cannot
be read through the face. The search then adds a warning outcome entry
naming the member and quoting no identifier
(fhir/prescriptions/read.rs:
a_prescription_with_no_id_to_read_it_by_is_listed_and_warned_of). A
created dispensation whose member names no such id gets a 201 with no
Location, and the OperationOutcome says so.
Every answer
Every answer other than a success is an OperationOutcome. The gate’s
authentication answers (401 and the 403s of a token) are those of every
route (Failure statuses),
and another method on any of these paths is refused 403
operation-refused before the token is read (fhir/serving/mod.rs:
another_method_on_a_prescription_path_is_refused_at_the_gate).
| Route | Status | When |
|---|---|---|
| search | 200 | The searchset, empty for a patient no declared member holds or none may say anything about. |
| read | 200 | The member’s prescription or dispensation, with the headers Reading one prescription names. |
| receipt | 201 Created | The owner created the dispensation. Location names it by its locator id, {fhir-base}/MedicationDispense/{id}, with /_history/{version} when the member named a version that is a FHIR id, which the version read takes, and the OperationOutcome names the member and every profile constraint the check did not evaluate. |
| search | 400 | A parameter other than patient.identifier, _count, -offset and _format, no patient, or two. |
| receipt | 400 | A body that is no MedicationDispense or no UTF-8 text; or an openEHR-federation-endpoint header naming another member’s endpoint, or one the registry does not hold. |
| every route | 403 | No granted scope covers the route, or a patient/ grant reaches beyond its patient (patient-confinement). |
| every route | 404 not-supported | No member is declared for the route’s category. |
| read | 404 not-found | The gateway did not write the id, it names no declared member, the gateway already places its ehr_id at another member, or the member holds no such resource for that ehr_id. Every case gets the same text. |
| version read | 404 not-found | As a read; or the member holds the resource at another version, or with no meta.versionId, and the gateway keeps no history. |
| receipt | 404 not-found | No member holds the patient, none may say so, or no member holds the prescription. Every case gets the same text. |
| read | 409 conflict | Two members claim the ehr_id the id names, which also raises the integrity incident; the id’s endpoint part matches two endpoints; or the member answered more than one resource for one id, with no incident. Nothing is passed on. |
| receipt | 409 conflict | Two members hold the prescription, which also raises the PrescriptionIdentifierCollision integrity incident; one member holds it twice, with no incident; or the owner refused the dispensation, named with its status, and the owner’s own OperationOutcome issues follow the gateway’s, unaltered, when its body is one. |
| receipt | 409 duplicate | The owner already holds a dispensation with the received one’s business identifier, named by its locator id. Nothing was written. |
| receipt | 415 | Another media type than application/fhir+json. |
| receipt | 422 | The dispensation breaks its profiles; it carries no business identifier with a system and a value (required); the patient’s identifiers disagree; the prescriptions are held by different members; a prescription is named by the face’s reference alone; a reference and an identifier name different prescriptions; a reference is not to the face; or the owner is not declared for Electronic-Dispensations. |
| search, read | 424 | A member answered with an error, with no searchset, or with another resource than the one asked for; or the patient could not be resolved at a member. |
| receipt | 500 | The dispensation profiles this build carries could not be read; or the request to the owner would have carried a patient identifier outside its body, so it was not sent (§5.4.1, N33). |
| receipt | 502 | The owner answered the dispensation with a 5xx status or a 401, failed in another way, or could not be reached. After a 5xx whose body is an OperationOutcome, the owner’s issues follow the gateway’s. For an owner that could not be reached, whether it wrote the dispensation is unknown. |
| every route | 503 access-unrecorded | The access record has no place, so the answer is withheld (Failing closed). A receipt reserves its record’s place first, so a receipt refused this way reached no member. |
| receipt | 503 | A member that could hold the prescription answered the lookup with no usable searchset, or the patient could not be resolved at it; the owner answered the duplicate check with an error; or the dispensation could not be sent before the deadline. Nothing was written. |
| search, read | 504 | A member is offline or did not answer in time. |
| receipt | 504 | A member did not answer the lookup in time, and nothing was written; or the owner did not answer the dispensation in time, and whether it wrote it is unknown. |
After a 504 or a 502 for an owner that could not be reached, the
gateway did not send the dispensation again: ask the member, or read the
prescription’s status, before you send it once more with the same dispense
identifier, which the member refuses when it already holds it. No other
answer follows a write the owner could have made.
Completeness
A search asks every declared member that holds the patient, and is
all-or-nothing by default, as a federated query is (§11.3, §11.4). A member
that does not answer, answers with no searchset, or links a page outside
its own base fails the search: 504 for a member that is offline or timed
out, 424 otherwise, with every such member named. A member the identifier
cross-reference could not answer for fails it too, named not-resolved
(fhir/prescriptions/mod.rs: a_silent_member_fails_the_search_and_is_named,
an_unreachable_member_fails_the_search_with_a_gateway_time_out;
fhir/prescriptions/resolution.rs:
a_member_the_cross_reference_could_not_answer_for_fails_the_search_and_is_named).
Where the deployment sets [federation] best_effort = true, the default,
send openEHR-federation-completeness: partial to take the prescriptions
of the members that answered. Each member that did not is then named in a
warning outcome entry with its §11.1 status, so a partial list never
reads as a complete one (fhir/prescriptions/mod.rs:
under_partial_a_silent_member_is_named_in_an_outcome_entry;
fhir/prescriptions/resolution.rs:
under_partial_a_member_whose_resolution_failed_is_named_in_an_outcome_entry).
A read asks one member, so it has no partial answer. A receipt has none
either: the lookup needs every member that could own the prescription, and
partial does not change that.
Remaining dispensations
No EU element carries the number of dispensations a prescription has left:
the MPD element dispenseRequest.numberOfRepeatsAllowed is the number
authorised. A member states the remaining number in the extension
https://ferrobridge.eu/fhir/StructureDefinition/remaining-dispensations on
MedicationRequest.dispenseRequest, a valueUnsignedInt, at most once, and
leaves it out when it states none:
"dispenseRequest": {
"extension": [{
"url": "https://ferrobridge.eu/fhir/StructureDefinition/remaining-dispensations",
"valueUnsignedInt": 4
}],
"numberOfRepeatsAllowed": 5
}
The gateway passes the extension through as the member sent it, as it
passes every element, and never computes or corrects the number. Its FHIR
R4 StructureDefinition is the interoperability component’s,
src/medication/StructureDefinition-remaining-dispensations.json of the
published eehrxf crate, and FerroBRIDGE publishes it at that URL (its
crates/eehrxf/tests/it/medication/extension.rs:
the_extension_is_simple_at_most_once_with_an_unsigned_int,
the_context_is_the_dispense_request_of_a_medication_request).
Access records and the counter
Each request that reached a member writes one access record (The access log):
| Request | Recorded as | Test |
|---|---|---|
| search | a read under Electronic-Prescriptions, naming every member asked | fhir/prescriptions/restricted.rs: a_search_is_recorded_once_under_electronic_prescriptions_naming_each_member |
| prescription read | a read under Electronic-Prescriptions, naming the member and the ehr_id it was asked by | fhir/prescriptions/read.rs: a_read_is_recorded_once_under_electronic_prescriptions_naming_its_member |
| receipt | a creation under Electronic-Dispensations and Electronic-Prescriptions, naming every member the lookup asked and the owner | fhir/prescriptions/dispense.rs: a_dispensation_is_recorded_as_a_creation_of_both_categories |
| dispensation read | a read under Electronic-Dispensations | fhir/prescriptions/dispense.rs: a_dispensation_read_is_recorded_as_a_read_of_dispensations |
| withdrawal | an update under Electronic-Dispensations, naming the member and the ehr_id it was asked by | fhir/prescriptions/withdraw.rs: a_withdrawal_is_recorded_as_an_update_of_dispensations |
| version read | a read as the read of the same resource records it | fhir/prescriptions/dispense.rs: a_dispensation_version_read_is_recorded_as_a_read_of_dispensations |
A not-served answer, and a request refused before any member was asked,
write no record. A receipt takes its record’s place before the owner is
asked, so a receipt whose record has no place is refused 503 access-unrecorded and reaches no member
(a_dispensation_whose_record_has_no_place_reaches_no_member). The
patient identifier you sent never reaches the gateway’s log
(fhir/prescriptions/restricted.rs: the_patient_identifier_never_reaches_the_log).
Every receipt that reaches the lookup is counted once by how it ended in
ferrofed_fhir_dispensations_total, labelled outcome alone: created,
unowned, claimed, several, unroutable, silent, refused,
unknown or failed. No prescription, patient or member is ever a label,
so a dispensation no member owns is counted with no value
(fhir/prescriptions/dispense.rs:
a_dispensation_with_no_owner_is_counted_with_no_value;
The metrics). A prescription two
members hold also raises the integrity incident
PrescriptionIdentifierCollision, which carries no prescription identifier
(The registry).
The capability statement
GET {fhir-base}/metadata lists MedicationRequest only when a member is
declared for Electronic-Prescriptions, with the profile
MedicationRequest-eu-mpd, the search-type, read and vread
interactions and the patient.identifier search parameter. It lists
MedicationDispense only when a member is declared for
Electronic-Dispensations, with the profile MedicationDispense-eu-mpd and
the create, read, vread and update interactions, the last only as
a withdrawal. Each interaction it declares is one its route serves
(fhir/serving/mod.rs:
with_no_member_declared_the_capability_statement_lists_neither_resource,
a_declared_member_lists_both_resources_in_their_mpd_profiles,
every_interaction_declared_for_a_prescription_or_dispensation_is_one_the_route_serves).
A national contact point
A national contact point for digital health connects a Member State’s
healthcare providers to MyHealth@EU and answers the requests that
health professionals of other Member States send through it (Regulation
(EU) 2025/327 Art 23, vendored as
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml).
The part of a contact point that reaches into its own country is its
national connector. FerroFED is one of the national infrastructures a
national connector can call: it serves the connector on the FHIR face, the
same interface every other client of the European exchange format uses.
The MyHealth@EU NCPeH API guide (9.1.0, and its current build 9.1.1-ci)
specifies only the exchange between two contact points: “It is out of scope of this guide to define the
interfaces with the National Connector and/or between the National
Connector and National Infrastructure”. Implementing Regulation (EU)
2026/2083 Art 3(6) leaves “the operation of national infrastructures” with
the contact point. How a national connector reaches FerroFED is therefore
FerroFED’s own design, recorded in docs/architecture.md (decisions A67 to
A72).
Connecting a national connector
You connect a contact point in four steps:
- Serve the FHIR face under
[fhir], with the members that serve prescriptions and dispensations in[[fhir.serve]](The patient summary over FHIR, Prescriptions and dispensations over FHIR). - Trust the issuer of the connector’s tokens as an
[[auth.issuer]]ofkind = "federation"withsender_constraint = "required", and give it an[[auth.federation]]table that setscontact_point = true(Authentication). - Name that issuer in a
[[federation.contact_point]], with the header the connector sends its correlation identifier in (National contact points). - Give FerroFED its own PostgreSQL store with
[store], which a contact-point issuer needs; without it the gateway refuses to start (FerroFED’s own store).
[fhir]
base = "/fhir"
[[fhir.serve]]
category = "Electronic-Prescriptions"
members = ["node-a", "node-b"]
[[fhir.serve]]
category = "Electronic-Dispensations"
members = ["node-a", "node-b"]
[[auth.issuer]]
issuer = "https://ncp.example.org"
kind = "federation"
audience = ["https://gateway.example.org/fed"]
sender_constraint = "required"
[[auth.federation]]
issuer = "https://ncp.example.org"
contact_point = true
mapping = "ehdsi"
countries = ["LU"]
fields."professional.country_code" = [{ constant = "LU" }]
fields."professional.issuing_authority_name" = [{ constant = "Example HP Register" }]
fields."provider.issuing_authority_name" = [{ constant = "Example HCP Register" }]
[[federation.contact_point]]
issuer = "https://ncp.example.org"
correlation_header = "ncp-correlation-id"
[store]
url_file = "/run/secrets/ferrofed/store-url"
Your contact point’s authorization server writes what it read from the
validated HP Identity Assertion and the treatment relationship confirmation
(TRC) under the token’s ehdsi claim, each attribute at its
ferrohealth-smart field name (ehdsi.professional.hp_identifier,
ehdsi.point_of_care and so on). The patient of the TRC, its subject-id
(eHDSI SAML Profile 10.0.0 §4.3), travels in ehdsi.patient.id as one
system|value token. The assertion carries no country and no issuing
authority, so you configure them as constants for the contact point: the
country of the contact point, and the authorities that issue its
professionals’ and providers’ identifiers. The connector reaches the
gateway over mutual TLS, and each token is bound to the connector’s client
certificate in its cnf claim (RFC 8705 §3).
The exchange your authorization server answers
No product we can name accepts the HP Identity Assertion as a token to
exchange. RFC 7522 needs a bearer assertion, and the identity assertion is
sender-vouches (eHDSI SAML Profile 10.0.0 §2.1). So your Member State runs
the authorization server acting for NCPeH-A. FerroFED publishes the
exchange it expects that server to answer, and its test suite runs a harness
that answers it the same way:
-
The request. The national connector sends an RFC 8693 token exchange (§2.1):
grant_typeurn:ietf:params:oauth:grant-type:token-exchange, with the identity assertion, base64url, assubject_tokenofsubject_token_typeurn:ietf:params:oauth:token-type:saml2. Once the patient is identified, it adds the TRC, base64url, astrc_tokenoftrc_token_typeurn:ietf:params:oauth:token-type:saml2. A discovery-phase exchange sends notrc_token. The namestrc_tokenandtrc_token_typeare FerroFED’s own design. RFC 6749 §8.2 lets a profile define them, and RFC 8693 has no parameter for a second subject assertion. -
The presenter. Both assertions are
sender-vouchesand name no presenter (SAML Profile 10.0.0 §2.1). Your server authenticates the national connector of NCPeH-A before it reads either assertion, for instance by its client certificate (RFC 8705 §2), and refuses any other clientinvalid_client(RFC 6749 §5.2). NCPeH-A has verified NCP-B’s message signature over the assertions before it hands them over (Messaging Profile 10.0.0 §5.5.2). -
The checks. Your server checks both assertions as
ferrohealth-smart’sfederation::assertion::Validator::exchangedoes:- the enveloped signature by NCP-B’s trusted seal, at or above the Cryptographic Algorithms 9.0.0 §2 floor (an RSA key of at least 3000 bits, SHA-256 or above, no elliptic curve);
- the SAML Profile 10.0.0 §2 and §4 checks, with
IssueInstant,AuthnInstantandSessionNotOnOrAfterheld to the clock; - a TRC whose
Advice/AssertionIDRefnames the identity assertion’s@ID; - each exchange admitted once while its assertions are valid (RFC 7522
§3). The record of admitted exchanges is one store every replica of
your server shares, keyed by a digest of the assertions’ issuers and
@IDs and kept until the earliest window ends, as the crate’sreplay::ReplayGuarddescribes. A replica that keeps its own record admits a replay another replica refused.
It refuses an assertion or a replay with RFC 6749 §5.2
invalid_grant, naming the Requirements Catalogue 10.0.0 §1.25 code, and answersserver_errorwhen the replay record cannot be read. -
The token. It carries the
ehdsiclaimExchange::claimwrites, is bound to the connector’s certificate, and lives no longer thanExchange::ends, the earliest end of either assertion’s window and session.
ferrofed config check reads the tables, and OPTIONS {base}/ declares the
contact point under federation.national_contact_point, so the connector’s
operator can check what the gateway expects. You see the running values on
the operator console’s Configuration page, on its Authentication tab, and in
the running configuration of the admin listener
(The running configuration):
the contact point’s issuer, the correlation header and the added countries,
and any secret by the file it is read from alone.
What the connector calls
The connector uses the routes of the FHIR face, each with its token and, when you declared one, its correlation header:
| Route | What it answers |
|---|---|
GET {fhir-base}/Patient?identifier=system|value | the patient lookup: the Patient the identity binding holds, or the empty searchset |
GET {fhir-base}/Patient/$summary?identifier=system|value | the patient summary built from every member that holds the patient |
GET {fhir-base}/DocumentReference?patient.identifier=system|value | the document list |
GET {fhir-base}/MedicationRequest?patient.identifier=system|value | the patient’s prescriptions at the serving members |
GET {fhir-base}/MedicationRequest/{id} | one prescription |
POST {fhir-base}/MedicationDispense | a dispensation, written to the member that owns its prescription |
PUT {fhir-base}/MedicationDispense/{id} | the discard of a dispensation, status entered-in-error, written to the member that holds it |
The connector names the patient by the national identifier its own identity services confirmed, with that identifier’s system. The gateway resolves it as it resolves any subject, through the configured identity binding, and answers a patient no member holds and a patient every member restricts alike.
The treatment relationship binding
The patient lookup is the identification step and needs no TRC. Every other
route reaches patient data, and the gateway holds it to the patient the
token’s ehdsi.patient.id names (eHDSI XCA Profile 8.0.1 §4.2.1 and
§4.2.2):
- A token with no
ehdsi.patient.id, or one that is notsystem|value, is refused403 forbiddenwith the diagnosticstrc-missing, and nothing is asked of anyone. - The patient a summary, a document list or a prescription search names must
be the TRC’s patient, compared on the system and the value before the
identity binding or any member is asked. A dispensation’s
subjectmust carry the TRC’s identifier. - The gateway resolves the TRC’s patient at every member before the request goes on. A read of a prescription, a dispensation or a document by its id, and a withdrawal, reaches its member only when the resource sits in that patient’s own EHR there.
- A request outside the TRC’s patient, a patient no member holds, and any
other
not-foundon these routes all get one answer:404 not-found, whoseissue.detailscarries the refusaltrc-patient-mismatch(https://ferrofed.eu/fhir/CodeSystem/refusal) and the service’s Requirements Catalogue 10.0.0 §1.25 code (https://ferrofed.eu/fhir/CodeSystem/myhealth-eu-exception):ERROR_PS_NOT_FOUNDfor the summary and its documents,ERROR_ORCD_NOT_FOUNDfor a laboratory report, andERROR_EP_NOT_FOUNDfor prescriptions and dispensations. Nothing in it tells the cases apart, so the connector answers “No Data” as XCA §4.2.2 asks. The legacy numeric codes of the NCPeH Components Specifications 10.0.0 appendix 6.4 are never used. - A request outside the TRC’s patient is a security event. It is logged
under the target
ferrofed::securityastrc-patient-mismatchand counted in the security metrics. The event names the contact point, the professional (hp_identifier, its issuing authority and country) and the request id, and names both patients by pseudonym alone: the first 16 bytes of an HMAC-SHA256 under a key the process generates at start and never writes anywhere (eHDSI Section I Security Policies 10.0.0 SR 5). The contact point’s own audit trail carries the patient, joined by the correlation identifier. Blocking the professional, which XCA §4.2.2 asks as a SHOULD, stays with the contact point.
Dispensations and their discard
eHDSI SAML Profile 10.0.0 §2.5 has a professional carry at least PPD-046
for a notification on dispensation and on its discard. When the token’s
ehdsi.professional.permissions lists permissions without it, the gateway
refuses the dispensation 403 forbidden with permission-missing and
ERROR_SEC_GENERIC, and the discard with discard-refused and
ERROR_ED_DISCARD_FAILED. A token that lists no permissions is not held to
it.
A discard is admitted only within 24 hours of the dispensation and from the point of care that dispensed it (MyHealth@EU Requirements Catalogue 10.0.0 07.04; NCPeH Components Specifications 10.0.0 §3.1.6.4.2, “issued by the same HCPO”). The identity assertion names the point of care by a name alone, so the gateway matches identifiers:
- When the owner creates a contact point’s dispensation, the gateway keeps
the token’s
ehdsi.provider.healthcare_provider_identifier(the assertion’sorganization-id) and the identifier of theOrganizationthe dispensation names onperformer.actor, contained or by its logical identifier (HL7 Europe MPD 1.0.0), for 24 hours in FerroFED’s store. - A contact point’s discard must carry the same
organization-idand name the same facility onperformer.actor. One from another organisation or facility, one after 24 hours, and one of a dispensation the gateway kept no dispenser of (written by another caller, or naming no facility) is refused403 forbiddenwithdiscard-refusedandERROR_ED_DISCARD_FAILED, and the member is never asked.
Other callers’ discards are not held to this; the member decides.
- A TRC patient the cross-reference cannot resolve at every member, or a
deployment with no cross-reference, is answered
424patient-context-unavailablewith nothing sent, and logged underferrofed::securityastrc-patient-unresolvedwith the cause.
No specification governs the binding across members, the code systems the codes travel under, or the pseudonym: they are FerroFED’s own design over the XCA rule that the contact point of the country of affiliation compares the patients. The FHIR face is the contact point’s path.
The binding on the ITS-REST face
A contact point’s token is admitted to the ITS-REST face as well, and the gateway holds a patient-data request there to the same TRC patient, in the answers ITS-REST gives (openEHR ITS-REST 1.1.0; eHDSI XCA Profile 8.0.1 §4.2.1 and §4.2.2):
- A token with no
ehdsi.patient.idis refused403with the codetrc-missingon every route that reaches patient data: the query routes, the EHR API and the DEMOGRAPHIC API. A definition request reaches no patient data and needs none. - A query, ad hoc or stored, sent by
POSTorGET, must name the TRC’s patient, compared on the namespace and the value before the demographics supplier, a localizer, the consent pre-filter or any member is asked. A query scoped to oneehr_id(N29) must be scoped to one of that patient’s ownehr_ids at the member it goes to. - A request under
{base}/v1/ehr/{ehr_id}, reads and writes alike, goes only to the member where thatehr_idis the TRC patient’s own EHR, located through the TRC patient’s pairs: theehr_idindex is not read and no member is probed.GET {base}/v1/ehrby subject must name the TRC’s patient. - Another patient, an EHR of another patient, a TRC patient no member holds,
and a TRC patient every member that holds it restricts all get one answer:
404with the codesubject-unavailable, the answer an unavailable subject gets when consent exclusions are withheld, which a contact point’s request always is. Nothing in it tells the cases apart, and no member is sent anything. - A query that names no patient and is scoped to no
ehr_id, the creation of an EHR, and a DEMOGRAPHIC request name nothing a TRC binds: each is refused403with the codetrc-unbound, with nothing sent. - Every request outside the TRC’s patient is the same
trc-patient-mismatchsecurity event the FHIR face records, naming the professional and both patients by pseudonym alone.
ITS-REST has no MyHealth@EU service, so its answers carry no §1.25
*_NOT_FOUND code (Error codes).
For every request of the contact point, the gateway:
- holds the token to the MyHealth@EU contact-point profile of
ferrohealth-smart(eHDSI SAML Profile 10.0.0 §2.1, §2.3), bound to the connection’s client certificate, and refuses one that falls short before any member is asked; - withholds every consent exclusion, whatever
[federation.consent] disclosesays (Regulation (EU) 2025/327 Art 8, Art 11(5)); - writes one access record naming the foreign professional by their identifier and the provider, marked as asserted by the contact point, with the correlation identifier and no name or address (Audit);
- tells each member the foreign professional and provider in the caller’s conveyance, with no name or address (GDPR Art 5(1)(c)), and sends no patient identifier to any member (Federation Tier §5.4.1, N33).
The gateway never refuses a contact-point request because of the professional’s role or the point of care: it conveys both for the record and for the member’s information. The country of affiliation must not deny access on them (MyHealth@EU Requirements Catalogue v10.0.0, 01 and 01.02), so a member that applies its own release rule to a contact-point request must not use them either.
The emergency access limit
A health professional may ask for a patient’s data in an emergency, when the
patient cannot confirm their will, with the purpose of use EMERGENCY
(eHDSI SAML Profile 10.0.0 §2.3). eHDSI Section II Security Services 10.0.0
§2.5 has the contact point of the patient’s country provide such access
and limit it: “No more than five emergency access attempts per HP per
24-hour period”, which each country may tighten. The NCPeH Architecture
Specifications 10.0.0 §5.1.9 make an emergency access past the
country-defined threshold a deny response. Implementing Regulation (EU)
2026/2083 Art 4(1) binds the contact points to these specifications.
FerroFED applies the limit on the national side, behind the contact point,
so the limit holds even though the contact point’s own policy runs
elsewhere. Where and how the count is kept is FerroFED’s own design.
A contact point’s request is an emergency access when its token declares a
purpose of use that [[access_log.emergency_purpose]] names, the same rule
that marks the access record (Emergency
access). Declare the code your
contact point’s tokens carry:
[[access_log.emergency_purpose]]
system = "urn:uuid:3bc18518-d305-46c2-a8d6-94bd59856e9e" # the system your tokens name
code = "EMERGENCY"
[emergency]
threshold = 5 # 1 to 5: emergency accesses one professional is granted in one window
window = "1d" # up to 24 hours: the window
| Key | Default | What it sets |
|---|---|---|
threshold | 5 | how many emergency accesses one professional is granted in any window, 1 to 5 |
window | 1d | the window, at most 24 hours (1d) and at least one second |
The defaults are the §2.5 baseline. A country may only tighten it, so
config check, the start and a reload refuse a threshold above 5 or a
window above 86400, naming §2.5:
emergency.threshold is 6, above the baseline of 5 emergency access attempts per health professional per 24-hour period of eHDSI Section II Security Services 10.0.0 §2.5, which a country may only tighten
A threshold or a window of zero is refused too: §2.5 has every country
provide emergency access, so FerroFED never refuses it by default. A reload
applies a changed limit. OPTIONS {base}/ declares the limit in effect
under federation.national_contact_point.emergency, as threshold and
window, so the contact point’s operator can check what the gateway
holds its professionals to.
How the gateway counts:
- Per professional. The professional is the one the token relays: the
contact point,
country_code, the issuing authority andhp_identifier, the assertion’sNameID(Implementing Regulation (EU) 2026/2099 Annex Table 1; eHDSI SAML Profile 10.0.0 §2.1). - Every request that reaches patient data. The summary, the document list, a document, a prescription search and read, and a dispensation each count once. The patient lookup is the identification step and is never counted.
- In any window. The window slides: any span of
windowseconds holds at mostthresholdgranted accesses of one professional. A denied attempt is not counted, so the professional is granted again once the oldest access leaves the window. - Across replicas and restarts. The count lives in FerroFED’s own store
(
ferrofed.emergency_access, FerroFED’s own store), read and incremented in one statement, so replicas share one count and a restart keeps it. The table names each professional by a SHA-256 digest alone.
Past the limit the request is denied before any member is asked. On the
FHIR face the answer is 403 forbidden, whose issue.details carries the
refusal emergency-limit-reached
(https://ferrofed.eu/fhir/CodeSystem/refusal) and ERROR_SEC_GENERIC
(https://ferrofed.eu/fhir/CodeSystem/myhealth-eu-exception; eHDSI XCA
Profile 8.0.1 §2.2.3, “Security validations for the request or the
requestor failed”). Its diagnostics note that too many attempts for an
emergency access have been made, as §2.5 asks. The access record of the
denied attempt carries the emergency mark, and its outcome description
names the anomaly warning EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS
(NCPeH Architecture §5.1.9), so the contact point and the national audit
see it. The denial is also the security event emergency-limit-reached,
logged under ferrofed::security and counted in the security metrics,
naming the contact point and the professional.
When the store does not answer, an emergency access cannot be counted, so
it is refused 503 emergency-uncounted and no member is asked. A request
that is no emergency access never touches the count.
The count does not depend on the access log. A development gateway with
[audit] destination = "off", which keeps no access record, still counts
and denies a contact point’s emergency accesses: the limit is a deny
decision (NCPeH Architecture §5.1.9), apart from the access record of
Regulation (EU) 2025/327 Annex II 3.2. Such a denial carries no access
record and is still the security event emergency-limit-reached. The
[[access_log.emergency_purpose]] entries and the store are what the count
needs either way.
Failures the connector relays
The contact points exchange a common set of exceptions, and the national
answer travels with the one the contact point sends on (Requirements
Catalogue v10.0.0, §1.25). Every failure of the face is an
OperationOutcome whose status and issue type tell the connector which kind
it is:
| What happened | Status and issue type | Temporary? |
|---|---|---|
| A member, the identity binding, the access record or the store that counts emergency accesses could not answer in time or at all | 503 transient, 504 timeout | yes, the connector may ask again |
| A member answered with an error, named in the outcome | 424 incomplete | the member’s, which the outcome names |
| The owner of a prescription failed on a dispensation or could not be reached | 502 exception, the member’s own issues after the gateway’s when it sent some | unknown: read the prescription before sending again |
| The request is malformed or misses a parameter | 400 invalid | no |
| The token falls short of the MyHealth@EU contact-point profile | 403 forbidden | no, until the contact point sends what the assertion carries |
| The token names no TRC patient | 403 forbidden, trc-missing | no, until the contact point sends it |
| The professional reached the emergency access limit | 403 forbidden, emergency-limit-reached and ERROR_SEC_GENERIC | yes, once the oldest emergency access leaves the window |
| No member holds the patient, or the prescription, or the request is outside the TRC’s patient | 404 not-found, trc-patient-mismatch and the service’s *_NOT_FOUND code | no |
| The member that owns the prescription refused the dispensation | 409 conflict, the member’s own issues after the gateway’s | no |
| The gateway failed on its own | 500 exception | no |
A patient no member holds and a patient every member restricts answer alike, so the connector reports both as a patient with no data.
The §1.25 code of every refusal
Every refusal to a contact point names its Requirements Catalogue 10.0.0
§1.25 exception code, so the national connector maps it to the code NCPeH-A
answers its NCP with. An ITS-REST error body carries it in ncp_error, a
FHIR OperationOutcome in its issue.details
(https://ferrofed.eu/fhir/CodeSystem/myhealth-eu-exception), and the
access record leads its outcome description with it. No legacy numeric code
of the NCPeH Components Specifications 10.0.0 appendix 6.4 is ever issued.
| Refusal | §1.25 code |
|---|---|
| A token the contact-point profile refuses | the code ferrohealth-smart gives it: ERROR_HPI_NO_INFORMATION, ERROR_HPI_INSUFFICIENT_INFORMATION, ERROR_HPI_HAI_NO_INFORMATION, ERROR_HPI_POC_NO_INFORMATION, ERROR_HPI_AUTHENTICATION_NOT_RECEIVED or ERROR_SEC_DATA_INTEGRITY_NOT_ENSURED |
| Any other refused token, its key set unreadable included | ERROR_SEC_GENERIC, as ferrohealth-smart gives it |
A patient-data request whose token names no TRC patient (trc-missing) | ERROR_SEC_DATA_INTEGRITY_NOT_ENSURED (requirement 11.01.02), the code ferrohealth-smart gives every other missing assertion attribute |
| A scope, operation or rights refusal, a request a TRC binds nothing to, and the emergency access limit | ERROR_SEC_GENERIC |
| A member, the identity binding or a service it needs cannot answer | ERROR_REGISTRY_NOT_AVAILABLE |
| Outside the TRC’s patient, or a patient no member holds | the service’s *_NOT_FOUND |
| The gateway’s own defect, or its own record that failed | ERROR_INTERNAL_ERROR |
The gateway holds no table of its own for a refused token: the code is the
one the shared crate’s refusal carries. Which code each of the gateway’s own
refusals takes is FerroFED’s own design over the catalogue’s rows. A
restricted patient answers a contact point exactly as a patient no member
holds, code included (Regulation (EU) 2025/327 Art 8), so a contact point
never reads ERROR_NO_CONSENT.
The end-to-end test e2e::national drives these routes from the harness
national connector of tools/ferrofed-testkit over two FerroEHR nodes.
What stays with the contact point
FerroFED answers the national connector in the exchange format of the FHIR face, and does nothing on the contact point’s side of the connector. These stay with the contact point:
- Patient identification. The contact point identifies the patient
against its Member State’s identity services and asks FerroFED by the
confirmed identifier. FerroFED searches no demographics. The connector
may look the confirmed identifier up first with
GET {fhir-base}/Patient?identifier=, the patient lookup Xt-EHR D5.1 §3.4.6 asks of every producer EHR system and has a consumer use (Looking a patient up). - The eHDSI CDA and the PDF of the original. FerroFED emits no eHDSI CDA pivot document, no national CDA and no PDF. The contact point of the country of affiliation passes on a PDF of the original summary in its language (Requirements Catalogue v10.0.0, 05.02); every section of the gateway’s document carries that original information as its generated narrative, from which the contact point renders it. Regulation (EU) 2025/327 recital 26 allows different profiles at the level of EHR systems and of contact points, and the transformation to the contact point’s profile is the contact point’s. MyHealth@EU runs its Patient Summary and ePrescription services on CDA, and its FHIR guides for them are proofs of concept; the reference contact point, OpenNCP, validates and transcodes the CDA in its own Transformation Manager.
- The original clinical documents. A laboratory, discharge or imaging document or an image a member serves itself passes through the gateway byte for byte, with the media type the member sent (A member’s own laboratory reports). The gateway does not check that it is a PDF, JPG or PNG, and does not fill its header data; wrapping it for the cross-border exchange is the contact point’s (Requirements Catalogue v10.0.0, 08 and 08.01). A report the gateway writes from an openEHR composition is a new document in the exchange format, and no original.
- Transcoding and translation. Implementing Regulation (EU) 2026/2083 Art 3(4) and (5) give the mapping and translation of codes to the Member State, with the Commission’s central terminology service.
- HCP and treatment-relationship assertions, SMP and cross-border transport. They belong to the exchange between contact points, which the NCPeH API guide specifies, and to the secure network the contact points use (2026/2083 recital 6). FerroFED builds no XCPD, XCA or XDR responder and reads no SAML assertion.
- The exchange log. 2026/2083 Art 15(1)(f)(ii) puts the log of the cross-border exchange on the contact point. FerroFED keeps its own access record, which the correlation identifier joins to that log.
- The joint-controller role. Regulation (EU) 2025/327 Art 23 makes the contact points joint controllers for the processing in MyHealth@EU (2026/2083 recital 19).
The sources these decisions were checked against
The Commission has not adopted the implementing act on the exchange
format (Regulation (EU) 2025/327 Art 15(1)), and its Have your say page
(initiative 17512) lists the draft act as upcoming, with no text. The
closest published text is the Xt-EHR joint action’s deliverables D6.1
(Patient Summary) and D6.2 (electronic prescription and dispensation),
which FerroFED reads as a draft (scripts/vendor/xt-ehr.sh). Their
cross-border flow agrees with the list above: the contact point of the
patient’s country retrieves the summary from the national systems, the
patient’s identity is confirmed before the request, and transformation and
translation happen at the contact point (D6.1 §8.2.1). D6.2 §6.2 says the
existing CDA services move to FHIR over time. D5.1 has the contact point
query the producer EHR systems through the interoperability component as
a consumer (§7.1.1), and asks each producer for a patient lookup, a
document list and a summary retrieval (§3.4.6, §3.5.4, §6.6). FerroFED
caches every published Xt-EHR deliverable.
The decisions were also checked against the public MyHealth@EU material,
vendored by scripts/vendor/myhealth-eu.sh and scripts/vendor/eu.sh:
the MyHealth@EU FHIR guides (core, Patient Summary, ePrescription and
eDispensation, laboratory, discharge and imaging reports, vaccination and
the NCPeH API), the eHDSI CDA reference test documents, and the OpenNCP
Transformation Manager’s Schematrons and coded element list.
The decisions were checked again on 2026-10-29 against the MyHealth@EU
Requirements Catalogue v10.0.0 (W10-Operation Ready, 2026-03-06), which the
owner read under an EU Login reading grant: its requirements on health
professional identification (01), patient identification (02), the contact
point’s operation and data protection (03, 04), the patient summary (05),
information quality (10), security, traceability and auditability (11),
non-repudiation (12), the summary use case and the exception handling,
and on 2026-11-10 its original clinical documents (08). They
hold, with two amendments of FerroFED’s own: the gateway never refuses on
the professional’s role or point of care, and the PDF of the original is
the contact point’s, rendered from the narrative. The catalogue is
privileged-access and FerroFED has no grant to reuse it (Commission
Decision 2011/833/EU Art 2(2)(c)), so this page and docs/architecture.md
cite it by version, section and requirement id only and carry none of its
text. This is FerroFED’s own reading, not a MyHealth@EU compliance check.
The result for every requirement id, with its status and evidence, is on
MyHealth@EU self-assessment. When the Art 15(1)
act is adopted, the decisions behind this page are checked again.
Errors and status codes
When FerroFED fails a request, the HTTP status follows the table of §11.2 of the Federation Tier specification, and the body names a stable code your client can branch on. This page lists every code.
The codes are API. A code is only ever added: it is never renamed, never removed and never moved to another status, so a client written against this page keeps working across releases. A test holds this page to the gateway’s own table, so a code the gateway answers is always listed here.
The error body
A failure the gateway reports on its own behalf answers with an RFC 9457
problem details document, application/problem+json
(RFC 9457), with the status of the
code tables below:
type: the problem type, this page with the code as its fragment, such ashttps://ferrofed.eu/docs/integrate/errors.html#offset-unsupported. Each code row below carries that anchor.title: the code’s fixed summary. Its wording can change between releases, so do not parse it.status: the HTTP status, the same as the response’s.detail: a sentence for a person about this occurrence. Do not parse it either.instance: the request id as aurn:uuid:URI, when the request id is a UUID.code: the stable code, from the tables below. Branch on this member or ontype.request_id: the request id, the value of theX-Request-Idresponse header. It is your ownX-Request-Idwhen you sent one, and otherwise the gateway’s own id, the one its log and every node record. Your own id never leaves the gateway: no node receives it and the log does not record it, because the gateway cannot tell whether free text names a patient (§5.4.1, N33). To name a request the operator can find by its id, send none. A500 internalfor a request whose handler failed, a503 overloadedand a429 rate-limitedalways name the gateway’s own id here, while the header still echoes yours.
{
"type": "https://ferrofed.eu/docs/integrate/errors.html#offset-unsupported",
"title": "the query is refused",
"status": 400,
"detail": "offset-based paging is not supported across a fan-out (§11.6.2, N39)",
"instance": "urn:uuid:6f1c0b9e-3d1a-4c55-9a43-0d8f1f2b7c11",
"code": "offset-unsupported",
"request_id": "6f1c0b9e-3d1a-4c55-9a43-0d8f1f2b7c11"
}
Before FerroFED 0.0.15 the body was the openEHR ITS-REST Error with the
same code and request_id, and message where detail is now. Under the
FHIR face ([fhir]) every error stays a FHIR OperationOutcome.
No error body quotes your request: not the AQL text, not the value of a
query parameter, not a header value and not the path. A message about part of
the query points at it by byte range (bytes 52..77), and a message about a
query parameter names the parameter and never its value (§5.4.3). This holds
for every answer in the code tables below. A targeting-conflict message
names the node sets your request selected, as §8.4.1 requires, by the
registry’s own endpoint identifiers, which are membership information and
carry no patient data (§7a.2). A failed fan-out answers a result
set instead, which echoes your own query as q, as every result set does
(N17).
A failed fan-out
Under the default all-or-nothing strategy, a query fails when a node it asked
did not answer or answered with an error (§11.4, N37). That answer is not an
error body. It is the federated RESULT_SET with your q, your columns
and no rows (N17), and its meta.federation carries complete: false and
every node with its status (§11.1, §11.4):
| Status | When | The cause, in meta.federation.endpoints[] |
|---|---|---|
| 504 | a node did not answer in time, or could not be reached | time-out or offline, with the node’s error |
| 424 | a node answered with an error, or with a result the gateway cannot use (under version-identity, a version uid that is not an OBJECT_VERSION_ID) | node-error, with the node’s own failure in error |
| 424 | the cross-reference service could not answer for a member | not-resolved, with the service’s failure in error |
A node the gateway already sends as many requests as
federation.max_in_flight_per_node allows is time-out too: your request
waited for a slot until its per-node deadline and was never sent, and the
node’s error says so (§11.5, N38; Overload protection).
When both a 504 and a 424 cause occur, the answer is 504 (§11.4). With
openEHR-federation-completeness: partial on a gateway that offers it, the
same causes answer 200 with the rows of the nodes that did answer, and the
failed nodes reported the same way.
Two statuses are answers and never fail a query: not-resolved (the patient
is not known at that node) and consent-denied (a consent pre-filter dropped the
node, or the node refused with a code the registry lists for it; see
Consent). A query where no node knows
the patient answers 200 with no rows (§11.3). A deployment that declares
federation.consent.disclose: false in OPTIONS {base}/ reports a node the
pre-filter dropped as not-resolved, with the same error as a node that
does not know the patient, so there a not-resolved node may be one the
patient restricted (Withholding consent exclusions).
A node’s own answer
On a route the gateway forwards to one node, the node’s status and body pass
through as the node sent them: a node’s 404 is the node’s 404, and a
node’s 500 is the node’s 500 (§11.2). Those answers carry no FerroFED
code, because the body is the node’s. The single-node routes are the EHR
resources under a path ehr_id, {base}/v1/ehr/{ehr_id} and below it
(§7a.1), POST {base}/v1/ehr, the creation of an EHR (§12.4),
GET {base}/v1/ehr?subject_id=…&subject_namespace=…, the read of an EHR by
subject, which the gateway sends as GET {base}/v1/ehr/{ehr_id} to the one
member that resolves the subject, and every
request under {base}/v1/definition/ the stored-query registry does not
answer itself (§12.6). A template-missing
validation failure a node reports is that node’s error and passes through
unmasked (§12.6). A request under {base}/v1/demographic/ answers 501
unless the deployment declared its endpoint; then it is routed there, with
or without a header naming it, and is targeting-conflict when a header
names another endpoint (§7a.1, §12.6, N32). Every other ITS-REST path except the federated query and,
where the registry is offered, a stored query run by name answers 501 with
the code not-implemented. On a request routed by its target alone (a new
EHR or a definition request), a malformed declared
value is refused before the missing target is: parameter-value-invalid, or
the 406 or 415, comes before target-required.
Three answers on a routed request are the gateway’s, because the node gave
none of its own to pass on: node-timeout and node-unreachable (504),
and node-refused (424) when the node refused the gateway’s onward
credentials with a 401. That 401 is about the gateway’s credentials, not
yours, so it is never passed to you as a challenge. Every answer to a routed
request, these three included, names the acting endpoint in
openEHR-federation-endpoint and its node’s system_id in
openEHR-federation-system-id (N31, §9.6).
Inside a fan-out, a node’s 404 or 500 is not passed through. The node is
reported node-error, and the query fails with 424, or, under partial,
the node is reported and the query succeeds.
A fan-out template upload
Where the deployment offers fan-out template upload, an upload naming * or
several endpoints is answered per node, and no node’s body passes through
(§12.6, N43). The body is meta.federation alone, one endpoints[] entry per
registry member, as in a federated result set (§9.5):
| Status | When |
|---|---|
| 200 | every member the request named accepted the template, so complete is true |
| 207 | some members accepted and others failed: complete is false, each failed member is node-error, time-out or offline with its error, and the members that accepted keep the template (§12.6) |
| 424 | no member accepted, and every one answered with a failure (node-error, the node’s HTTP status in error) |
| 504 | no member accepted, and at least one timed out or could not be reached (§11.2) |
The provenance headers name only the members that accepted (§7a.3).
A distributed stored query
Where the deployment distributes stored-query definitions, a PUT naming
members is answered on the statuses of the table above, and the definition is
stored at the registry first whatever they are (§12.7, N44). The body is the
registry’s StoredQuery with meta.federation beside it, so even a 424 or
a 504 names the definition the registry holds.
A GET of a version naming members reports drift: 200 when every named
member’s copy matches, and 207 otherwise. A member whose copy differs, or
that holds none, is node-error with an error object whose code is
definition-differs or definition-missing. §11.1 has no status for drift,
and its set is closed, so the code says which.
Gateway codes
| Code | Status | When |
|---|---|---|
body-invalid | 400 | The request body is not the ITS-REST request the route takes, for example an AdhocQueryExecute without a string q. On a GET form of query execution, and on a stored-query PUT, it answers a query string the ITS-REST decoder refuses: a required parameter absent, a parameter given twice, a value that is not of its declared type, or a pair that does not percent-decode to UTF-8 text. |
completeness-invalid | 400 | The openEHR-federation-completeness header is repeated, or carries neither all nor partial (§11.4). |
partial-unsupported | 400 | The request asks for partial, and this gateway does not offer best-effort (§11.4, N37). |
dedup-invalid | 400 | The openEHR-federation-dedup header is repeated, or names neither none nor version-identity (§10, §7a.2). |
parameter-invalid | 400 | A query parameter is null, an array, an object, or an integer outside 64 bits. |
patient-invalid | 400 | The query’s patient identifier or namespace cannot form a patient reference (§5.2). It also answers GET {base}/v1/ehr when subject_id or subject_namespace is absent, empty or given more than once (ITS-REST 1.1.0 declares both required). |
no-destination | 404 | The request can be routed to no destination at all: node selection left no registry member in scope, or the ORGANISATION directive or the openEHR-federation-organisation header names only organisations that manage no endpoint (§11.2, §11.3). It also answers a read of an EHR resource that nothing routes, and a query scoped to one ehr_id, when every member the ask-all probe asked answered 404 (§12.5.1), and GET {base}/v1/ehr when no member, or not the member the openEHR-federation-endpoint header names, holds an EHR for the subject (a deployment that withholds consent exclusions answers subject-unavailable there instead). That is the operation’s own 404 for a subject with no EHR: it reads one EHR resource, so the 200 with no rows of §11.3, which answers a query, does not apply (ITS-REST 1.1.0, §11.2). |
ehr-id-collision | 409 | The ehr_id is claimed by more than one node: your session’s resolution bindings or the gateway’s ehr_id index hold it at two members or more, or the ask-all probe of a read, or of a query scoped to that ehr_id, found it at two members or more. The message lists the claiming endpoints. The gateway never chooses between them and sends the request, a read or a write, to neither; name the node in the openEHR-federation-endpoint header to address one of them (§12.5.1, §12.5.2, N41, N42). |
controlling-system-unreachable | 409 | A versioned write (an update of a composition, EHR_STATUS or directory, a delete, or a CONTRIBUTION amending versions) amends a version its path ehr_id’s node did not create: the registry routes that preceding version’s creating_system_id to another member, or to none. The write goes to its controlling CDR alone, and the path ehr_id belongs to the other node, so the gateway sends it to no node, the controlling node included, whether that node is up or down. A write against a row that de-duplication kept is held to the same rule. The message names the controlling system: the version’s creating_system_id as the registry spells it, the controlling node, and its endpoint. For a system the registry does not know, it names the version’s creating_system_id as your request spells it, says the controlling system is outside the federation, and points at where your request names the version (If-Match, the path, or a CONTRIBUTION version by position); it quotes no other part of your request (§10.3, §12.2, §12.4, §12a.1, N23, N36). |
internal | 500 | The gateway failed on its own side. The operator’s log records the failure under the gateway’s request id. |
not-found | 404 | The path is outside every surface the gateway serves. |
not-implemented | 501 | The request addresses a part of the ITS-REST API the gateway does not serve (§7a.1, N32). That is: every path under {base}/v1/, and OPTIONS {base}/, when no registry is configured; the DEMOGRAPHIC API under {base}/v1/demographic/, unless the deployment declares the one endpoint that serves it; running a stored query, GET or POST {base}/v1/query/{name}, where the stored-query registry is not offered; a path under {base}/v1/ that ITS-REST does not define, or a method ITS-REST does not declare for its path; and OPTIONS on a path the gateway does not serve. No node is asked. |
endpoint-unknown | 400 | The FROM ENDPOINT directive or the openEHR-federation-endpoint header names an identifier that is not an endpoint of the registry, or the header names no identifier at all (§8.4.1, N19). This holds on every request the header applies to: a query, and a request routed to one node. The message names the directive or the header, points at the identifier by its place in the list, and never quotes it. |
organisation-unknown | 400 | The ORGANISATION directive or the openEHR-federation-organisation header names an identifier that is not an organisation of the registry, or the header names no identifier at all (§8.1, §8.4.1, N20). The message points at the identifier by its place in the list and never quotes it. |
target-required | 400 | A write to an EHR resource names no node in openEHR-federation-endpoint, and neither a resolution binding of the session nor the gateway’s ehr_id index names exactly one; the gateway never finds a write’s destination by trial, so nothing is probed (§12.5.1, N41). The creation of an EHR, POST {base}/v1/ehr or PUT {base}/v1/ehr/{ehr_id}, always names its node in the header, because a new EHR has no owner for a binding or the index to name (§12.4, N23). So does every request under {base}/v1/definition/ the stored-query registry does not answer: a template lives at the node it was sent to, and the gateway never picks one for you (§12.6, N43). |
endpoint-several | 400 | A request routed to one node selects more than one endpoint through openEHR-federation-endpoint or openEHR-federation-organisation (§7a.1, §12.4). A definition request is never fanned out, so a * there is an unknown endpoint (endpoint-unknown) and two named endpoints are this error (§12.6, N43), except a template upload where fan-out template upload is offered (above), and a stored-query PUT or version GET where definition fan-out is offered beside the registry (above); so is a DEMOGRAPHIC request naming two (§7a.1, N32). |
query-parameter-refused | 400 | A request routed to one node carries a query parameter the ITS-REST operation it addresses does not declare, or subject_id or subject_namespace anywhere but on GET {base}/v1/ehr, where the gateway consumes both as resolution input. The gateway cannot tell an identifying value from any other, so it sends nothing; the message names the parameter by position, never by name or value (§5.4.1, N33). |
node-timeout | 504 | The node a request was routed to, or a member the ask-all probe asked, did not answer in time (§11.2, §11.5). A member that did not answer may hold the ehr_id, so the probe names no owner; the message names that member. |
node-unreachable | 504 | The node a request was routed to, or a member the ask-all probe asked, could not be reached (§11.2). |
node-refused | 424 | The node a request was routed to, or a member the ask-all probe asked, refused the gateway’s onward credentials (§11.2). |
targeting-conflict | 400 | The request names its node set twice, and the two sets differ: the AQL directive and a targeting header, or the endpoint header and the organisation header. The gateway never merges them and never picks one (§8.4.1, N35). The message names both sets by the registry endpoints each selects; every identifier in it is one the registry already holds. Two mechanisms that select the same set are accepted. A DEMOGRAPHIC request whose header names an endpoint other than the one the deployment declared for that area is this error too, naming both (§7a.1, N32). |
ehr-id-invalid | 400 | The ehr_id in the request path, or the one ehr_id a query is scoped to (N29), is not an openEHR HIER_OBJECT_ID, so it names no EHR and the request is not routed (§12.5). The message never quotes the value. |
node-error | 424 | A member the ask-all probe asked answered with neither a success nor 404, so whether it holds the ehr_id is unknown and the read is not served (§11.2, §12.5.1). The message names the member and its status. |
probe-requires-uuid | 400 | A read of an EHR resource, or a query scoped to one ehr_id (N29), that no targeting header, resolution binding or ehr_id index entry routes to one node has an ehr_id that is not a bare UUID: an ISO OID, an internet id, or a UUID with an extension. The gateway cannot tell such a value from a patient identifier, and the ask-all probe would send it to every member, so it asks no member anything (§5.4.1, N33, §12.5.1). Name the node in openEHR-federation-endpoint. The message never quotes the path. |
query-name-invalid | 400 | A stored query’s name is not [{namespace}::]{query-name} over a-z, A-Z, 0-9, _, . and -, or its query name is aql, which ITS-REST reserves. The message never quotes the name. |
query-version-invalid | 400 | A stored query’s version is not major.minor.patch with no leading zeros, or, where a version is looked up, not a {major} or {major}.{minor} prefix either (ITS-REST, “Qualified query name”). |
query-version-required | retired | Retired since #875: no longer raised. A definition PUT at {base}/v1/definition/query/{name} with no version is stored at a new version the registry assigns (Stored queries). Until then it was a 400. |
query-type-unsupported | 400 | A definition’s query_type is not AQL; the registry stores AQL only. |
subject-literal | 400 | A definition names its patient by a literal identifier. The registry would hold that identifier at rest, so it refuses the definition; name the patient through a $parameter and bind it in query_parameters when you invoke the query (§5.4.1, N33). |
stored-query-held | 409 | The registry already holds a definition at this name and version. A stored version is immutable, so the second PUT is refused and the held text stands; store the change as a new version (§12.7, N44). A PUT with no version answers it too when other writers stored each version the registry assigned it first, eight times, or when the newest version’s patch is the largest a version holds, so it has no next one; nothing is stored. |
stored-query-unknown | 404 | The registry holds no stored query at this name, or none at the version or version prefix the path names. |
preceding-version-invalid | 400 | A versioned write names no single version it amends: If-Match is absent, repeated, a list, *, a weak tag or unquoted, or names no OBJECT_VERSION_ID; or the path of a composition DELETE is no OBJECT_VERSION_ID; or a CONTRIBUTION body is not one, in the representation its Content-Type selects (canonical JSON, or a canonical envelope whose data is FLAT or STRUCTURED), whose every preceding_version_uid is an OBJECT_VERSION_ID. Without that version the gateway cannot find the write’s controlling CDR, so it sends nothing (ITS-REST 1.1.0 If-Match and contribution_create; §12.4, N23). |
parameter-value-invalid | 400 | A request routed to one node carries a value that is not what the ITS-REST operation declares for it: a path version_uid that is no OBJECT_VERSION_ID, a versioned_object_uid that is no UUID, a version_at_time that is no extended ISO 8601 date-time, or a detail_level outside its listed values. The gateway sends nothing; the message names the header, or the parameter by its position and declared name, and never quotes the value (§5.4.1, N33). |
media-type-not-acceptable | 406 | The Accept header of a request routed to one node admits none of the media types the ITS-REST operation answers in, as a node would answer it (RFC 9110 §12.5.1). The message lists the media types the operation offers. |
media-type-unsupported | 415 | The Content-Type header of a request routed to one node, or of a request the gateway answers itself (POST {base}/v1/query/aql, a stored query run by name, a stored-query definition PUT), is not one of the media types the ITS-REST operation takes, or carries a parameter other than charset=utf-8 (RFC 9110 §8.3, §15.5.16); the gateway sends and stores nothing. A CONTRIBUTION in canonical XML is one, answered before it is routed: contribution_create takes its body in JSON alone (ITS-REST 1.1.0 EHR API). A body sent without a Content-Type is never refused: it is read as, or travels with, the first media type the operation lists. The message lists the media types the operation takes. |
subject-several | 400 | The subject of GET {base}/v1/ehr resolves at more than one member, and no openEHR-federation-endpoint header names one of them. The gateway never chooses by where the patient resolved, so it sends the read to none; the message lists the endpoints and never the subject. Name the endpoint in the header to read that member’s EHR (§8.4, §12.5.2, N41). |
resolution-unavailable | 424 | The cross-reference service could not answer for a member while resolving the subject of GET {base}/v1/ehr, or no cross-reference service is configured. That member may hold the EHR, so the gateway answers neither its 404 nor another member’s EHR; the message names the members and never the subject (§5.2, §11.2). |
ehr-id-held | 409 | PUT {base}/v1/ehr/{ehr_id} names one member in openEHR-federation-endpoint while your session’s resolution bindings or the gateway’s ehr_id index already place that ehr_id at another member. Creating it would put one ehr_id at two members, the collision of §12.5.2, so the gateway sends the create to no node. The message names the holding endpoints and the one you named, and never quotes the ehr_id. When the member you named is the one that holds it, the create is sent there and that node answers its own 409 (ITS-REST 1.1.0 ehr_create_with_id; §12.4, §12.5.2, N23, N42). |
definition-endpoint-targeted | 400 | A stored-query PUT names members to distribute the definition to, and its AQL carries a FROM ENDPOINT or ORGANISATION directive. The directive names members of the federation, which a node cannot run, so the gateway distributes nothing and stores nothing; store it without naming members and it runs federated (§12.7, §8.1, N44). |
stored-query-fan-out-unsupported | 400 | A stored-query PUT or version GET at the registry carries openEHR-federation-endpoint or openEHR-federation-organisation, which asks for distribution to members or a per-member drift report, and the gateway offers neither (definition.stored_query_fan_out is false). The gateway stores and reads nothing, so a request for distribution is never answered as a plain one; send it without the header (§12.7, N44). |
stored-query-read-only | 405 | A stored-query PUT, with or without a version, at a gateway whose registry is read-only: it loads its definitions from files when it starts, so nothing is stored. Allow names GET, OPTIONS, the methods the registry serves there, and OPTIONS on the path lists no PUT. Read or run a definition the registry holds; a new one is added by its operator (stored-query backends) (§12.7, N44). |
stored-query-reserved | 409 | A stored-query PUT names a definition in the namespace eu.ferrofed.eehrxf, or one nested under it, in any letter case. The gateway reserves it for its own read-only queries, the patient summary’s section queries, so nothing is stored at any version and the held text stands. Store the definition under a namespace of your own (the reserved queries) (§12.7, N44). |
consent-denied | 403 | The consent pre-filter does not permit asking the members that might hold the subject of GET {base}/v1/ehr, and no other member holds an EHR for it. The gateway resolves and sends nothing there; the message names the denied endpoints and never the subject. A denied member may hold the EHR, so this is never the 404 of a subject with no EHR (N27a, §13.2.1; Consent). A deployment that withholds consent exclusions never answers it. |
subject-unavailable | 404 | Only where the deployment withholds consent exclusions (federation.consent.disclose: false): no member the gateway may read holds the EHR. It answers GET {base}/v1/ehr for a subject no member knows, one only a member the consent pre-filter denied holds, and one whose holder refused on consent grounds; a request under {base}/v1/ehr/, the creation of an EHR or a DEMOGRAPHIC request that the node refuses on consent grounds or answers 404, in place of the node’s own answer; and an ask-all probe no member answers with the EHR, whether none holds it or the holder refused. It also answers every request of a national contact point outside the patient of its treatment relationship confirmation: a query or a read by subject for another patient, an ehr_id outside that patient’s own pairs, a TRC patient no member holds and one every holder restricts, with nothing sent (eHDSI XCA Profile 8.0.1 §4.2.1, §4.2.2; National contact point). One answer for all of them never shows that a restriction exists (Regulation (EU) 2025/327 Art 8). A 404 covers each, since HTTP defines it for a resource the server did not find or “is not willing to disclose” (RFC 9110 §15.5.5). The message names no endpoint and never the subject; a routed answer still names the endpoint it was routed to in its headers (Withholding consent exclusions). |
localization-unavailable | 424 | The localizer could not answer while localizing the subject of GET {base}/v1/ehr and the deployment fails closed (federation.localization.on_failure = "closed"), or, under any policy, the XCPD exchange could not be audited (ITI TF-2 §3.55.5.1). No member was asked. A member may hold the EHR, so this is never the 404 of a subject with no EHR; the message never names the subject (N4, §14.1; Node selection). |
unauthenticated | 401 | The request carries no access token the gateway accepts: none, one in another scheme, or a token that does not verify. A token does not verify when it is no RFC 9068 access token (a claim missing, a type other than at+jwt), is signed with none, an HMAC or an algorithm its issuer does not allow, comes from an issuer the deployment does not trust, carries a signature that does not verify or a key its issuer does not publish, is past its exp or before its nbf beyond the clock skew, was issued for another audience, or is one the issuer’s introspection endpoint calls inactive (§13.1, N25). Every such token gets one challenge, error="invalid_token" with no description, so the answer does not say which check failed (RFC 6750 §3.1, RFC 9068 §4); the security log names the check (Client authentication). No node is asked. On the admin listener it also answers a write action, such as the stored-query distribution, whose caller has no token that verifies, and a scrape of GET /metrics without a client-credentials token of a metrics.scrape_clients client where one is named (Metrics). |
credential-malformed | 400 | The request carries two Authorization fields, or one that is no credential at all, such as a Bearer with no token. The challenge names error="invalid_request" (RFC 6750 §3.1). No node is asked. |
trc-missing | 403 | A national contact point’s request on the ITS-REST face reaches patient data, and its access token names no patient of a treatment relationship confirmation in ehdsi.patient.id (eHDSI SAML Profile 10.0.0 §4.3 makes the TRC’s subject-id mandatory). No member and no identity service is asked. The FHIR face answers the same case with an OperationOutcome (National contact point). |
trc-unbound | 403 | A national contact point’s request on the ITS-REST face names no patient or EHR its treatment relationship confirmation can bind: a query that names no patient and is scoped to no ehr_id, or reads beyond its one EHR; the creation of an EHR; or a DEMOGRAPHIC request (eHDSI XCA Profile 8.0.1 §4.2.1). Nothing is sent, and the request is logged as a trc-patient-mismatch security event (National contact point). |
scope-insufficient | 403 | The token is valid, and no scope it grants covers the operation, or, on the DEMOGRAPHIC API, its client is not one the issuer’s entry lists as a demographic client (Client authentication). No node is asked. On the admin listener it answers a write action whose token verifies and does not carry auth.product_scope, from any peer (Metrics). |
purpose-of-use-required | 403 | The token is valid and its scopes cover the operation, and it declares no purpose of use, which the deployment requires (§13.4). No node is asked. |
authentication-unavailable | 503 | The token’s issuer cannot be asked: its key set cannot be fetched or read, or its introspection endpoint does not answer or answers with an error. The gateway cannot verify the caller, so it admits no one; ask again later (§13.1). No node is asked. |
operation-refused | 403 | The request addresses the ADMIN API under {base}/v1/admin/, which the gateway admits no caller to, whatever its token; nothing is verified and no node is asked. |
patient-context-missing | 403 | Only a patient/ scope covers the operation, its issuer is bound to a member ([trust.issuer.patient]), and the token carries no ehrId claim, or one that is no HIER_OBJECT_ID. Without it there is no patient to confine the grant to (SMART on openEHR master07 §Context Selection; Patient grants). No node is asked. |
patient-confinement | 403 | The token’s patient/ grant is confined to one patient, and the request reaches beyond that patient’s own {node, ehr_id} pairs: a query for another patient, a query that names no patient, a query whose FROM reads a class beside its one scoped EHR (under AND or OR, a second EHR, an EHR under NOT CONTAINS), an ehr_id that is not the patient’s at the member it would go to, a read by subject of another patient, the creation of an EHR or a definition request. A token whose resource scopes are all patient/ is refused on the DEMOGRAPHIC API too, whether or not its issuer is bound and even when its client is a listed demographic client. The gateway sends nothing, never probes for the ehr_id, records no binding or index entry for another patient, asks no localizer, consent pre-filter or other member about another patient it resolves at the bound member, and the message names no ehr_id and no endpoint (§5.2, §12.5; Patient grants). |
patient-context-unavailable | 424 | The cross-reference service could not resolve the patient of the token’s patient/ grant, or the patient a national contact point’s treatment relationship confirmation binds, at every member, places a grant’s patient under another ehr_id at the member that issued the token, or no cross-reference service is configured. The request cannot be held to its patient, so the gateway sends nothing (§5.2, §11.2). |
overloaded | 503 | The gateway is serving as many requests as server.max_concurrent_requests allows, so it refused this one before reading it: no authentication, no resolver and no node. Retry-After names the seconds to wait, server.retry_after (RFC 9110 §15.6.4, §10.2.3). The health family is never refused (Overload protection). |
rate-limited | 429 | You sent more requests than [server.caller_rate] admits from one caller, counted per verified issuer and client_id, whatever address a request comes from. Retry-After names the whole seconds until your next request is admitted (RFC 6585 §4). No node is asked (Overload protection). |
access-unrecorded | 503 | The request reached patient data at a node, and the gateway could not store the record of that access in its access log, so it withholds the answer (Regulation (EU) 2025/327 Annex II 3.2; Audit). A query or a read returned nothing to you. A write may have reached the node before the record failed, so read the resource before you write again. |
natural-person-required | 401 | The request reaches patient data (a query, the EHR API or the DEMOGRAPHIC API), and the token names no natural person: its sub is its client_id, or only system/ scopes cover the operation. Unless your deployment declares the issuer’s client tokens as acting for a professional, and the token names that professional, a client token reaches no patient data (Regulation (EU) 2025/327 Annex II 3.1). The challenge carries error="insufficient_user_authentication" (RFC 9470 §3). No node is asked (Professionals and assurance). |
contact-point-attributes-required | 403 | The token’s issuer is a national contact point, and the token falls short of the MyHealth@EU contact-point profile ferrohealth-smart holds it to: it does not carry what a conforming eHDSI HP Identity Assertion and treatment relationship confirmation provide (eHDSI SAML Profile 10.0.0 §2.1, §2.3), its purpose of use is not TREATMENT or EMERGENCY, or it outlives an assertion’s window. The answer quotes no value. No node is asked (National contact points). |
emergency-limit-reached | 403 | The token’s issuer is declared a national contact point, the token declares an emergency purpose of use ([[access_log.emergency_purpose]]), and the health professional it relays reached the limit of emergency accesses in the window, five per 24 hours unless [emergency] tightens it. Too many attempts for an emergency access have been made, so the access is denied (eHDSI Section II Security Services 10.0.0 §2.5; NCPeH Architecture Specifications 10.0.0 §5.1.9). On the FHIR face the outcome names ERROR_SEC_GENERIC. The access record carries the emergency mark and the warning EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS. No node is asked (The emergency access limit). |
emergency-uncounted | 503 | A national contact point’s emergency access could not be counted against the professional’s limit, because FerroFED’s own store did not answer, so it is refused; ask again later. No node is asked (FerroFED’s own store). |
correlation-invalid | 400 | The token’s issuer is declared a national contact point, and the correlation header it declares is sent twice, empty, longer than 128 bytes, or with a byte outside visible ASCII. No node is asked (National contact points). |
authentication-assurance-insufficient | 401 | The request reaches patient data, the token’s issuer requires an authentication assurance level, and the token states none at that level or above it: the claim is missing, carries a value the deployment declares at no level, or carries a lower level. Authenticate again at the level the deployment requires. The challenge carries error="insufficient_user_authentication" (RFC 9470 §3). No node is asked (Professionals and assurance). |
configuration-invalid | 422 | Under config.ui = "managed", a configuration revision saved, applied or restored on the admin listener is refused: its TOML does not read, it sets a key of the bootstrap core, it holds a secret inline where only a _file reference is kept, its reason is empty, config check would refuse the configuration it makes with the file’s core, or the reload refused it. The message carries the error ferrofed config check reports, which names the key and never its value. Nothing is stored, and the running configuration stays (Configuration revisions). |
revision-stale | 409 | A save on the admin listener names a base that is not the newest revision, an apply names a revision a later one supersedes or the one already running, or a rollback names a running revision that is no longer running. Another operator or replica changed the revisions first, and nothing changed; read them again (Configuration revisions). |
revision-unknown | 404 | A configuration revision route on the admin listener names a number the store holds no revision under (Configuration revisions). |
rollback-unavailable | 409 | A rollback on the admin listener asks for the revision before the running one, and none ran before it (Configuration revisions). |
resource-file-owned | 409 | A change on the admin listener names a member the registry document provides, or a care services directory, or a stored-query version the read-only definition files provide. Only that source changes it, and nothing is saved (Console-managed resources). |
resource-unknown | 404 | The removal of a member on the admin listener names a node_id the newest configuration revision holds no console member under (Console-managed resources). |
approval-required | 409 | An apply on the admin listener names a configuration revision that changes a resource config.approval names, and no operator other than its author approved it. Nothing changes (Second-operator approval). |
approval-own-revision | 403 | The author of a configuration revision approves it on the admin listener; only another operator does (Second-operator approval). |
approval-not-required | 409 | An approval on the admin listener names a configuration revision that changes no resource config.approval names (Second-operator approval). |
operator-unverified | 403 | A confirmation or an approval on the admin listener comes from a caller the client authentication did not verify, such as a loopback peer under the development profile: each records the operator who gave it (Commit-confirm). |
confirmation-unavailable | 409 | A confirmation on the admin listener names a configuration revision that does not run awaiting one, or whose deadline passed, so the gateway rolls it back (Commit-confirm). |
confirmation-pending | 409 | An apply on the admin listener while the running configuration revision awaits its confirmation: confirm it or roll it back first (Commit-confirm). |
secret-store-unavailable | 409 | A change on the admin listener sends a secret value while config.secrets_dir is unset, so the gateway writes no secret file; nothing is saved. Set the secret by a reference to a file (Secrets are write-only). |
configuration-unmanaged | 404 | GET /admin/config on the admin listener of a gateway whose configuration is not managed (config.ui = "off"): it serves no configuration revisions, and the file alone configures it (Configuration revisions). |
On the client listener the gateway answers 409 with five codes, and none
of them sends anything to a node:
ehr-id-collision: two members claim oneehr_id, on a read or a write. The gateway also raises an integrity incident for the federation operator, because two nodes holding oneehr_idis a defect in the federation (§12.5.2, N42).ehr-id-held: a new EHR would take anehr_idanother member already holds. The refusal keeps the collision from arising, so it raises no integrity incident; the gateway logs a warning naming the endpoints (§12.4, §12.5.2).controlling-system-unreachable: a versioned write would be committed at a node other than its controlling CDR, which would fork the object (§10.3, N23).stored-query-held: the registry already holds the stored query’s name and version, and a stored version is immutable (§12.7, N44).stored-query-reserved: the stored query’s namespace is the one the gateway reserves for its own read-only queries (§12.7, N44).
subject-several is a 400, never a 409: the subject of
GET {base}/v1/ehr resolving at more than one member is no integrity
defect, because a patient may have an EHR at several members, each under its
own ehr_id. The read by subject returns one EHR, so the gateway asks you to
name the member, as N41 has it when no one node is found (§12.5.2).
Query refusals
The gateway refuses a query it cannot answer correctly before it sends
anything to a node (§5.4.1, §7.1, §11.6). Every refusal is a 400.
Other answers
Two answers come from the HTTP layer around the gateway and carry no code:
408 when a request runs past the server’s request timeout, and 413 when
the request body is over the configured size limit. Each is an
about:blank problem (RFC 9457 §4.2.1), whose title is the status phrase,
with a fixed detail and the gateway’s request id as instance.
Instructions for use
These are FerroFED’s instructions for use under Regulation (EU) 2025/327 on the European Health Data Space (the EHDS Regulation). Art 30(1)(d) has every EHR system accompanied, free of charge, by “clear and complete instructions for use”; Art 32(2)(d) has them cover “its maintenance, in accessible formats”; and Annex III, point 1(j), puts “the instructions for use for the user and, where applicable, installation instructions” in the technical documentation. Annex II, point 1.2, asks that the system “can be supplied and installed, taking into account the instructions and information provided by the manufacturer, without adversely affecting its characteristics and performance during its intended use”.
This page is the entry point. It says what to do in order and links the
reference page that holds each detail. The
information sheet names the
manufacturer, the version, the intended purpose, the data categories and the
standards. Every quotation is from the Official Journal text, vendored at
docs/specs/eu-ehds/reg-eu-2025-327-en.xhtml.
Who reads which part
| You are | Read |
|---|---|
| The operator who installs and runs a deployment | every section below |
| The developer of a client application clinicians use | Intended use, Reading an answer safely, Limitations, and The client contract |
| A deployment’s data protection officer or counsel | Configuration for a deployment in the EU, Limitations, Data protection and Regulatory status |
Intended use
FerroFED is used by healthcare providers, through the client applications their health professionals use in patient care, to read and write a patient’s openEHR records across the clinical data repositories (CDRs) of a federation. It answers an ordinary AQL query with one result set that names the CDR each row came from, and routes follow-up reads and writes to the CDR that holds the record (Intended purpose).
Use it only for that. FerroFED computes no score, flags no finding and recommends nothing. A client application, a stored query an operator publishes, or a change to FerroFED’s code can bring a purpose of its own, which is outside the manufacturer’s intended purpose and is assessed on its own (The medical device question).
Before you install
- Check the platform. FerroFED runs on Linux,
x86_64oraarch64, as a container image or a static binary; nothing is built for Windows (Supported platforms). The gateway holds little in memory: about 2 MiB at rest in the measurement on Memory footprint. Each concurrent query holds its members’ answers, up tofederation.max_node_answer_sizeeach (The answer bound), so size memory from the sizing below. - Gather the services it consumes: two or more openEHR CDRs that serve ITS-REST 1.1.0, an identity provider for your callers, a PIX Manager, and an Audit Record Repository with the FHIR Feed of ITI-20 (What you need).
- Agree the conditions of membership with each CDR’s operator: a unique
system_id, version-4 UUIDehr_ids never reused or adopted, eachehr_idfed to the PIX Manager, and consent enforced at the CDR (Preparing each CDR).
Installation
Follow A production deployment in order. Its steps are the installation instructions:
- Verify the image or binary against its attestation and pin it by digest (Install).
- Write the registry with every endpoint suspended (The registry).
- Trust your callers’ issuer (Client authentication).
- Give each member its onward credential and make the signing key (Onward credentials).
- Configure identity resolution (Identity resolution).
- Send the access records to your Audit Record Repository (The access log).
- Put TLS in front of the gateway, or on its listener (TLS and the public address).
- Run
config check, start the gateway, and admit each member withferrofed admission checkbefore you take its endpoint out of suspension (Check, start and admit). - Send a first query and read its
meta.federation(The first federated query).
ferrofed config check reads the configuration exactly as serve does and
exits 78, naming the key, on anything it refuses. Run it before every
start and every upgrade. Work through the hardening checklist
before the gateway reaches real patient data.
Configuration for a deployment in the EU
The settings below decide whether a deployment meets the EHDS Regulation where the gateway can meet it. Add the ones the example shows to the configuration of the production guide, and work through each item of the list after it:
# ferrofed.toml
[federation.consent]
disclose = false # Art 8: a restriction is never shown to a provider
[[auth.issuer]] # the issuer of the production guide's step 4
issuer = "https://idp.example.org/realms/ferrofed"
kind = "persons"
[auth.issuer.assurance]
claim = "acr"
minimum = "substantial" # Annex II 3.1; "high" for cross-border from 26 March 2032
levels = { "urn:example:loa:substantial" = "substantial", "urn:example:loa:high" = "high" }
[audit]
destination = "repository" # the access records of Annex II 3.2
[access_log.templates]
"Example Lab Report.v1" = ["Laboratory-Reports"]
"Example Discharge.v1" = ["Discharge-Reports"]
[access_log.retention]
years = 3 # Art 9(2): at least three years from each access
-
[federation.consent] disclose = false. Art 8 reads: “The fact that a natural person has restricted access … shall not be visible to healthcare providers.” The default,true, reports a member a consent pre-filter excludes asconsent-denied, which the Federation Tier requires (N27a). In the EU setfalse(Withholding consent exclusions). -
The assurance level, per issuer. Annex II, point 3.1, asks for “reliable mechanisms for the identification and authentication of health professionals”. Set
[auth.issuer.assurance]for every issuer whose tokens reach patient data, withminimum = "substantial"; Implementing Regulation (EU) 2026/2099 Art 6(3) raises a cross-border exchange to"high"from 26 March 2032. List the values your issuer writes; no specification says whichacrvalues stand for which level (The assurance level). -
The access records to a repository. Every access to patient data is recorded with the verified caller, the patient, the categories and the origins, and an access whose record cannot be stored is refused
503 access-unrecorded(The access log). -
A category map. FerroFED ships none. Author one in
[access_log]from the templates your members hold, or every access is recordedehds-unclassified([access_log]). -
The retention periods. Art 9(2) keeps the information on each access “for at least three years from each date of access”. Declare in
[access_log.retention]any longer period your national law sets for a category or an origin, and keep each record at your repository until itsehds-retention-ends(How long a record is kept). -
The patient namespaces. Art 9(1) gives the person information on “any access”. Name in
[access_log] patient_namespacesthe namespaces your Member State’s access service searches the log by, so the record of a request addressed byehr_idnames the patient (The patient behind anehr_id), and setmax_named_ehrsto the mostehr_ids the record of a query over many patients’ data names (A query over many patients’ data). -
The emergency purposes. Art 11(5) asks that an access to restricted data in the vital interests of the patient be logged “in a clear and understandable format”. FerroFED names no emergency purpose of use. Declare in
[[access_log.emergency_purpose]]the codes your issuers put in the token for it, such as the HL7 v3ActReasoncodeBTG, or no access is marked (Emergency access). -
The receiving members. Annex II, points 2.2 and 2.3, ask that an EHR system “be able to receive” data in the European exchange format. Declare in
[[fhir.receive]]the member that stores each category you receive; a document of a category with no member declared is refused (Configuring the face). This release receives patient summaries and laboratory reports as documents (A laboratory report), and a dispensation is written to the member that owns its prescription (Receiving a dispensation).GET {fhir-base}/metadatanames the categories your deployment receives and those it has no receiving member for, and declares noBundlecreate when it receives none (The categories received). -
The prescription and dispensation members. Declare in
[[fhir.serve]]only members that meet the contract FerroFED relies on, because the gateway computes no dispensability, counts no remaining dispensations and holds no lock (The member contract). Each such member states each prescription’s dispensing status and the dispensations it has left, holds the prescriber’s signature, and is the member that created the prescription, never one holding a copy. It refuses a dispensation its own state no longer allows, and it refuses a second dispensation carrying a dispense identifier (MedicationDispense.identifier) it already holds, so a pharmacy that sends a dispensation again after a time-out cannot have it written twice. Ask each member’s operator to confirm this at admission. Setreceive_namespacesunder[fhir]once dispensations are served: it names the identifier systems a dispensation’s patient is resolved in. A search follows at most 64 pages of each member’s answer.
Keep two defaults, which the claims review assessed against Annex II, point 2.5:
- Keep
[federation] best_effortat its default,true. Withfalse, a client can no longer ask for a partial answer, so one silent member blocks every answer (Completeness). - List each member’s consent refusal codes. A member’s consent refusal
fails the whole query
424until its codes are in the registry’sconsent_refusal_codesfor that endpoint (Consent). Ask each member’s operator for them at admission.
Sizing
The budgets and limits bound how long one request or one slow member can hold the gateway. Set too low, they delay access; set too high, one slow member holds the clinician’s answer. Size them from what you measure:
| Setting | Default | How to size it |
|---|---|---|
federation.per_node_timeout | 10s | above the slowest member’s 99th percentile on ferrofed_node_request_duration_seconds, measured under your load (Metrics) |
federation.overall_timeout | 25s | above per_node_timeout plus the resolver’s and the localizer’s time on their duration histograms (Timeouts) |
server.request_timeout | 30s | more than one second above overall_timeout; config check refuses less |
server.max_concurrent_requests | 512 | the queries your members can take at once divided by the number of members each query asks (The concurrency limit) |
federation.max_in_flight_per_node | 64 | the requests the smallest member can serve at once; past it a member is reported time-out (The per-member cap) |
[server.caller_rate] | off | set only where one caller can crowd out others (The per-caller rate) |
After a change, watch ferrofed_overload_refusals_total and the
FerroFEDOverloaded, FerroFEDMemberCapSaturated and FerroFEDSlowAnswers
alerts (Dashboard and alert rules).
Reading an answer safely
These are the instructions a client application passes on to the clinicians who use it. Each one is a control of the clinical safety risk file.
- Read
meta.federation.completebefore the rows.falsemeans a member that may hold the patient’s data contributed nothing: it was not resolved, it was excluded on consent, or, under a partial answer, it did not answer. Show that the answer is incomplete, and name the members that did not contribute;meta.federation.endpointslists each with its status (What a client gets back). - Ask for a partial answer only where an incomplete record is safe to
show. By default a member that does not answer fails the query with no
rows.
openEHR-federation-completeness: partialreturns the other members’ rows withcomplete: false(Completeness). - Expect the same fact from two members. Two CDRs can hold the same entry, for example a medication both recorded. The gateway returns both rows, each with its endpoint, unless the client asks for de-duplication by version identity, which removes only copies of one version. Show the origin of each row, and never add up quantities across members without checking for duplicates.
- Show the origin. The
openEHR-federation-endpointresponse header names the members that contributed rows, and a query that selects theENDPOINTattributes gets each row’s origin in the row (Provenance columns). A follow-up read or write goes to the CDR that holds the record. - Treat
not-resolvedas unknown. In a deployment withdisclose = false, a member the patient restricted reads exactly as a member that does not know the patient. Neither means the patient has no data there. - Read a
503 access-unrecordedafter a write as an unknown outcome. The node may have stored the write before the gateway failed to record the access. Read the resource before writing it again (Failing closed). - Read a
504to a dispensation as an unknown outcome, and a502that says the owner could not be reached. The owner of the prescription did not answer, and the gateway never sends a dispensation again. Ask the member, or read the prescription’s status, before you send the dispensation once more, and send it with the same dispense identifier, which the member refuses when it already holds it (Every answer).
Maintenance and its frequency
Art 30(1)(k) has the manufacturer inform users “of any mandatory preventive maintenance of the EHR systems and its frequency”. This release sets no maintenance on a fixed calendar. The maintenance it needs is triggered by an event, and its frequency is the event’s:
| Maintenance | When | Reference |
|---|---|---|
| Upgrade to the newest release | at each release, and as soon as you can after a security advisory: only the newest release is supported, and a security fix ships only in a new release | Upgrading, SECURITY.md |
Run config check | before every start, reload and upgrade | Running it |
Re-run ferrofed admission check for a member | when the member changes its CDR product, its version, its system_id or how it creates ehr_ids | Admitting a node |
| Update the category map | when a member adds or changes a template | [access_log] |
| Watch the alerts | continuously, on the shipped alert rules | Dashboard and alert rules |
| Clear a quarantined audit record | when ferrofed_audit_quarantined rises, after reading why the repository refused it | The audit spool |
| Rotate the signing key | on your key policy’s schedule, and at once when it may have leaked | Rotating the signing key |
| Rotate a machine client’s secret | on your credential policy’s schedule, and at once when it may have leaked: rotate the client secret of the Prometheus scraper or the PMIR Registry at its issuer and give it the new one | metrics.scrape_clients, pmir.feed_clients |
| Renew TLS certificates and onward credentials | before each expires; send SIGHUP after replacing a listener’s certificate files, or set the listener’s reload_interval so the gateway reads them again on that period | Hardening, TLS on the listeners |
Clear a held ehr_id collision | once the member at fault has fixed it, on each replica that holds it | Clearing a held collision |
| Move to the newest release | when a new release is published, because an earlier release receives no fix; once the end date of your release in SECURITY.md has passed, the startup banner and config check say so as well | SECURITY.md, The startup banner |
| Confirm an applied change to an onward credential or a binding section | under config.ui = "managed", within config.confirm (120000 ms by default) of the apply, once the gateway works with it; unconfirmed, it is rolled back | Commit-confirm |
| Bring a replica back in step | when FerroFEDConfigurationDrift fires: fix what the replica refused, then send it SIGHUP or apply a newer revision | Several replicas |
| Back up the configuration revisions | under config.ui = "managed", with the stored-query store and before every upgrade: the redb file or the PostgreSQL database, and the files of config.secrets_dir | Turning it on |
| Confirm a prescription member’s contract | at admission, and when a member declared in [[fhir.serve]] changes its CDR product or version | The member contract |
| Settle a prescription identifier collision | when the PrescriptionIdentifierCollision integrity incident is raised: two members hold one prescription identifier, and every dispensation for it is refused 409, so agree with both members’ operators which one owns it | Every answer, Integrity incidents |
| Export the identity state | before a system that replaces FerroFED takes over, on each replica | Exporting the identity state, Decommissioning |
| Keep each receiving category’s packages complete | when you move a [[fhir.receive]] package or one of its dependencies to a new version: name every package of its dependency closure in dependencies, which config check holds to the manifests | Receiving a document |
| Keep the PIX Manager fed | continuously: the gateway keeps no patient index of its own, so a patient whose records a member has not fed to the PIX Manager over ITI-104, or whose binding the PIX Manager has dropped, resolves at no member and answers as not found | Identity resolution |
Move the gateway’s keys out of [server] | before you upgrade to 0.0.16: this release refuses server.public_url, server.replica, server.bindings_drain_timeout and server.overload_retry_after_s as unknown keys, so write them as gateway.public_url, gateway.replica, gateway.bindings_drain_timeout and server.retry_after | Upgrading |
| Keep the console’s identity provider reachable at its start | when the console’s [oidc] names issuer alone: the console reads the provider’s endpoints from its discovery document when it starts and refuses to start without it; name authorization_endpoint, token_endpoint and jwks_uri to start without asking the provider | Upgrading |
| Move the keys 0.0.20 removed, and declare each issuer’s attributes | before you upgrade to 0.0.20: an [[auth.attributes]] table for every issuer whose callers declare a purpose of use, without which each such caller is refused purpose-of-use-required, and [[trust.issuer]] professional_issuing_authority moved into that table; an imaging receiver’s template at FerroBRIDGE v0.0.24 with CLUSTER.organisation.v1; the keys 0.0.19 refused by name are now refused as unknown keys, and config check names each one | Upgrading |
| Move the keys 0.0.19 renamed or moved | before you upgrade to 0.0.19: every span of time and amount of data under a key that names no unit, the outbound tables’ address, TLS and credential under each endpoint table, and the node credentials nested under credentials; the keys 0.0.18 refused by name are now refused as unknown keys, and config check names each one | Upgrading |
| Move the keys 0.0.18 renamed or removed | before you upgrade to 0.0.18: this release refuses each one at start-up and in config check, naming its replacement, among them [auth] issuer keys moved onto each [[auth.issuer]], [[auth.basic.users]], [stored_queries] url, [trust.issuer.national_contact_point], a node’s oauth2 and fapi2 tables and every outbound table’s TLS keys outside its tls sub-table | Upgrading |
| Back up FerroFED’s own store | when [store] is set, before every upgrade: dump the ferrofed schema of its PostgreSQL database, which holds the stored queries, the emergency access counts, the DPoP proofs and a contact point’s dispensations; an older release refuses a store a newer one migrated | FerroFED’s own store |
| Rotate a node grant’s private key | on your key policy’s schedule, and at once when it may have leaked: name the next key in the grant’s next_private_key_file, register its kid at the node’s authorization server, then move it to private_key_file and keep the old one as previous_private_key_file until the node no longer holds an assertion signed with it | Signing keys and the JWK Set |
| Re-read this page and the information sheet | at each release | the changelog |
When a release makes maintenance mandatory, the manufacturer says so in its upgrade notes and through the channels of Complaints and incidents.
Limitations
Art 28(b) forbids “failing to inform the professional user of likely limitations related to interoperability or security features of the EHR system in relation to its intended purpose”. The full list is Limitations; the ones a deployment in the EU must plan for are:
- The European exchange format covers four of the six categories, and
receives three. The gateway serves the patient summary on its FHIR R4
face and receives patient summaries there (Annex II, points 2.1 to 2.3).
It receives laboratory reports at the member you declare for them and
lists each member’s, with a member that serves its own reports listed as
it holds them and read through the gateway
(Laboratory reports over FHIR).
The record that member keeps holds a received result’s normal and typed
reference ranges and the order filler’s identifier, but a range text,
Observation.basedOnand an order named by reference alone only in the original document (hazard I-16); the report the gateway writes carries each range the member records and the order (hazard I-18). It serves prescriptions, and receives dispensations, through members that serve both in the HL7 Europe MPD profiles themselves or, in the map mode, from their openEHR medication orders, and computes no dispensability, remaining dispensations or lock of its own (Prescriptions and dispensations over FHIR; hazard I-17). The patient summary takes a member’s current medication from its medication orders and its vaccinations from medication actions under a vaccination list section; record current medication as orders, since a medication action under another section reaches no section (hazard I-03). It writes discharge and imaging reports from a member’s composition and lists them, with a member’s own listed as it holds them; a report it writes carries what the mapping reads, and names by its openEHR path each element the mapping does not read, such as a medicine’s timing or an imaging finding (hazards I-19 and I-20; Discharge reports over FHIR, Imaging reports over FHIR). It receives discharge reports; the record the member keeps holds a received report’s diagnoses, procedures and discharge medicines, but its hospital course and every section the template holds no place for only in the original document. The answer declares each of them to the sender as not carried, and a reader of the member’s record is told of none (hazard I-21). It receives an imaging report the same way, keeping its study, its impression and its performing organization in the member’s composition but its report identifier, a performer that is no organization and each section’s narrative only in the original document, declared to the sender and told to no reader of the member’s record (hazard I-22; The categories received). The open hazards of the interoperability component are in the clinical safety risk file. - The logging component is read at your Audit Record Repository. The
gateway has no review route of its own: the records are read there with
ITI-81, and the repository sets who may read them and keeps each for the
period it states (Annex II, points 3.3 and 3.4;
Reading the log). A query over many
patients’ data names the patients behind the
ehr_ids its delivered rows project, up to[access_log] max_named_ehrs; a query that projects noehr_idrecords only its scope, so a search by the person’s identifier does not find it (A query over many patients’ data). The gateway offers the person no log access interface of its own: the access service finds a person’s records at the repository with ITI-81 by their identifier. Each record carries a security label per category and per origin, and the access rights by origin and category hold only once you configure your repository to apply them (Labelling the access records). - The access record names the professional only as the token does. It
records the professional’s identifier and the assurance level when your
issuer’s tokens state them, and an assurance level only for an issuer
with
[auth.issuer.assurance](The person behind an access). - A received document is checked only as far as the validator can
evaluate it. Every invariant, binding and entry profile is evaluated,
and a constraint the validator cannot evaluate (an invariant whose
expression calls a function it cannot answer, a value set no package
carries, a reference outside the document) is listed in the outcome of the
write as a
warning, not refused. Arequiredbinding the packages cannot decide is asked of your terminology server; with no server configured it is listed as not evaluated (The check against the profiles). - A code in no preferred code system is translated only through your
terminology server. With
[eehrxf.terminology]set, the patient summary the gateway writes adds eachequivalentorequaltranslation after the original coding, and a translation the server refuses or does not answer fails the summary. With no server configured, the summary keeps the code as recorded, as the eHealth Network Guidelines on Patient Summary, Release 3.4, Art 10(7) allow as an interim solution (The terminology server). - Each replica holds its own resolution bindings. Behind several
replicas, a follow-up write that names no node is refused
400target-requiredby a replica that did not resolve the patient. SendopenEHR-federation-endpointon every write, or keep each client on one replica (Writes through a gateway with several replicas). - The console changes only what the file leaves to it. Under
config.ui = "managed", the bootstrap core (profile,[server],[metrics],[auth],[trust]and[config]) and client authentication stay in the file, and a member, credential or binding section the file provides is read-only in the console. The console reads one replica at a time, and each replica keeps its own incidents and routing table (Configuration revisions, The operator console). - No EU declaration of conformity has been drawn up, and no release carries the CE marking (Regulatory status).
- The Federation Tier specification is release 1.0.1, the erratum of 2026-11-01 to the 1.0.0 release the openEHR SEC approved on 2026-10-12. Builds up to v0.0.15 implement 1.0.0, and builds up to v0.0.9 the 0.9.0 release candidate.
Accessible formats
Recital 37 asks for instructions “in accessible formats for persons with
disabilities”. These instructions are HTML text with headings, lists and
tables with header rows; no step is carried by an image or by colour
alone. The source is plain Markdown,
website/book/src/operate/instructions-for-use.md,
and the book’s print page renders every page as one document. Ask the
single point of contact for
another format.
Problems and complaints
When the gateway refuses to start, answers with an error code, or logs a failure, look the symptom up on Troubleshooting: it leads from the status, the code or the log line to the cause and the setting that fixes it.
Report a problem, a complaint or a possible serious incident through the channels on Complaints and incidents. Never send patient data.
A production deployment
This page takes you from nothing to a first federated query over two openEHR CDRs you already run. It walks the steps in order and links the reference page for each, so read those for every key and every edge case. The specification leaves deployment to each federation, so no specification governs this page: our own design.
Every value below is a placeholder: hosts under example.org, identifiers
in the urn:oid:2.999 example arc. The two members are cdr-a and cdr-b,
and the gateway’s public base URL is https://gateway.example.org/fed.
scripts/checks/production-guide.sh assembles every TOML block on this
page into one ferrofed.toml and one registry.toml, runs
ferrofed config check over them, and serves them behind the shipped
reverse proxy. Each block opens with a comment naming the file it belongs
in.
1. What you need
| What | Why | Reference |
|---|---|---|
| Two openEHR CDRs that serve ITS-REST 1.1.0, ad hoc AQL included | the members; each answers AQL scoped to one ehr_id | What FerroFED runs beside |
| An identity provider for your callers | every caller presents an RFC 9068 access token; step 4 configures Keycloak | Client authentication |
| A PIX Manager | resolves each patient to the ehr_id each member holds | Identity resolution |
| An Audit Record Repository with the FHIR Feed of ITI-20 | takes the audit and access records | The audit trail |
| A Linux host with Docker Compose, or a Kubernetes cluster | runs the published image | The container image |
| A DNS name and a TLS certificate for the gateway, and nginx or another reverse proxy | the public address clients, nodes and identity services reach | step 8 |
| Prometheus, optional | scrapes the admin listener | Metrics |
Decide three values before you start, because several settings repeat them:
- the public base URL, here
https://gateway.example.org/fed: its path isserver.base_path, andgateway.public_urlnames it once, from which each issuer’saudience, the JWK Set’s URL and the PMIR feed’s URL follow (The public base URL); - the federation id,
federation.id, whichOPTIONS {base}/reports; - each member’s node id and endpoint id, which the registry, the PIX Manager’s domains and the onward credentials all key on.
Prepare each CDR
Agree the conditions of membership and what the gateway needs to reach each member with the member’s operator before you admit it: the checklist is Preparing each CDR.
2. Install
The release lane attests every image and every binary. Verify what you pull before you run it, with the GitHub CLI:
gh attestation verify oci://ghcr.io/ferrohealth/ferrofed:X.Y.Z \
--repo FerroHEALTH/FerroFED \
--signer-workflow FerroHEALTH/FerroFED/.github/workflows/release-image.yml
Then pin the image by the digest you verified, ghcr.io/ferrohealth/ferrofed@sha256:…;
docker pull prints it on its Digest: line. For a binary, verify the
tarball against release-build.yml instead
(The release binaries).
Pick one layout. Both mount the configuration at /etc/ferrofed/, the
secrets at /run/secrets/ferrofed/ and a durable volume at
/var/lib/ferrofed/, the paths every block on this page uses:
- One host: the release’s
compose.yaml,ferrofed.tomlandregistry.toml(The gateway from a release). The gateway’s port is published on127.0.0.1:8080, where the reverse proxy of step 8 reaches it. - Kubernetes: the kustomize package each release attaches, built from
deploy/kubernetes/(The Kubernetes package). Run the reverse proxy as a container in the same pod, or put your ingress in front of the Service with TLS on the gateway’s listener.
Edit the release’s example files as you go through the steps below. The gateway itself, and the address the proxy passes to:
# ferrofed.toml
profile = "production"
[server]
listen = "127.0.0.1:8080"
base_path = "/fed"
trusted_proxies = ["127.0.0.1"]
forwarded_header = "x-forwarded-for"
drain_delay = "5s"
shutdown_timeout = "30s"
[gateway]
public_url = "https://gateway.example.org/fed"
[telemetry]
format = "json"
[federation]
id = "example-federation"
node_selection = "ask-all"
per_node_timeout = "10s"
overall_timeout = "25s"
The image sets FERROFED__SERVER__LISTEN=0.0.0.0:8080, which overrides
listen inside the container; the published port decides who reaches it.
trusted_proxies and forwarded_header take the client’s address from the
reverse proxy of step 8 on loopback, and
from no one else
(Behind a reverse proxy).
drain_delay keeps the listener open while a load balancer stops
routing to a stopping gateway
(Stopping without dropping a request).
3. The registry
The registry document names the members: one [[organisation]] per
operator, one [[node]] per CDR with its system_id, and one
[[endpoint]] per ITS-REST base URL
(The registry). Add each endpoint suspended: the gateway
sends a suspended endpoint nothing, and the admission check of
step 9 still reaches it.
# registry.toml
[[organisation]]
id = "org-a"
name = "Example organisation A"
[[organisation]]
id = "org-b"
name = "Example organisation B"
[[node]]
id = "cdr-a"
organisation = "org-a"
system_id = "cdr-a.example.org"
[[node]]
id = "cdr-b"
organisation = "org-b"
system_id = "cdr-b.example.org"
[[endpoint]]
id = "cdr-a-query"
node = "cdr-a"
url = "https://cdr-a.example.org/openehr"
connection_type = "openehr-rest-query"
managing_organisation = "org-a"
status = "suspended"
[[endpoint]]
id = "cdr-b-query"
node = "cdr-b"
url = "https://cdr-b.example.org/openehr"
connection_type = "openehr-rest-query"
managing_organisation = "org-b"
status = "suspended"
# ferrofed.toml
[registry]
document = "/etc/ferrofed/registry.toml"
format = "toml"
The registry refuses a second member with the system_id of another, and
reloads on SIGHUP without a restart
(Reloading the registry).
4. Client authentication
Every request to {base}/v1/ and OPTIONS {base}/ carries an access
token the gateway verifies before it reads anything else
(Client authentication). The token must be an RFC 9068
access token:
- its JOSE header names the type
at+jwt; - it carries
iss,exp,aud,sub,client_id,iatandjti, andaudnames the issuer’saudience, which isgateway.public_urlunless you set it; - its
scopeholds the SMART on openEHR scopes of each operation, such asuser/aql-*.sfor a federated query (Scopes per route); - it declares a purpose of use, as
extensions.ihe_iua.purpose_of_use, an array of FHIRCoding, or as RFC 9396authorization_details(Purpose of use).
An issuer recipe: Keycloak
Keycloak 26.2 added a client setting that types its access tokens at+jwt.
The recipe below was run on 2026-10-06 against Keycloak 26.8.0: the gateway
admitted a user’s token made this way and answered a federated query for
it, and refused the reporting service’s client-credentials token as naming
no natural person. With the at+jwt setting off, it refused the same user’s
token as “not typed at+jwt”.
Run it with Keycloak’s admin CLI, kcadm.sh, against your Keycloak’s
public address. It creates a realm, one client scope per SMART on openEHR
scope, a clinical application that signs users in, and a reporting service
that uses the client-credentials grant. The realm’s acr.loa.map names the
levels of Keycloak’s authentication flows, so its tokens carry acr as
urn:example:loa:substantial for level 1 and urn:example:loa:high for
level 2, the values the gateway’s [auth.issuer.assurance] reads below.
Keycloak 26.8.0 wrote level 1 into a token after a password sign-in and
into a client-credentials token. A real realm reaches each level only by a
flow whose means meets it, which Regulation (EU) No 910/2014 Art 8(2)
defines and your identity provider’s assessment establishes; the password
sign-in here does not:
kcadm.sh config credentials --server https://idp.example.org --realm master --user admin
kcadm.sh create realms -s realm=ferrofed -s enabled=true \
-s 'attributes."acr.loa.map"="{\"urn:example:loa:substantial\":1,\"urn:example:loa:high\":2}"'
for scope in 'user/aql-*.s' 'user/composition-*.r' 'system/aql-*.s'; do
kcadm.sh create client-scopes -r ferrofed -s "name=$scope" -s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true' \
-s 'attributes."display.on.consent.screen"=false'
done
kcadm.sh create clients -r ferrofed -s clientId=example-clinical-app \
-s publicClient=false -s standardFlowEnabled=true \
-s 'redirectUris=["https://app.example.org/callback"]' \
-s 'attributes."access.token.header.type.rfc9068"=true'
kcadm.sh create clients -r ferrofed -s clientId=example-reporting-service \
-s publicClient=false -s standardFlowEnabled=false -s serviceAccountsEnabled=true \
-s 'attributes."access.token.header.type.rfc9068"=true'
Each client then gets its scopes as default client scopes, loses the
profile and email scopes, whose personal data the gateway never reads,
and gets its protocol mappers: the audience and the purpose of use for
both, and client_id for the clinical application:
id_of() { kcadm.sh get clients -r ferrofed -q "clientId=$1" --fields id --format csv --noquotes; }
scope_of() {
kcadm.sh get client-scopes -r ferrofed --fields id,name --format csv --noquotes |
awk -F, -v n="$1" '$2 == n { print $1 }'
}
app="$(id_of example-clinical-app)"
svc="$(id_of example-reporting-service)"
for scope in 'user/aql-*.s' 'user/composition-*.r'; do
kcadm.sh update "clients/$app/default-client-scopes/$(scope_of "$scope")" -r ferrofed
done
kcadm.sh update "clients/$svc/default-client-scopes/$(scope_of 'system/aql-*.s')" -r ferrofed
for client in "$app" "$svc"; do
for scope in profile email; do
kcadm.sh delete "clients/$client/default-client-scopes/$(scope_of "$scope")" -r ferrofed
done
kcadm.sh create "clients/$client/protocol-mappers/models" -r ferrofed -f audience.json
kcadm.sh create "clients/$client/protocol-mappers/models" -r ferrofed -f purpose-of-use.json
done
kcadm.sh create "clients/$app/protocol-mappers/models" -r ferrofed -f client-id.json
audience.json puts the gateway’s audience in aud:
{
"name": "ferrofed-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.custom.audience": "https://gateway.example.org/fed",
"access.token.claim": "true",
"id.token.claim": "false",
"introspection.token.claim": "true"
}
}
purpose-of-use.json declares the purpose of use, here treatment (TREAT
of HL7 v3 ActReason), in the IHE IUA extension. A hardcoded claim gives
one purpose per client, so a client that requests data for another purpose
gets a client of its own:
{
"name": "purpose-of-use",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"config": {
"claim.name": "extensions.ihe_iua.purpose_of_use",
"claim.value": "[{\"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActReason\", \"code\": \"TREAT\"}]",
"jsonType.label": "JSON",
"access.token.claim": "true",
"id.token.claim": "false",
"userinfo.token.claim": "false",
"introspection.token.claim": "true"
}
}
client-id.json adds client_id to the user’s token. RFC 9068 requires the
claim, and Keycloak 26.8.0 wrote it by itself into the service account’s
token and not into the user’s:
{
"name": "client-id",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"config": {
"claim.name": "client_id",
"claim.value": "example-clinical-app",
"jsonType.label": "String",
"access.token.claim": "true",
"id.token.claim": "false",
"userinfo.token.claim": "false",
"introspection.token.claim": "true"
}
}
A user who signs in to example-clinical-app then gets a token whose
scope reads user/composition-*.r user/aql-*.s, and the reporting
service a token with system/aql-*.s, both with acr
urn:example:loa:substantial. Keycloak’s realm issuer is
https://idp.example.org/realms/ferrofed; copy issuer from
https://idp.example.org/realms/ferrofed/.well-known/openid-configuration,
since the gateway compares iss exactly:
# ferrofed.toml
[auth]
product_scope = "__ferrofed.operator"
[[auth.issuer]]
issuer = "https://idp.example.org/realms/ferrofed"
kind = "persons"
first_party_clients = ["example-clinical-app", "example-reporting-service"]
clock_skew = "1min"
[auth.issuer.assurance]
claim = "acr"
minimum = "substantial"
[auth.issuer.assurance.levels]
"urn:example:loa:substantial" = "substantial"
"urn:example:loa:high" = "high"
[[auth.attributes]]
issuer = "https://idp.example.org/realms/ferrofed"
mapping = "iua"
[[trust.issuer]]
issuer = "https://idp.example.org/realms/ferrofed"
backend_clients = ["example-reporting-service"]
The [[auth.attributes]] table reads the professional and the purpose of
use from the IHE IUA claims under extensions.ihe_iua (ITI TF-2
§3.71.4.2.2.1.1), where the realm’s purpose-of-use mapper below writes
them.
The realm’s tokens are typed at+jwt and carry client_id, so the issuer
keeps the default rfc9068 profile. A person’s token is admitted only
through a client first_party_clients lists, the reporting service’s too,
since its service account’s sub is a user’s and never the client’s.
system/aql-* counts only for a client listed in backend_clients, and a
token only a system/ scope covers names no natural person, so the
reporting service reaches no patient data: its query is refused
401 natural-person-required.
[auth.issuer.assurance] reads the realm’s acr and refuses a token below
substantial with 401 authentication-assurance-insufficient
(Professionals and assurance).
The gateway reads the realm’s metadata at its OpenID Connect location and
the key set it names. An operator who uses the
operator console needs __ferrofed.operator, the
product_scope, in the token as well
(The operator surface).
5. Onward credentials
The gateway never forwards a caller’s token to a node. It authenticates to
each endpoint with that endpoint’s own credential, one section per
endpoint id, and signs the caller’s identity onto every request with its
own key (Onward credentials). Here cdr-a takes a
user and a password and cdr-b registers the gateway as an OAuth 2.0
client, which obtains its token with the client credentials grant and
authenticates with a JWT signed by its own key (private_key_jwt). A
node’s grant takes no client secret (§13.1, N25):
# ferrofed.toml
[credentials."cdr-a-query".credentials]
user = "ferrofed-gateway"
password_file = "/run/secrets/ferrofed/cdr-a-password"
[credentials."cdr-b-query".credentials.oauth]
token_endpoint = "https://auth.cdr-b.example.org/oauth2/token"
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt"
private_key_file = "/run/secrets/ferrofed/cdr-b-client-key.pem"
scope = "system/aql-*.s system/composition-*.r"
[signing]
key_file = "/run/secrets/ferrofed/signing-key.pem"
Make the signing key and cdr-b’s client key, and give every secret file
to the gateway’s user alone:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out secrets/signing-key.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out secrets/cdr-b-client-key.pem
sudo chown -R 65532:65532 secrets && sudo chmod 0400 secrets/*
Use ec_paramgen_curve:P-256 instead when a member’s authorization server
holds to the FAPI 2.0 Security Profile
(Signing keys and the JWK Set).
The JWK Set is served at https://gateway.example.org/fed/.well-known/jwks.json,
on the public address of step 8, and
signing.jwks_uri takes that value from gateway.public_url; give it to
cdr-b’s operator with the client_id, and to every member that verifies
the caller token.
6. Identity resolution
A patient query names its patient by an identifier and its namespace. The
gateway asks the PIX Manager for the patient’s ehr_id in each member’s
ehr_id domain (ITI-83), and sends each member a query scoped to that
ehr_id alone (§5.2, N3). Without a cross-reference every patient query is
a 424 (Choosing one).
The Manager answers only from what its feeds delivered, so it must hold,
for every patient, the identifier clients name and each member’s ehr_id
in that member’s domain. Each member, or the integration engine beside it,
feeds its own domain: ITI-104 or the PMIR ITI-93 feed when it creates an
EHR, and once, a bulk load of the EHRs it already holds
(Keeping the PIX Manager current).
Register each domain at the Manager before you add the member.
SanteMPI is the PIX Manager the end-to-end lane verifies against two members, and the block below is its shape: an OAuth 2.0 client-credentials grant with the secret in the request body (A PIX Manager verified with FerroFED: SanteMPI).
# ferrofed.toml
[[pixm.manager]]
[pixm.manager.endpoint]
url = "https://mpi.example.org/fhir/"
[pixm.manager.members]
"cdr-a" = "urn:oid:2.999.10"
"cdr-b" = "urn:oid:2.999.20"
[pixm.manager.endpoint.credentials.oauth]
token_endpoint = "https://mpi.example.org/auth/oauth2_token"
client_id = "ferrofed-pix-consumer"
client_auth = "client_secret_post"
client_secret_file = "/run/secrets/ferrofed/mpi-consumer-secret"
scope = "*"
Every member of the registry is resolved by exactly one Manager, so a
member missing from [pixm.manager.members] refuses the configuration.
When your hospital MPI does not answer ITI-83, run a Manager for the
federation beside it, or let PDQm translate a local identifier first
(When the hospital MPI does not speak PIXm).
To hear of identity merges as they happen, subscribe at a PMIR Patient Identity Registry. It sends each change to the feed route under the base, which step 8 opens to the Registry alone (The identity feed):
# ferrofed.toml
[pmir]
feed_clients = ["pmir-registry"] # the Registry's client at the issuer of step 4
[pmir.endpoint]
url = "https://pmir.example.org/fhir"
The Registry sends the feed to pmir.callback_url, which is
https://gateway.example.org/fed/pmir/feed here, the feed route under
gateway.public_url.
7. The access log
Every IHE transaction the gateway makes is audited, and every access to
patient data it intermediates is recorded with the caller it verified
(The audit trail,
#623). Both go to your Audit Record
Repository as FHIR AuditEvents, from a spool on the durable volume:
# ferrofed.toml
[audit]
destination = "repository"
[audit.repository]
hostname = "gateway.example.org"
spool_dir = "/var/lib/ferrofed/audit-feed-spool"
[audit.repository.endpoint]
url = "https://arr.example.org/fhir"
Outside the development profile, config check refuses this configuration
without destination, naming audit.destination. With the access log of
#623, a gateway with a registry refuses log as well, because the log
target names no caller and no patient.
A record counts as recorded once it is on the spool’s disk. Keep the volume on encrypted storage that outlives the container, one spool per replica (Losing a spool).
8. TLS and the public address
The listener speaks plain HTTP by default. Choose one of two shapes:
- A reverse proxy beside the gateway terminates TLS and reaches the gateway over loopback, on the same host or in the same pod. This page ships one for nginx.
- TLS on the listener, with
[server.tls], where the hop from your proxy or load balancer to the gateway crosses a network others share. Addclient_auth = "required"and aclient_ca_fileto admit the proxy alone (TLS on the listeners).
ferrofed.conf is the nginx configuration for the settings above, and
every release carries it with its checksum. Download it from the release
you run, check it, set your server name and certificate, and reload nginx:
for f in ferrofed.conf ferrofed.conf.sha256sum; do
curl -LO "https://github.com/FerroHEALTH/FerroFED/releases/download/vX.Y.Z/$f"
done
sha256sum -c ferrofed.conf.sha256sum && sudo cp ferrofed.conf /etc/nginx/conf.d/
nginx -t && nginx -s reload
It passes these routes, each under the base:
| Route | Who reaches it |
|---|---|
GET and OPTIONS {base}/ | your clients |
{base}/v1/ and below | your clients; the gateway authenticates each one |
{base}/operator/ and below | the operator console |
GET {base}/.well-known/jwks.json | every member’s authorization server, and every member that verifies the caller token |
POST {base}/pmir/feed | the PMIR Patient Identity Registry alone; name its address in the allow line |
{base}/health and below | stopped at the proxy with 403: the dependency report names every member endpoint, so probe the gateway on its own port |
| every other path | 404 at the proxy |
It also:
- passes each request URI as the client sent it, so a version uid’s
::and every encoded character reach the gateway unchanged; - keeps its own timeouts above the gateway’s 30-second request timeout and its body limit above the gateway’s 1 MiB, so a client gets the gateway’s own answer and error code;
- sets
Strict-Transport-Security, which the gateway does not send; - sets
X-Forwarded-Forto the address the request came from, replacing any a client sent, and dropsForwarded, so the gateway, which trusts this proxy alone, names the client’s address and never one a client wrote; - writes an access log line with the gateway’s request id in place of the
request line, because the
GETform of a query and a read by subject carry the patient identifier in the query string. The request id finds the gateway’s own log line.
scripts/checks/production-guide.sh runs the file in front of a gateway
with this page’s configuration, and checks each route in the table, the
header and the log line.
Under the Dutch Nuts grant, the gateway also serves its DID document at the
path its did:web names, outside the base; pass that path as well
(The Nuts grant).
9. Check, start and admit
Check the configuration before every start, with the same image you run.
It resolves the configuration exactly as serve would, secret files
included, opens no store, binds no socket and writes nothing, so it needs
no durable volume: the audit spool directory is checked where it stands
and created by serve
(Running it):
docker run --rm -v "$PWD/ferrofed.toml:/etc/ferrofed/ferrofed.toml:ro" \
-v "$PWD/registry.toml:/etc/ferrofed/registry.toml:ro" \
-v "$PWD/secrets:/run/secrets/ferrofed:ro" \
ghcr.io/ferrohealth/ferrofed@sha256:… config check --config /etc/ferrofed/ferrofed.toml
A refused file exits 78 with one line naming the key at fault, never a
secret (Running it). Then start the gateway
and ask for its readiness on the host:
docker compose up --wait
curl http://127.0.0.1:8080/fed/health/readiness
curl -H "Authorization: Bearer $OPERATOR_TOKEN" \
http://127.0.0.1:8080/fed/operator/dependencies
Readiness is 200 once the gateway serves; no member and no identity
service gates it. The dependency report shows the last state the gateway
observed of each member, the resolver and the audit repository to a token
with the operator scope (The dependency report).
Admit each member before it serves (§12b.1, N42a). The admission check creates synthetic EHRs at the member and checks each condition a test can reach, so agree the run with the member’s operator first (Admitting a node):
docker compose exec ferrofed /usr/local/bin/ferrofed admission check --endpoint cdr-a-query
When the member’s governance forbids test data in its production CDR, run
the full check against a staging copy of it, and check the production CDR
with --read-only, which writes nothing and names every condition it leaves
unproven (A run without writes).
When both members pass, remove status = "suspended" from their endpoints
and reload the registry:
docker compose kill -s SIGHUP ferrofed
In Kubernetes, change the ConfigMap and send each pod POST /admin/reload
(Reloading over the admin listener).
10. The first federated query
Get a token from the issuer of step 4 for a
person: sign in through example-clinical-app, which receives the access
token at its callback, and put it in $token. A machine’s
client-credentials token, such as the reporting service’s, names no natural
person and is refused patient data 401 natural-person-required.
Then send one ordinary ITS-REST query for a patient both members know:
curl -s https://gateway.example.org/fed/v1/query/aql \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' -d @- <<'EOF' | jq .meta.federation
{"q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c WHERE e/ehr_status/subject/external_ref/id/value = 'example-0001' AND e/ehr_status/subject/external_ref/namespace = 'urn:oid:2.999.1'"}
EOF
The answer is one ITS-REST RESULT_SET, and meta.federation says how it
was made (What a client gets back):
completeistruewhen every member in scope answered.endpointshas one entry per member.activewith arow_countmeans the member was asked and answered.not-resolvedmeans the PIX Manager holds noehr_idfor the patient there: the member was not asked, andcompleteisfalse.node-error,time-outandofflinename a member that failed, with the reason inerror.- The
openEHR-federation-endpointandopenEHR-federation-system-idresponse headers name the members that contributed rows.
A 401 names its reason in the gateway’s security log, never in the
answer, a 403 in its error code, and a 424 means a member that may hold the patient could
not be resolved (Errors and status codes).
11. Operations
Turn on the admin listener, and scrape GET /metrics there. It stays on
loopback unless you allow otherwise, and never sits under the base path.
Off loopback, the scrape needs a client-credentials token of a scrape
client, which your Prometheus obtains with oauth2, and a write action such as the
stored-query distribution needs an access token with auth.product_scope
(Metrics). A
console with [admin] needs it on an internal address its server reaches
and no public network does (Console):
# ferrofed.toml
[metrics]
listen = "127.0.0.1:9464"
# off loopback: listen = "0.0.0.0:9464", allow_remote = true, and
# scrape_clients = ["prometheus"], a client of the issuer of step 4
- Alerts. Load the shipped dashboard and alert rules, and add an alert
on the scrape job’s
up(Dashboard and alert rules). - Logs. Ship the JSON log to a store with access control and a
retention period. It carries no patient identifier; an
outbound-gate-stoppedevent is an incident (Hardening). - Restarts. On
SIGTERMreadiness turns503, the listener stays open fordrain_delay, the requests in flight getshutdown_timeout, and the bindings getbindings_drain_timeoutto stop their processes. Keep the runtime’s grace period above all three (Stopping without dropping a request). - Several replicas. The replicas share nothing in memory. A follow-up
write needs the client to name its endpoint, or the balancer to keep a
client on one replica; the stored-query registry and a contact-point
issuer need FerroFED’s own PostgreSQL store (
[store]); each replica keeps its own audit spool (Running several replicas). - Upgrades. Read the release’s upgrade notes, keep a copy of the
configuration and the image digest you run, verify the new image as in
step 2, and run its
config checkover your files as in step 9 before you replace the image (Upgrading). Going back has its own steps for the configuration, the stored-query store and the audit spools (Rollback). - Signing-key rotation. Rotate in three rolling restarts (Rotating the signing key).
- Hardening. Work through the hardening checklist before the gateway reaches real patient data.
Preparing each CDR
Each member CDR meets the conditions below before A production deployment admits it, as step 1 of that guide asks.
Agree each of these with the member’s operator before you admit the member. The first five are the conditions of membership (§12b.1, §12b.2, N42a); the rest are what the gateway needs to reach the member.
-
A unique
system_id. The CDR stamps one openEHRsystem_idinto every EHR and version it creates, and no other member uses it (§12b.2). Find it by asking the CDR itself, at the member, as the member’s operator:SELECT DISTINCT e/system_id/value FROM EHR eExpect one value. If the CDR reports several, ask its operator which one it stamps into new EHRs, and map any other system whose versions it holds with a
[[creating_system]]entry (The registry). -
ehr_ids are version-4 UUIDs, or come from a scheme with the same collision resistance (§12b.2). -
No
ehr_idis reused, across restores, migrations and test-data resets (§12b.2). The evidence is the member’s documented procedures. -
No foreign
ehr_idis adopted. An imported EHR gets a freshehr_id(§12b.2). -
Each
ehr_idreaches the PIX Manager in the member’sehr_iddomain, with the patient identifier your clients use, from the moment the EHR is created, and the EHRs that exist today in a bulk load (§5.5; step 6). -
ITS-REST is reachable from the gateway at one base URL per endpoint, over
httpswhenever the gateway sends it a credential (What must travel encrypted). -
The member accepts the gateway’s credential: a user and a password, or an OAuth 2.0 client registered under the gateway’s
client_idfor the client credentials grant (step 5). -
The member makes its own access decision and enforces consent (§13.2, N26, N27). If it marks a consent refusal with an ITS-REST
Errorcode, write the code down for the registry’sconsent_refusal_codes(Consent). -
The member verifies who asks, recommended: it reads the
openEHR-federation-clienttoken the gateway signs, with the gateway’s JWK Set, and records the caller in its own audit trail (Verifying it at the node).
What FerroFED runs beside
The binary serves the federated query over a registry of member CDRs (Configuration). This page describes what a running gateway needs around it, taken from the roles the specification names, and says which binding FerroFED ships for each role and which is planned.
See how it works: deployment and trust and keys.
Supported platforms
FerroFED runs on Unix only. The server drains on SIGTERM and SIGINT and
reloads its registry on SIGHUP, and both use Unix signals. Every release
binary is Linux, for x86_64 and aarch64 on glibc and on musl, and the
container image is Linux (The release binaries).
Nothing is built or tested for Windows: building the server crate for a
non-Unix target stops at a compile error that says so. No specification
governs this: our own design.
The services a gateway consumes
| Role | What it does | Binding |
|---|---|---|
| Member CDRs | answer standard AQL scoped to one ehr_id, and the reads and writes routed to them | openEHR ITS-REST 1.1.0, over each node’s own base URL |
| Demographics (optional) | names the master identity of a patient the client names by an identifier the cross-reference does not know | IHE PDQm ITI-78 or ITI-119 (Demographics first) |
| Identifier cross-reference | maps a patient identifier to each node’s local ehr_id, or reports it not found | IHE PIXm ITI-83 (Identity resolution) |
| Localization (optional) | returns the candidate communities for a patient; without it the gateway asks every member’s cross-reference | none, where every member is a candidate (ask-all); under node_selection = "localized", IHE XCPD ITI-55 (XCPD localization), the Dutch NVI (Dutch localization) or the PIX Manager itself |
| Consent pre-filter (optional) | names the members that may not be asked about the patient, never the gate | the Dutch Mitz closed authorization question (Dutch consent) |
| Addressing | resolves each community to its CDR base URLs | the registry document, in TOML or as FHIR Organization and Endpoint resources, or an mCSD care services directory read with ITI-90 and kept in step with ITI-91 (The registry) |
| Authentication and authorization | authenticates the client, and the gateway to each node | client authentication by RFC 9068 access tokens from the issuers you trust, or an explicit edge mode (Client authentication); outbound credentials per endpoint, a bearer token, basic credentials, OAuth 2.0 client credentials or token exchange with an RFC 7523 assertion and optional DPoP, or the Nuts and FAPI 2.0 grants of the Dutch binding (below) |
The specification references the internals of each service out (§2.2): how an MPI matches identities, how a locator decides where data is, and the transport trust framework all belong to their own profiles. A region may supply its own realisation; Annex B describes the Dutch Generic Functions as one.
Authentication
Every client authenticates to the gateway (§13.1, N25): a request to the
ITS-REST surface, and OPTIONS {base}/, carries an RFC 9068 access token
from an issuer you trust, with a SMART on openEHR scope for the operation and
a purpose of use, or, in the explicit edge mode, an assertion your proxy
signed. A request without one is refused before any node is asked
(Client authentication). The listener speaks plain HTTP
for a proxy that terminates TLS in front of it on a hop nobody else can
read, or TLS itself with [server.tls], with an optional client CA that
admits only the proxy, where that hop crosses a shared network
(TLS on the listeners). The gateway never forwards a client’s
Authorization header to a node. The resolution bindings of §12.5.1 are
kept per verified caller
(The registry).
Toward the nodes, the gateway authenticates with credentials you configure per endpoint (Configuration):
- an RFC 7617 user and password, the password inline or read from a file, sent on every request to that endpoint and to nothing else;
- OAuth 2.0 client credentials with an RFC 7523 signed JWT client assertion, the default mechanism of §13.1 (N25): the gateway obtains a token at the node’s token endpoint and sends that (OAuth 2.0 to a node), or RFC 8693 token exchange for a token per verified caller (Token exchange), either bound to a key of the gateway’s with DPoP where you configure one (DPoP);
- the Nuts grant and the FAPI 2.0 grant of the Dutch binding (Annex B §B.4, §B.4a; Onward credentials).
For the OAuth 2.0 grant, the node’s authorization server needs the gateway
registered as a client under its client_id, with the gateway’s JWK Set
location. The gateway serves the set at {base}/.well-known/jwks.json
without client authentication, and declares its location as
federation.auth.jwks_uri in OPTIONS {base}/
(Signing keys and the JWK Set).
Keep that route reachable from every node’s authorization server; client
authentication guards the rest of the surface and never this route. A PIX Manager takes a
user and password, the OAuth 2.0 client credentials grant, or none where the transport
authenticates the gateway (Identity resolution).
Every request to a node also carries the verified caller in an
openEHR-federation-client token the gateway signs with its own key (§13.1,
N24, #82), so
[signing] is required on every federating gateway, and every node that
verifies the token needs the same JWK Set location
(What a node is told about the caller).
What the gateway keeps
The gateway holds no clinical data. It keeps the registry of organisations,
endpoints and the system_id mapping that routing depends on (§3.1, N21), and,
if the deployment offers it, the federated stored-query definitions it is
authoritative for (§12.7). The specification is silent on storage, so this is
FerroFED’s own design: the registry is a reviewed document, loaded at boot
and on each reload into an immutable snapshot, and the ehr_id index and the
learned creating_system_id routes are held in memory, bounded, and lost on a
restart. The stored-query registry is the
one durable store, holding parameterised AQL and never a patient identifier,
over the backend [stored_queries]
names: an embedded redb file for one gateway process, FerroFED’s own
PostgreSQL store for several replicas ([store]), or read-only
definition files. A contact-point issuer needs that store too.
Running several replicas
Several gateway replicas behind one address share nothing in memory: each
holds its own resolution bindings, ehr_id index and learned routes, and a
miss on one replica costs a probe or an explicit target, never a wrong route.
For a write the miss is visible to your clients. A follow-up write under a
path ehr_id that names no node is routed by the caller’s resolution
binding or the ehr_id index (§12.5.1, N41), and the gateway never probes
for a write’s owner, so the replica that resolved the patient routes it and
any other replica answers 400 target-required. Nothing reaches a node,
and behaviour then changes with the replica count. Give your clients one of
two remedies:
- Clients send
openEHR-federation-endpointon every write, with theendpoint_idof the row they act on. It routes on every replica, and it is what the client contract recommends (Writes through a gateway with several replicas). - The balancer keeps each client on one replica, such as a Kubernetes
Service with
sessionAffinity: ClientIP(Session affinity). The bindings belong to the verified caller, which a balancer cannot read from a token, so affinity by client address is an approximation: a replica restart, a scale-down or the affinity timeout still moves a client to a replica without its bindings.
The replicas never share the bindings or the index: each lives in the
memory of one process and is lost on its restart, and the gateway writes
nothing derived from a patient identifier to a shared store. For the same
reason the operator’s clear of a held ehr_id collision clears the replica
whose admin listener receives it, so send it to each replica
(Clearing a held collision); the
operator console does that for you when its [admin] table names every
replica’s admin listener on an internal address
(The operator console), and
the export of the learned routes for a replacement system holds one
replica’s routes, so export each replica
(Exporting the identity state).
Every operator report and every export names the replica that produced
it (The replica name), so name your
replicas apart or leave each its pod name. The operator console reads the
gateway through one address and shows the replica that answered each view;
it cannot show every replica at once.
The stored-query registry is the exception, because a stored version must be
the same on every replica and a second PUT of it refused on every replica
(§12.7, N44):
- A
redbfile is opened by one process at a time, so replicas cannot share one, and a file per replica would let two replicas each accept a different firstPUTof the same name and version. Run the registry on thepostgresbackend: every replica uses one database, a version one replica stores is served by all of them, and two replicas storing the same new version at once store exactly one. - Replicas that only serve definitions an operator publishes can use the
read-only
filesbackend instead, each loading the same directory, with no database. APUTis then refused405on every replica. - Without
[stored_queries]no replica offers the registry, and the replicas need no shared state at all.
Two more settings take the replica count into account:
- The identity feed. Replicas behind one
[pmir] callback_urlshare one PMIR subscription, and a drain keeps it for the others (on_drain = "keep", the default). Each identity change reaches one replica; the others keep a stale binding until its lifetime ends. A replica with acallback_urlof its own holds its own subscription and hears every change (Several replicas). - Signing-key rotation. Every replica must publish a new key before any
replica signs with it, so a rotation takes three rolling restarts:
publish it as
next_key_file, make itkey_file, then retire the old one (Rotating the signing key).
Failure behaviour you should know before you run it
- When a node that was asked does not answer, the default is to fail the query. A client that wants flagged partial rows has to ask for best-effort explicitly (§11.4).
- Every response, a failing one included, reports each node in scope with a
status such as
active,offline,time-outornot-resolved(§11.1). - Consent is enforced by each node before it releases data (N27). A node’s
403is reportedconsent-denied, and fails nothing, only when its ITS-RESTErrorcarries a code the registry lists for that endpoint; every other refusal isnode-error. An optional Step-1 pre-filter drops members before dispatch: the development table, or Mitz in the Netherlands (Consent).
The container image and the quickstart
FerroFED ships one static binary, ferrofed, and an image that carries it on
distroless static. Every release carries compose.yaml, which runs that image
alone in front of the CDRs you already run, with an example ferrofed.toml
and registry.toml (The gateway from a
release). The repository’s own compose.yaml
starts the image beside four member CDRs, four FerroEHR instances, so the
topology a federated query runs over is up in one command (The
quickstart).
See how it works: the quickstart and a production layout.
The gateway from a release
Every release carries three files that run the gateway alone, at that release’s image, in front of the CDRs you already run, and the nginx reverse proxy that puts it on its public address. You need Docker Compose and no checkout of the repository.
-
Download the files into a directory of their own, and check the proxy configuration against its checksum:
mkdir ferrofed && cd ferrofed curl -fsSL -o compose.yaml https://github.com/FerroHEALTH/FerroFED/releases/latest/download/gateway-compose.yaml for f in ferrofed.toml registry.toml ferrofed.conf ferrofed.conf.sha256sum; do curl -fsSLO "https://github.com/FerroHEALTH/FerroFED/releases/latest/download/$f" done sha256sum -c ferrofed.conf.sha256sum…/releases/download/vX.Y.Z/$fdownloads the files of one version. The release attaches the gateway’s compose file asgateway-compose.yaml, since itscompose.yamlis the self-contained quickstart; saved ascompose.yaml, it is the file Compose reads without-f.ferrofed.confis the nginx configuration of the production guide; Compose does not read it. -
Edit
registry.toml, the registry document: replace the two example members with your organisations, nodes and endpoints, as many as the federation has. -
Edit
ferrofed.toml, the gateway configuration: your federation id, your PIX Manager’s URL with each node’sehr_iddomain there (Identity resolution), and a[credentials."<endpoint id>"]section for each endpoint that needs one, and[signing] jwks_uri, the address the nodes fetch the gateway’s JWK Set from. Every value to change is markedEDIT. -
Put each credential, and the gateway’s signing key, in its own file in
secrets/, under the nameferrofed.tomlgives it after/run/secrets/ferrofed/. The gateway runs as uid 65532, so that user must be able to read each file:mkdir -p secrets printf '%s\n' "$PIX_TOKEN" > secrets/pix-token openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 \ -out secrets/signing-key.pem sudo chown -R 65532:65532 secrets && sudo chmod 0400 secrets/* -
Start it:
docker compose up --wait curl http://127.0.0.1:8080/health
This setup writes no credential in ferrofed.toml: each one is a file, named
by a password_file or client_secret_file key, and so is the signing key,
named by key_file. The gateway also accepts a secret written inline, under
every profile (Configuration),
but a file keeps it out of the configuration and out of every copy of it. Compose mounts ferrofed.toml, registry.toml and
secrets/ read-only at /etc/ferrofed/ and /run/secrets/ferrofed/, and
the named volume audit-spool at /var/lib/ferrofed for the
audit spool. A missing ferrofed.toml or registry.toml
stops docker compose up; a missing secrets/ is created empty.
A configuration the gateway refuses stops it with exit code 78, and
docker compose up --wait reports the container unhealthy. docker compose logs ferrofed shows the one line naming the key at fault. After you correct a
file, docker compose restart ferrofed starts the gateway on it.
The variables in the compose file are about the container only, read from the
shell or from .env beside it:
| Variable | Default | What it sets |
|---|---|---|
FERROFED_VERSION | the release’s version | the image tag |
FERROFED_BIND_HOST | 127.0.0.1 | the host address the gateway is published on |
FERROFED_PORT | 8080 | the host port |
FERROFED_CPUS | 1 | the CPU limit |
FERROFED_MEMORY | 256M | the memory limit |
The gateway service runs as uid 65532 with a read-only root filesystem, every
capability dropped and no-new-privileges. Its healthcheck is the image’s
ferrofed healthcheck, so --wait returns once the gateway is ready. Its
port binds the loopback interface unless you set FERROFED_BIND_HOST, for the
reason under The quickstart. A registry URL can name a CDR
on the Docker host itself as host.docker.internal. The stop grace period of
40 seconds outlasts the gateway’s 30-second drain and the bindings’ 5-second
drain
(Stopping without dropping a request);
raise it with server.drain_delay, server.shutdown_timeout or
gateway.bindings_drain_timeout.
The restart policy is unless-stopped. Docker restarts a gateway that exits
with an error, doubling its wait before each attempt from 100 ms
(docker run --restart),
so a refused configuration is retried at a slowing pace rather than in a
tight loop. on-failure would stop the retries after a count, but Docker
does not apply it when the daemon restarts
(restart policies),
so a gateway that drained cleanly on a host reboot would stay down. Docker restarts no container
for failing its healthcheck; the healthcheck tells --wait and you whether the
gateway is ready.
To move to a newer release, download its gateway-compose.yaml over the old
compose.yaml and run docker compose up --wait. Read the release’s Upgrade notes first, and
follow Upgrading; Rollback says how to go back.
A deployment resolves patients through its PIX Manager; the quickstart below runs FerroPIX for that.
The image
docker/Dockerfile copies the release lane’s musl binary for the target
platform onto gcr.io/distroless/static-debian13:nonroot, pinned by the
digest of its image index. The image:
- runs as the numeric user
65532:65532, so an orchestrator’srunAsNonRootaccepts it; - has no shell and no package manager;
- runs with a read-only root filesystem and every capability dropped, and
writes only to the audit spool volume at
/var/lib/ferrofed; - binds
0.0.0.0:8080(the binary’s own default is loopback, which no container can publish), set throughFERROFED__SERVER__LISTEN; - starts
ferrofed serveas PID 1, soSIGTERMreaches the server and it drains before it exits.
The base has no shell and no HTTP client, so the image’s HEALTHCHECK runs
the binary itself:
HEALTHCHECK --interval=30s --timeout="5ms"s --start-period=15s --retries=3 \
CMD ["/usr/local/bin/ferrofed", "healthcheck"]
ferrofed healthcheck asks the gateway’s readiness over loopback and exits
0 only when it answers 200; Health probes has the details.
docker inspect --format '{{.State.Health.Status}}' <container> shows the
outcome.
The audit spool
With [xcpd] audit = "repository", the gateway keeps each ITI-55 audit
message in a spool until the ATNA Audit Record Repository takes it
(The audit repository). Set
xcpd.audit_repository.spool_dir = "/var/lib/ferrofed/audit-spool". The
image ships /var/lib/ferrofed and that spool directory owned by the
gateway’s user 65532:65532 with mode 0700, so a named Docker volume
mounted at /var/lib/ferrofed starts with that owner and mode, and the
release compose.yaml mounts one, audit-spool, with no step on the host.
With [audit] destination = "repository", the PIXm, mCSD and PMIR audit
records wait in a spool of their own until the repository takes them
(The audit trail). Set
audit.repository.spool_dir = "/var/lib/ferrofed/audit-feed-spool" on the
same volume.
The spool holds audit records that name patients: each message carries the query parameters, the patient identifier among them. Keep the volume on an encrypted disk; the gateway holds no key to encrypt it with. Each replica needs a spool of its own, since two gateways must never drain one directory.
Losing a spool
A record counts as recorded once it is on the spool’s disk, and the
transaction it records goes on from there
(The spool and the failure policy).
A spool that is lost before the repository took everything in it therefore
loses records of transactions that did happen: the repository’s trail misses
them for good, and nothing at the repository shows the gap. The gateway
cannot send them again, because it keeps no other copy. While records wait,
the dependency report shows audit_repository or audit_feed as
degraded (Health probes), so a spool is safe to discard only
once both read up.
So keep the spool on storage that outlives the container and its host: a named volume under Docker, and a persistent volume claim per replica under Kubernetes. A replica that comes back on the same volume delivers what its spool still holds.
Kubernetes
deploy/kubernetes/ is a kustomize package, which every release attaches
with its images pinned by digest (The Kubernetes package).
Its base, deploy/kubernetes/base/, is a ConfigMap with the configuration
and the registry document and no secret, a StatefulSet, a Service, the
headless Service of the admin listeners, a PodDisruptionBudget and a
NetworkPolicy that opens the gateway port to your reverse proxy and the
metrics port to your Prometheus (Metrics);
its components add the operator console, the routes, a ServiceMonitor and
an egress policy. CI validates every manifest with kubeconform in strict
mode, and runs ferrofed config check over the ConfigMap’s configuration
with synthetic secrets. The configuration trusts one example issuer in
[auth] and reads the gateway’s signing key from the ferrofed-secrets
Secret, which you create before you apply the manifests.
The gateway runs as a StatefulSet for its audit spool alone. In a
Deployment, every pod mounts the same volume claim, or an emptyDir of its
own, and neither fits: two gateways must never drain one spool, and an
emptyDir is deleted with its pod, so a reschedule or a node drain would
lose the records still in it. A StatefulSet’s
volumeClaimTemplates gives each replica a claim of its own,
audit-spool-ferrofed-0 and audit-spool-ferrofed-1, which the replica
finds again wherever it is scheduled
(StatefulSets).
The replicas share nothing else, so podManagementPolicy: Parallel starts
and stops them together. Its governing Service is the headless
ferrofed-admin, which gives each replica’s admin listener a name of its
own for the console and the scraper; clients reach the replicas through the
ferrofed Service alone. The StatefulSet:
- probes startup and readiness on
GET /health/readinessand liveness onGET /health/liveness; - mounts the ConfigMap at
/etc/ferrofedand theferrofed-secretsSecret at/run/secrets/ferrofed, readable by the gateway’s group (fsGroup65532, mode0440); - mounts its
audit-spoolclaim at/var/lib/ferrofedfor the audit spool, writable through the samefsGroup, which the kubelet applies only when the volume’s root does not already match (fsGroupChangePolicy: OnRootMismatch); the gateway creates each spool under it with mode0700. The claim asks for the storage classencrypted, a placeholder: replace it with a class of your cluster that encrypts the volume at rest, because the spool names patients. Its1Giholds the default bounds of both spools (spool_max_size,spool_max_events) with headroom; raise it with either bound; - keeps each claim when the StatefulSet is deleted or scaled down
(
persistentVolumeClaimRetentionPolicyRetain). A replica scaled away leaves its claim behind, and the records in it wait until that replica comes back. Before you delete a claim, scale back up until its spool is delivered, and check thatGET {base}/operator/dependenciesreadsupfor the audit repository; - runs as the numeric user
65532withrunAsNonRoot, a read-only root filesystem,allowPrivilegeEscalation: false, every capability dropped and theRuntimeDefaultseccomp profile; - sets resource requests and limits, a starting point to size from your own
load, ephemeral storage among them (a
256Milimit for the container log, since the spool is on its claim); - sets
server.drain_delayto 5 seconds in the example configuration, so a pod keeps accepting while it leaves the Service’s endpoints, andserver.shutdown_timeoutto the 30-second request timeout andgateway.bindings_drain_timeoutto 5 seconds; - holds
SIGTERMwith apreStopsleep of 5 seconds, matching the drain delay, while the proxies in front stop sending the pod new requests; - gives the pod a
terminationGracePeriodSecondsof 50, which outlasts the sleep, the delay, the drain and the bindings’ drain with 5 seconds of room, so the kubelet never kills a drain in progress. Keep the grace period above the four if you change any of them (Stopping without dropping a request); - spreads its replicas over zones and nodes where the cluster has them.
The PodDisruptionBudget keeps one of the two replicas serving through a
voluntary disruption. Every secret is a key of the ferrofed-secrets
Secret, named by a _file key (Configuration): the
signing key, and any credential an endpoint needs.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out signing-key.pem
kubectl create secret generic ferrofed-secrets --from-file=signing-key.pem
kubectl apply -k deploy/kubernetes/base
The image lane publishes it as ghcr.io/ferrohealth/ferrofed, tagged with the
release version, its major.minor and latest, and attests the index and
each platform manifest. Verify the image you pull:
gh attestation verify oci://ghcr.io/ferrohealth/ferrofed:X.Y.Z \
--repo FerroHEALTH/FerroFED \
--signer-workflow FerroHEALTH/FerroFED/.github/workflows/release-image.yml
The release lane is the only place the image is built: every compose file and manifest in the repository runs the published image, and none builds one.
The release binaries
Every release on the
releases page
carries ferrofed for x86_64 and aarch64 Linux, on glibc and on musl.
FerroFED runs on Unix only
(Supported platforms), and no
release carries a Windows binary. Each tarball holds the binary, LICENSE, NOTICE and the README, and comes with
its .sha256sum, a CycloneDX and an SPDX SBOM, and the Sigstore bundles of its
provenance and SBOM attestations. Verify a tarball before you unpack it:
gh attestation verify ferrofed-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz \
--repo FerroHEALTH/FerroFED \
--signer-workflow FerroHEALTH/FerroFED/.github/workflows/release-build.yml
The quickstart
Every release attaches the quickstart as one self-contained compose.yaml,
with its .sha256sum: every file it needs is inlined as a Compose config, so
it needs Docker Compose 2.23.1 or newer and no checkout. Download the latest
release’s into an empty directory, write the gateway’s signing key beside it,
and start it:
curl -fsSLO https://github.com/FerroHEALTH/FerroFED/releases/latest/download/compose.yaml
mkdir -p secrets
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out secrets/signing-key.pem
chmod 644 secrets/signing-key.pem
docker compose up --wait
curl http://127.0.0.1:8080/health
The gateway signs the caller’s identity onto every request to a node with that key, and refuses to start without it (What a node is told about the caller). It is a development key: the gateway runs as uid 65532 and Compose hands the file over as it is, so it is readable by every user.
Before the gateway starts, the one-shot files and ferroehr-init
services copy the inlined files onto volumes the read-only services mount
(Compose refuses an inlined config in a read-only service), the
ferropix-seed service feeds FerroPIX the synthetic patients, and the
ehr-seed service creates their EHRs at the four nodes, committing the
Federation Tier reference implementation’s demonstration template and
compositions, fetched at a pinned commit and refused unless each has its
pinned SHA-256.
--wait returns once every service reports healthy. The gateway service
runs ghcr.io/ferrohealth/ferrofed at the product version, the tag default
compose.yaml holds, and FERROFED_VERSION selects another published
version. Its healthcheck is the image’s own ferrofed healthcheck, which
images before v0.0.7 do not carry, so with an older FERROFED_VERSION the
gateway never turns healthy and --wait fails. No compose file builds the
image; every one runs the published image.
The gateway and the four FerroEHR nodes send their installation reports to
FerroPULSE by default, the gateway’s instance id kept on the
ferrofed-state volume. FERROFED__USAGE_REPORT__ENABLED=false and
FERROEHR__USAGE_REPORT__ENABLED=false, in your shell or .env, stop them
(The usage report).
| Service | What it is | On the host |
|---|---|---|
ferrofed | the gateway | 127.0.0.1:8080 |
ferroehr-a | member node A, FerroEHR with system_id node-a.quickstart.local | 127.0.0.1:8081/ferroehr/rest/openehr/v1 |
ferroehr-b | member node B, FerroEHR with system_id node-b.quickstart.local | 127.0.0.1:8082/ferroehr/rest/openehr/v1 |
ferroehr-c | member node C, FerroEHR with system_id node-c.quickstart.local | 127.0.0.1:8083/ferroehr/rest/openehr/v1 |
ferroehr-d | member node D, FerroEHR with system_id node-d.quickstart.local | 127.0.0.1:8084/ferroehr/rest/openehr/v1 |
ferroehr-definitions-a to -d | one-shots that load the professional PERSON and ROLE templates into each node’s database | not published |
ferroehr-postgres | one FerroEHR PostgreSQL server, a database per node | not published |
issuer | the development token issuer of the callers and the nodes, whose network namespace the gateway and the nodes share | 127.0.0.1:8190 |
The four nodes run FerroEHR’s documented image, and each stamps its own
system_id into every EHR and version it creates, the value the quickstart
registry declares for it. They share one FerroEHR PostgreSQL container, and
each connects to its own database there, ferroehr_a to ferroehr_d, owned
by a login role of the same name. The image’s own init script creates the
first; the node-databases config (a copy of
docker/postgres/20-ferrofed-node-databases.sh), copied beside it, runs that
script again for the other three. A schema per node would not do:
FerroEHR creates fixed schema names in the database it connects to, so two
nodes in one database would share their tables. Each database admits only its
own node’s role, because the script revokes CONNECT from PUBLIC and grants
it to that role, so node A’s role is refused on ferroehr_b. The nodes still
share one server, its superuser and its cluster-wide group roles, so this is
no production boundary.
Four instances of one product: EHRbase, the second product the topology first
used, refuses a . in PARTY_REF.namespace, which openEHR BASE admits, so its
EHRs could not carry the OID-style issuing namespace the synthetic patients
use. Every database role’s password is its name followed by _example
(ferroehr_a_example): development credentials that must not reach anything
real. Every image is pinned by tag and digest, and docs/VERSIONS.md carries
each pin.
The nodes run FerroEHR 5.7.0, which binds every person who signs in to a
demographic PERSON, and reach no one under a Basic user. Each trusts the
quickstart’s development issuer, the issuer service, as its issuer of
persons (docker/quickstart/ferroehr.toml, inlined as the ferroehr-config
config), and admits a token only when it names the node’s own API root as its
audience. The gateway reaches a node for a caller only under the token the
issuer exchanges the caller’s token for (RFC 8693), the node’s onward lane
(The onward lane of a node):
the caller as sub, the node as aud and the gateway’s client ferrofed as
its client, which the node reads as a delegate acting for that person. A node
creates the person’s PERSON at the first request, from the token’s name,
when the token names the identity provider quickstart (FerroEHR’s
just-in-time creation). Before each node starts, its ferroehr-definitions-*
service loads the professional PERSON and ROLE templates the binding maps
into the node’s database, FerroEHR’s own pair, vendored under
docs/specs/ferroehr-definitions/. A node reads its issuer’s metadata over
plain http only on loopback, so the nodes run in the issuer’s network
namespace, like the gateway, each on its own port, which the issuer publishes.
The ehr-seed service signs in as the person quickstart-seeder, through the
nodes’ own client quickstart-seed.
Every published port binds the loopback interface. A published port is
DNAT’d ahead of the host firewall’s own rules, so a port on 0.0.0.0 is
reachable from the network even when the firewall says otherwise. Set
FERROFED_BIND_HOST to the one address you mean, or put a reverse proxy in
front.
docker/quickstart/registry.toml names the four nodes and
docker/quickstart/ferrofed.toml configures the gateway with each node’s
onward lane; Compose mounts both read-only. The configuration runs
in the development profile and resolves every patient through FerroPIX, the
FerroHEALTH patient index, as its PIXm Consumer: FerroFED keeps no patient
index of its own (Identity resolution). Compose runs FerroPIX
on its own PostgreSQL, configured by docker/quickstart/ferropix/ferropix.toml
with a development sealing key. FerroPIX admits a machine only with a client
credentials token of an issuer it trusts, so Compose runs a development token
issuer beside it, ferropix-issuer (NAV’s mock-oauth2-server, which signs any
client credentials request, NOT for anything real), and FerroPIX joins that
issuer’s network namespace, since it reads an issuer’s metadata over plain
http only on loopback. The gateway asks FerroPIX under the token
[pixm.manager.endpoint.credentials.oauth] obtains there, and the one-shot
ferropix-seed service, as the Patient Identity Source, feeds it the patients
of docker/quickstart/patients.tsv over ITI-104 under its own token before
the gateway starts.
docker compose down -v stops the stack and removes its volumes. A
quickstart volume created before v0.0.8 holds the roles with their earlier
passwords, which the nodes no longer send, so run docker compose down -v
once before you bring that stack up again, then seed it anew.
The synthetic patients
The ehr-seed service reads the patients file, the patients FerroPIX was
fed, and creates exactly those EHRs over each node’s ITS-REST API:
PUT /v1/ehr/{ehr_id} with the patient on EHR_STATUS.subject, the
reference implementation’s International Patient Summary template, and one
of its demo compositions per EHR. A second up reports what is already
there and adds nothing. Every identifier lies in the
example arc urn:oid:2.999:
Patient, in urn:oid:2.999.1.1 | Node A | Node B | Node C | Node D |
|---|---|---|---|---|
ffd-test-0001 | aaaaaaaa-…-000000000001 | bbbbbbbb-…-000000000001 | cccccccc-…-000000000001 | dddddddd-…-000000000001 |
ffd-test-0002 | bbbbbbbb-…-000000000002 | dddddddd-…-000000000002 | ||
ffd-test-0003 | cccccccc-…-000000000003 | |||
ffd-test-0004 |
The first group of each ehr_id names its node and the last group its
patient.
A federated query
Ask for the compositions of the patient every node knows. Every request carries an access token, which the development issuer signs for the quickstart’s client (The quickstart issuer):
TOKEN="$(curl -s http://127.0.0.1:8190/quickstart/token -d grant_type=client_credentials \
-d client_id=quickstart-client -d client_secret=any | jq -r .access_token)"
curl -s http://127.0.0.1:8080/v1/query/aql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d @- <<'EOF'
{"q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c WHERE e/ehr_status/subject/external_ref/id/value = 'ffd-test-0001' AND e/ehr_status/subject/external_ref/namespace = 'urn:oid:2.999.1.1'"}
EOF
The gateway resolves the patient to the four ehr_ids, sends each node an
ordinary AQL query scoped to its own ehr_id with no patient identifier in
it, and answers 200 with one RESULT_SET: four rows, one composition from
each node, its uid carrying the system_id of the node that created it.
meta.federation.complete is true, and meta.federation.endpoints reports
all four endpoints active with row_count 1.
A patient missing at some nodes
Send the same query for ffd-test-0002. The answer is 200 with two rows,
from node B and node D. Node A and node C hold no EHR for the patient, so the
gateway asks neither of them and reports both not-resolved, with no
latency_ms, and meta.federation.complete is false. A not-resolved
member is an answer and never fails the query (§11.3, N6, N37). For
ffd-test-0003 only node C answers, and for ffd-test-0004, which no node
holds, the answer is 200 with no rows and all four endpoints
not-resolved.
A query directed at one node
Name the node in the query with the endpoint directive, and project its attributes beside the data (§8, §9.4):
curl -s http://127.0.0.1:8080/v1/query/aql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d @- <<'EOF'
{"q": "SELECT p/id AS endpoint_id, p/system_id AS system_id, c/uid/value AS composition FROM ENDPOINT p [\"node-c-query\"] CONTAINS EHR e CONTAINS COMPOSITION c WHERE e/ehr_status/subject/external_ref/id/value = 'ffd-test-0001' AND e/ehr_status/subject/external_ref/namespace = 'urn:oid:2.999.1.1'"}
EOF
Only node C is asked. The one row reads node-c-query,
node-c.quickstart.local and the composition’s uid; the other three
endpoints are reported excluded, “not named by the request’s endpoint
directive”, and meta.federation.complete is true, because an excluded
member was never in scope. The header form selects the same way without
changing the query: send the patient query above with
-H 'openEHR-federation-endpoint: node-b-query' and only node B answers.
Memory footprint
Measured on 2026-10-03 with docker stats --no-stream, on an Apple silicon
Mac running Docker Desktop with 8 CPUs and 7.65 GiB for its virtual machine,
the images already pulled:
| When | Gateway | PostgreSQL | Each FerroEHR node | Total |
|---|---|---|---|---|
right after docker compose up --wait (22 s) | 1.8 MiB | 145 MiB | 23 MiB | 238 MiB |
| after the seed and the queries on this page | 2.2 MiB | 185 MiB | 54 to 65 MiB | 425 MiB |
The Kubernetes package
FerroFED ships a kustomize package for Kubernetes, deploy/kubernetes/, and
every release attaches it with each image pinned by digest. It has three
parts:
| Part | What it holds |
|---|---|
base/ | the gateway: its StatefulSet, ConfigMap, Service, the headless Service of its admin listeners, its PodDisruptionBudget and its NetworkPolicy |
components/ | the parts you add: the operator console, an Ingress or a Gateway API route for each, a Prometheus Operator ServiceMonitor, and an egress NetworkPolicy |
overlays/example/ | an overlay over the base with every component, the shape a production install takes |
The base is the StatefulSet that The container image
describes, with the probes, the audit spool claim per replica and the
hardened security context. CI renders the base and the example overlay with
kustomize and validates every object with kubeconform in strict mode, and
runs ferrofed config check over the base’s configuration and the
console’s configuration check over the console component’s, each with
synthetic secrets.
Installing a release
Each release attaches ferrofed-vX.Y.Z-kubernetes.tar.gz and its SHA-256.
The tarball is the tree of that tag, with the gateway’s image pinned to its
digest in base/kustomization.yaml and the console’s in
components/console/kustomization.yaml, so the pods run exactly the image
the release scanned and attested:
gh release download vX.Y.Z --repo FerroHEALTH/FerroFED \
--pattern 'ferrofed-vX.Y.Z-kubernetes.tar.gz*'
sha256sum -c ferrofed-vX.Y.Z-kubernetes.tar.gz.sha256sum
tar -xzf ferrofed-vX.Y.Z-kubernetes.tar.gz
Make an overlay of your own beside it, which names the base and the components you use, and keep your edits there:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ferrofed
resources:
- ../ferrofed-vX.Y.Z-kubernetes/base
components:
- ../ferrofed-vX.Y.Z-kubernetes/components/console
- ../ferrofed-vX.Y.Z-kubernetes/components/ingress
- ../ferrofed-vX.Y.Z-kubernetes/components/console-ingress
- ../ferrofed-vX.Y.Z-kubernetes/components/service-monitor
- ../ferrofed-vX.Y.Z-kubernetes/components/egress
Every value to replace is marked EDIT in the manifest that holds it: the
issuer and audience of [auth], the members of registry.toml, the storage
class of the audit spool, the namespaces of your reverse proxy and your
Prometheus, the hosts and certificates of the routes, and the egress
destinations. Create the Secrets the manifests name, then apply:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out signing-key.pem
kubectl -n ferrofed create secret generic ferrofed-secrets \
--from-file=signing-key.pem
kubectl -n ferrofed create secret generic ferrofed-console-secrets \
--from-file=client-secret=./console-client-secret
kubectl apply -k my-overlay/
A deployment with a contact-point issuer, or with the stored-query
registry on backend = "postgres", also sets [store] in
configmap.yaml and adds its connection string to ferrofed-secrets as
store-url (--from-file=store-url), with store-ca.pem when its CA is
not one of the platform’s roots (FerroFED’s own store). Every
replica shares that one database.
The tree in the repository names the images by tag alone; only the release
tarball pins the digests. To pin them yourself, add an images entry with
the digest of the image you verified
(Verifying the image).
The components
| Component | What it adds |
|---|---|
console | the operator console: a Deployment of one replica (its sessions live in its memory), its ConfigMap, its Service and a NetworkPolicy that admits the reverse proxy alone. It reaches the gateway’s Service, and each replica’s admin listener at ferrofed-0.ferrofed-admin and so on, for the configuration page |
ingress, console-ingress | an Ingress for the gateway and for the console, over TLS |
gateway-api, console-route | a Gateway API HTTPRoute for each, behind a Gateway whose HTTPS listener terminates TLS; use these or the Ingresses |
service-monitor | a Prometheus Operator ServiceMonitor that scrapes each replica’s admin listener through the headless Service, with a client-credentials token of the scrape client, its secret from the ferrofed-scrape Secret (Metrics) |
egress | an egress NetworkPolicy: DNS, and the destinations you list |
Behind either proxy, list the proxy’s addresses in server.trusted_proxies
so the access record names the client’s own address, and give the proxy a
body limit and a timeout at least server.body_limit and
server.request_timeout (A production deployment).
Network policies
The base’s NetworkPolicy admits two sources to the gateway pods, and nothing else:
- the gateway port: your reverse proxy’s namespace, and the console pods;
- the admin port: your Prometheus pods, and the console pods.
A client inside the cluster that calls the gateway directly, past the proxy, needs a peer of its own under the gateway port. The policies hold only where the cluster’s network plugin enforces NetworkPolicy, and they add to client authentication, never replace it (Hardening).
Egress stays open in the base, because the gateway reaches its members, its
identity services and its issuers wherever they are. The egress component
closes it to DNS and the destinations you list, each one the configuration
names: every member endpoint of registry.toml, the issuers of [auth] and
[[trust.issuer]], the Audit Record Repository, each identity service a
binding names, the token endpoint of each onward grant, the OTLP collector,
FerroFED’s own store ([store]) or a revision PostgreSQL, and the usage report’s collector
unless the report is off. A NetworkPolicy matches addresses, so a
destination behind changing addresses needs a CIDR wide enough to hold
them. A destination you leave out is refused: a member shows offline and
a service unavailable in GET {base}/operator/dependencies.
Spread, disruption and stopping
The StatefulSet spreads its replicas over zones and over nodes
(topologySpreadConstraints with ScheduleAnyway), so losing one zone or
one node leaves a replica serving, while a cluster with one zone or one node
still schedules both. The PodDisruptionBudget keeps one replica serving
through a node drain.
A pod’s stop has two holds before the drain. The preStop hook sleeps for 5
seconds, matching server.drain_delay, while the endpoint controllers and
the proxies stop sending the pod new requests; the gateway then turns
readiness to 503, keeps accepting for the drain delay, and drains
(Stopping without dropping a request).
terminationGracePeriodSeconds: 50 outlasts the sleep, the delay, the drain
and the bindings’ drain with 5 seconds of room, which
scripts/checks/kubernetes-example.sh holds in CI. Change the sleep with the
delay, and the grace period with either.
Reloading a changed configuration
The ConfigMaps keep fixed names, so kubectl apply -k updates them in place
and no pod restarts. The kubelet then writes the new files into each pod’s
mounted volume, within its sync period, which is about a minute by default
(ConfigMaps).
The gateway never watches its files, so tell each replica to reload once the
files have changed. The image has no shell to send SIGHUP from, so ask each
replica’s admin listener, with an access token that carries
auth.product_scope:
for pod in $(kubectl -n ferrofed get pods -l app.kubernetes.io/component=gateway -o name); do
kubectl -n ferrofed port-forward "$pod" 9464:9464 &
forward=$!
sleep 2
curl -fsS -X POST -H "Authorization: Bearer $OPERATOR_TOKEN" http://127.0.0.1:9464/admin/reload
kill "$forward"
done
Each replica reads the configuration again, checks it whole and applies it
or refuses it, and its answer names what changed and what waits for a
restart (Reloading the registry,
Reloading over the admin listener).
A key that needs a restart takes effect on the next rollout, kubectl -n ferrofed rollout restart statefulset/ferrofed. Under config.ui = "managed" the operator console saves, applies and confirms a revision, and
every replica follows it with no reload by hand
(Configuration revisions).
The console reads its configuration at start alone: after a change to
ferrofed-console-config, restart it with kubectl -n ferrofed rollout restart deployment/ferrofed-console.
Upgrading
This page says how to move a gateway to a newer release, what each release asks you to change, and which configuration changes FerroFED makes without notice and which it never does. Rollback says how to go back. No specification governs this page: our own design.
Before you upgrade
-
Read the release’s Upgrade notes, the first section of its changelog entry. They list every change you must make: a key that became mandatory, a value that is now refused, a key renamed, a file to move. The notes for the releases so far are below.
-
Keep a copy of the configuration you run now,
ferrofed.tomland the registry document, and note the image digest or binary version. A rollback needs both. -
Back up the stored-query store when
[stored_queries]is set, and FerroFED’s own store when[store]is set: copy theredbfile while no gateway has it open, or dump theferrofedschema of the PostgreSQL database (pg_dump --schema=ferrofed). The new release may migrate a store, and an older release refuses a store a newer one migrated. -
Run the new binary’s
config checkover the configuration, with the upgrade notes applied. It reads every file and secret asservedoes, binds no socket, and exits78naming each key it refuses. With the image:docker run --rm --volume ./ferrofed.toml:/etc/ferrofed/ferrofed.toml:ro \ --volume ./secrets:/run/secrets/ferrofed:ro \ ghcr.io/ferrohealth/ferrofed:<new version> config checkA key the new release renamed or removed is refused by name, with the key that replaces it, so fix each one the upgrade notes list before you roll out.
Then roll the new release out: docker compose up --wait with the new
compose.yaml, or a rolling update of the StatefulSet. Readiness answers
503 until the new process has loaded the configuration and the store.
Several replicas on one PostgreSQL store
A replica migrates FerroFED’s own store ([store])
when it starts, under an advisory lock, so replicas starting together apply
each migration once. Once one replica of a release that brings a new
migration has started, a replica of the older release refuses its next
connection to the store (Rollback).
When the upgrade notes name a new migration, upgrade every replica in one
rollout and expect the routes of the old replicas that need the store to
answer 500 until they are replaced. A release whose notes name none can be
rolled out replica by replica.
The compatibility policy
FerroFED has not released 1.0. Until it does, any release can change the configuration, and these rules bound how.
- A change that stops a configuration the last release accepted is in
the Upgrade notes. A key made mandatory, a value now refused, a table
moved: each is listed with what to set instead. CI runs the new build’s
config checkover the example configuration the last release attached, and refuses a change it rejects unless an upgrade note is pending. - A key can be renamed or removed in any release. Before 1.0 there is
no deprecation release: the release that renames or removes a key stops
reading the old one. It refuses the old key at start-up and in
config check, in the file and as aFERROFED__override: a renamed or moved key naming the key that replaces it, and a key removed with nothing in its place by its name. Its upgrade notes give the step. That refusal lives for the one release that renames or removes the key; from the next release on, the key is refused as any unknown key is, and the upgrade notes of the release that removed it stay the record. - A new key is optional, or the Upgrade notes say otherwise. A release that adds a key gives it a default that keeps the behaviour of the release before, unless the change is a security fix that cannot keep it.
- An older release refuses a key it does not know. Every table refuses unknown keys, so a gateway rolled back to an earlier release refuses a key added since. Remove the keys the newer release added before rolling back (Rollback).
- The environment follows the file. A
FERROFED__override names the same key as the file, so a rename applies to it too, and the refusal names the dotted key. - Stored data migrates forward only. A stored-query store records its schema version, is migrated forward in order when the gateway starts, and is never migrated back. A release refuses to start over a store a newer release migrated.
Only the newest release is supported. When a new release is published,
every earlier release stops receiving fixes: there are no maintenance
branches, no long-term-support line and no backports. A security fix ships
as a patch release of the newest release, with the fix alone or the reason
a functional change ships with it stated in the notes, and a published release is never changed, so upgrade to the newest release
to get the fix; upgrading is free
(SECURITY.md).
Upgrade notes by release
To v0.0.20, from v0.0.19
The keys v0.0.19 refused by name, the unit-suffixed keys such as
federation.per_node_timeout_ms, the url, credentials, tls and
timeout_ms keys of every outbound table that moved under its endpoint
table, a node’s user, password, password_file, oauth and onward
outside its nested credentials table, [trust.issuer.patient] act_is_representative, and the operator console’s [gateway] timeout_ms,
are now refused as unknown keys. Move them as the notes for v0.0.19 below
say before you upgrade.
- 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(Authentication, #1615). - Move a
[[trust.issuer]] professional_issuing_authority = "<claim>"to that table’sfields."professional.issuing_authority_name" = [{ claim = "<claim>" }]. The old key refuses the start, naming its replacement (#1615). - Give an imaging report receiver (
[[fhir.receive]]of the imaging category) FerroBRIDGE’s imaging report template at v0.0.24, and add the CKMCLUSTER.organisation.v1it slots to its archetypes. The imaging mapping this release ships does not compile against the earlier revision, and the gateway refuses to start with it. Both are vendored underdocs/specs/ferrobridge-imaging/(#1860). - Run
docker compose down -vonce before you start the quickstart on this release. Its FerroEHR 5.7.0 nodes need the professionalPERSONandROLEtemplates and the persons a quickstart volume from an earlier release does not hold (#1615). - A gateway that writes to a FerroEHR 5.7.0 node reaches it under the
caller from this release, through an onward lane of kind
personson each of the node’s endpoints. Once every FerroEHR endpoint has its onward lane, add the node’spersonsissuers, as Onward credentials says (#1615).
To v0.0.19, from v0.0.18
The keys v0.0.18 refused by name, the outbound TLS keys outside a tls
sub-table, a node’s oauth2 and fapi2 tables and mtls_alias_hosts, the
removed [trust.issuer] tables and client_tokens_act_for_professional,
and [stored_queries] url and url_file, are now refused as unknown keys.
Move them as the notes for v0.0.18 below say before you upgrade.
- 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).
To v0.0.18, from v0.0.16
v0.0.17 was tagged and never released; these are its notes, which v0.0.18 ships.
- 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).
To v0.0.16, from v0.0.15
- 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. This release refuses each old key at start-up and inconfig check, naming it, as it refuses any unknown key (#1490). - Remove
[server] site_rootand itsFERROFED_CONSOLE__SERVER__SITE_ROOToverride from 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). - Nothing in the operator console’s sign-in configuration has to change
(#1504). The
[oidc]endpoints are now optional: removeauthorization_endpoint,token_endpointandjwks_urito have the console read them fromissuer’s discovery document when it starts, which then needs the provider reachable at start. A reverse proxy in front of the console passes/sessionthrough to it.
To v0.0.15, from v0.0.14
- Move
server.public_url,server.replicaandserver.bindings_drain_timeout_msto a new[gateway]table, and renameserver.overload_retry_after_stoserver.retry_after_s. That release still read each old key and named it inconfig checkand the start-up log; FerroFED 0.0.16 refuses it. TheFERROFED__overrides follow, asFERROFED__GATEWAY__PUBLIC_URL(#1408). - Remove
base_path = "/", or writebase_path = "": the root is the empty path, and a/is refused. Give[server.caller_rate]bothrequests_per_secondandburst, which no longer default (#1408). - Move the OTLP keys into one
[telemetry.otlp]table:endpoint,sample_ratio,trust_roots_file,client_identity_file, andtracesandmetrics, both on unless set. The old[telemetry]and[metrics]OTLP keys and[telemetry] logged_query_parametersare refused as unknown, and an inlineclient_identityis no longer read (#1397). - A
[server.tls]or[metrics.tls]table withclient_ca_fileneedsclient_auth = "required"beside it to keep requiring a client certificate. A listener whose clients speak only TLS 1.2 needsmin_version = "1.2"(#1397). - Point a liveness probe at
{base}/health/liveness.{base}/healthfollows readiness now and answers503while the gateway boots or drains, and{base}/health/readinesslistsindicatorsas an array. The example Kubernetes manifests probe the new routes (#1397, #1445). - A probe, log rule or alert that matched the
readyornot readyline offerrofed healthcheckmatcheshealthyandunhealthyinstead (#1453). - A client that read
messagefrom an error body outside the FHIR face readsdetailof the RFC 9457 problem details document;codeandrequest_idstay (#1412). - A secret file loses only its trailing line ending: remove any space or tab that sits at either end of a secret by accident (#1397).
- A log alert on the panic line matches the target
ferrohealth_telemetry::panicand itsfileandlinefields (#1421).
To v0.0.14, from v0.0.13
- Remove
[dev]from your configuration:[[dev.crossref]]and[[dev.consent_denied]]are refused at load, naming the table. Resolve patients through a PIX Manager with[[pixm.manager]], as the quickstart does with FerroPIX. With thebinding-nlfeature the gateway carries the IHE binding too, because the Dutch binding resolves patients through FerroPIX’s PIXm Manager (#1377). - 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). - A verified HTTP Basic credential of
[[auth.basic.users]]is now kept for 300 seconds by default. Setauth.basic.cache_ttl_seconds = 0to check every request, as before (#1390). - An access record writes its security labels once, on its
ehds-categoriesandoriginentities, and nometa.security. A repository rule or search that filtered on_securityreads the entities’securityLabelinstead (#1390). - The quickstart runs FerroPIX beside the gateway, and its nodes run
FerroEHR 5.5.0. If you edited
compose.yamlor the quickstart’s FerroPIX file, carry their renames over as the changelog lists them, and rundocker compose down -vonce before the first start (#1377, #1386).
To v0.0.13, from v0.0.12
- 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). - The quickstart
compose.yamlnow sends the usage report by default, as the release’s compose file has since v0.0.12, with its instance id on the newferrofed-statevolume. SetFERROFED__USAGE_REPORT__ENABLED=false, andFERROEHR__USAGE_REPORT__ENABLED=falsefor the four nodes, in your shell or.envto keep it off (#1296). - A bookmark of the operator console’s
/configurationnow opens the Federation behaviour settings tab; the members are at/configuration/members(#1235).
To v0.0.12, from v0.0.11
- 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.
To v0.0.11, from v0.0.10
- If the operator console’s
[oidc] scopeslistsoffline_access, remove it from the list and set[oidc] offline_access = trueinstead (#643). The console now refuses the scope in the list, and the new setting asks for it withprompt=consentso the console can refresh the operator’s access token. - Check the templates of every member that receives documents on the FHIR
face (
[[fhir.receive]]) before you upgrade (#714). The mapping now takes a composition’scategoryfrom the template: its assumed value, its one admitted code, oreventwhen the template leaves the category open. A template that admits severalCOMPOSITION.categorycodes and assumes none now refuses every received document of its category with a422(MissingRequiredSuffixon<root>/category|code), whereeventwas taken before. Give the template’scategoryan assumed value (AOM 1.4assumed_value) and upload it to the member again. ferrofed_node_requests_totalcarries a new label,class:memberfor a member’s own answer or failure,client-errorfor a4xxit answered a routed request with (#982). 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; take the rules and the dashboard this release attaches.
To v0.0.10, from v0.0.9
- 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.
To v0.0.9, from v0.0.8
- If you run the operator console with
[session] secure_cookie = false, either set it totrueor make[oidc] redirect_urianhttpURL on a loopback host: the console refuses any other combination (#615). - A stored-query store that holds a definition naming its patient by a
literal, written past the gateway by a restore or a manual insert, refuses
the start (#586). Remove that row, or the file, and store the definition
again with a
$parameter. - The released binary and image carry both bindings, as before. A build of
your own with
--no-default-featuresneeds--features binding-iheorbinding-nlfor the sections it uses, or refuses them as unknown keys (#489). - The
configuration resolvedlog line no longer carries the registry directory, PIX Manager, PMIR and PDQm fields; they are on the IHE binding’s ownbinding configuredline (#489). Update a log query that reads them.
To v0.0.8, from v0.0.7
- Every caller authenticates. A gateway that federates refuses to start
without an
[auth]table that trusts at least one issuer ([[auth.issuer]]andauth.audience), and every request to the ITS-REST surface andOPTIONS {base}/needs an access token from that issuer with a SMART on openEHR scope and a purpose of use, unlessauth.purpose_of_use.required = false(#80; Client authentication). Issue tokens to your clients before you upgrade. [signing]is mandatory whenever a registry is configured, byregistry.documentor[registry.mcsd]: add a P-384 or P-256 signing key and thejwks_uriit is published at, and make{base}/.well-known/jwks.jsonreachable from the nodes’ authorization servers (What a node is told about the caller).[audit] destinationis mandatory for a production gateway with[pixm],[registry.mcsd]or[pmir]. Set"log"to keep the behaviour of v0.0.7 while you add an Audit Record Repository;"off"is refused outside development (#486; The audit trail).[xcpd] auditis mandatory:"log","repository", or"off"under the development profile alone (#410). With"repository",[xcpd.audit_repository] spool_diris mandatory outside development and[xcpd] home_communityis mandatory.- The ADMIN API under
{base}/v1/admin/answers403 operation-refused, where it answered501.
Unreleased
The pending notes are the *.upgrade.md files under
changelog.d/,
which the next release lists under its Upgrade notes.
Check before upgrading: a receiving member’s template that admits several
COMPOSITION.category codes and assumes none now refuses every received
document of its category (MissingRequiredSuffix on <root>/category|code),
where the composition took event before. Give the template’s category an
assumed value (The template’s category).
Rollback
This page says what going back to an earlier release does to each thing the gateway keeps, and how to do it. Upgrading says what to keep before an upgrade so that a rollback is possible. No specification governs this page: our own design.
The gateway holds no clinical data. What a rollback has to care about is
the configuration, the stored-query store and the audit spools. The
resolution bindings, the ehr_id index and the learned creating_system_id
routes live in memory and are rebuilt after any restart.
The configuration
Every table refuses a key it does not know, so an earlier release refuses
any key added since. Go back to the configuration you kept before the
upgrade, or remove each key the newer release added: the newer release’s
changelog lists them under Added, and an earlier release’s config check
names each one it refuses.
docker run --rm --volume ./ferrofed.toml:/etc/ferrofed/ferrofed.toml:ro \
--volume ./secrets:/run/secrets/ferrofed:ro \
ghcr.io/ferrohealth/ferrofed:<earlier version> config check
A key the newer release renamed was still read under its old name for at least one release (the compatibility policy), so a configuration that uses the old name works on both sides of that release.
The stored-query store
A writable store records its schema version: the redb file in its
ferrofed_schema table, FerroFED’s own PostgreSQL store each migration in
ferrofed.store_migration (FerroFED’s own store). A
release migrates the store forward when it starts, and refuses to start over
a store at a version newer than it knows, with a message that names both
versions:
the redb stored-query store is at schema version 2, and this FerroFED knows
versions up to 1: a newer FerroFED wrote it; run that version, or restore the
backup taken before the upgrade
It never rewrites that store. To roll back across a schema version, stop
every gateway, restore the backup you took before the upgrade (the redb
file, or the ferrofed schema with pg_restore), and start the earlier
release. A definition stored after the upgrade is lost with the restore;
store it again. To keep the newer definitions instead, stay on the newer
release.
Schema version 1 is the layout every release so far has written, and
migration 1 of FerroFED’s own store is the stored-query table as the
earlier releases wrote it. Releases before the version was recorded open
the redb file and the PostgreSQL table as before and leave the version
table alone, so a rollback to one of them needs no restore while the store
is at version 1. A release before [store] reads the PostgreSQL connection
string from [stored_queries] url or url_file and refuses [store], so
move the string back before you start it
(Moving from [stored_queries] url).
With several replicas on one PostgreSQL store, a replica of the earlier release refuses its next connection once a newer one has migrated the store. Roll every replica back together, after the restore.
The audit spools
The ATNA spools ([audit] spool_dir, [xcpd.audit_repository] spool_dir)
hold the audit records the gateway has not yet delivered. Before a rollback,
let them drain: wait until GET {base}/operator/dependencies reads up for
the audit repository, then stop the gateway. A spool the earlier release
cannot read is not lost: a file it does not recognise refuses the start,
naming the file, and a record it cannot parse is moved to the spool’s
quarantine directory (The audit trail).
The image and the binary
Pin the earlier release by its version tag, or better its digest, in
compose.yaml (FERROFED_VERSION) or in the StatefulSet, and roll it out
as you rolled out the upgrade. Every release attaches the example
configuration it accepts, which is a starting point for the configuration
you go back to.
Decommissioning
This page takes a FerroFED deployment out of service and removes what it holds. Regulation (EU) 2024/2847, the Cyber Resilience Act, asks for instructions on “the secure decommissioning of the product with digital elements, including information on how user data can be securely removed” (Annex II, point 8(d)), and CRA information to the user points here. The order of the steps is our own design: no specification governs it.
The gateway holds no clinical data. It keeps the routing state it learned in memory, a few stores on disk, and the records waiting in its audit spools; everything else it handed to another system, which keeps it (What the gateway keeps, Retention).
What a deployment holds
| What | Where | What happens to it |
|---|---|---|
Resolution bindings, the ehr_id index, the learned creating_system_id routes | each replica’s memory | lost when the replica stops; export the routes first to move them to a replacement |
| Stored-query definitions | the [stored_queries] backend: a redb file, the ferrofed schema of FerroFED’s own PostgreSQL store ([store]), or read-only definition files | parameterised AQL, never a patient identifier; you remove it |
Configuration revisions, under config.ui = "managed" | config.path (a redb file) or the ferrofed schema of the config.url database, and the secret files the console wrote to config.secrets_dir | every revision with who made it and who approved it, and the secret values set from the console; you remove them |
| IHE audit records and access records not yet delivered | the spools under audit.repository.spool_dir and xcpd.audit_repository.spool_dir, with their quarantine subdirectories | name patients and callers; delivered while a replica runs, then removed with the volume |
| Access and audit records delivered | your Audit Record Repository | kept for their retention period, never removed by decommissioning the gateway |
| Log lines, metrics and spans | your log, metrics and trace stores | kept as those stores keep them |
| The configuration, the registry document and the secrets | the files and the environment you give the gateway | you remove them, after the keys are retired |
| Operator console sessions | the console’s memory | end when the console stops |
1. Move what a replacement needs
Skip this step when nothing replaces the gateway.
- The learned routes. Export them from the admin listener before the
first replica stops; a stopped process has forgotten them
(Exporting the identity state). The bindings
and the
ehr_idindex are not exported: a replacement learns them again. - The stored queries. Copy the
redbfile while no gateway has it open, or dump the schema withpg_dump --schema=ferrofed, as before an upgrade (Before you upgrade). - The configuration. Keep
ferrofed.tomland the registry document. Underconfig.ui = "managed", export the running revision as TOML (Export and import); the export names each secret by its file and never holds its value.
2. Let the audit spools deliver
A record in a spool exists nowhere else, and only a running gateway delivers it. A lost spool loses records of transactions that did happen (Losing a spool).
- Stop sending the gateway client traffic, so no new record is written.
- Read
GET {base}/operator/dependencieson each replica until each ofaudit_repositoryandaudit_feedthat it lists readsup. Each readsdegradedwhile a record waits in its spool or sits in its quarantine (The dependency report). - A record in
quarantinewas refused by the repository and is never sent again. Read why it was refused before you remove it (The spool and the failure policy).
A replica stopped with records still waiting delivers them when it starts again on the same volume.
3. Stop the replicas
Stop each replica with SIGTERM, so it drains: readiness answers 503, the
requests in flight finish, and the bindings stop their processes
(Stopping without dropping a request).
With [pmir], the gateway holds a subscription at the Patient Identity
Registry. Set on_drain = "unsubscribe" on the last replica before it
stops, or on every replica that has its own callback_url; otherwise delete
the subscription at the Registry
(Several replicas). The drain logs a warning
when a binding did not finish stopping in
gateway.bindings_drain_timeout, since what it holds at a remote service
may be left there; delete it at that service.
Stop the operator console as well. Its sessions live in its memory and end with it (The operator console).
4. Keep the records for their retention period
The access records are kept after the gateway is gone. Regulation (EU)
2025/327 keeps the information on each access “available for at least three
years from each date of access” (Art 9(2)), and each access record states
the first date it may be deleted, ehds-retention-ends, for your Audit
Record Repository to apply
(How long a record is kept).
Keep the repository, or move its records to the archive that replaces it,
until the last record’s ehds-retention-ends has passed. The IHE audit
records follow your repository’s own policy, and the log pipeline follows
your records of processing
(Retention).
5. Retire the keys
The gateway signs the openEHR-federation-client token of every request
to a node with the key of [signing], each oauth grant’s client
assertions with its private_key_file, and publishes the public keys at
{base}/.well-known/jwks.json
(Signing keys and the JWK Set).
- Once the last replica has stopped, no token is signed. A token it signed
lives at most 300 s. Where
jwks_uripoints somewhere other than the gateway, remove the gateway’s keys there once that time has passed. - Ask each member to remove what it recorded when it admitted the gateway:
the gateway’s
jwks_uriand theissits tokens carry (Verifying it at the node). A member that still trusts the keys would accept a token signed with a copy of them. - Ask each party that issued the gateway a credential to revoke it: the
client each member’s authorization server registered for an
oauthgrant, the client certificate of mutual TLS (Onward credentials), the credentials of the identity services, the PMIR feed token (Signing keys and rotation), and the client the operator console is registered as at its OpenID Connect provider (The operator console). - Destroy the private keys and the secret files: every
[signing]key file, eachclient_identity_file, eachprivate_key_file,previous_private_key_file,next_private_key_fileanddpop_key_file, and the files underconfig.secrets_dir.
6. Remove the stores and the volumes
- The stored-query store, FerroFED’s own store and the revision store.
Delete the
redbfiles. FerroFED’s own store ([store]) and the PostgreSQL revision backend keep their tables in theferrofedschema; drop it, or the database, once nothing else you keep is in it. - The spool volumes. Delete each replica’s spool volume once step 2 holds for it. The records in it name patients, so the volume sits on an encrypted disk (The audit spool); remove it as your storage removes an encrypted volume.
- The configuration. Delete the configuration file, the registry
document and the environment that held
FERROFED__values. - The images and binaries. Delete them from your hosts and your registry mirror. A published release stays on the releases page and in the image registry; its removal from your environment is yours.
Configuration
The ferrofed binary reads one TOML file and the environment. It serves the
process shape (health, readiness, the request log, graceful shutdown) and, once
a registry is configured, the federated query POST {base}/v1/query/aql, the
EHR resources and the definition area routed to one node, and, when
[stored_queries] is set, the stored-query registry. The DEMOGRAPHIC area
answers 501 unless federation.demographic_endpoint declares the one
endpoint that serves it, which a request reaches without a targeting header,
and every other path under
{base}/v1/ answers 501. {base} is / unless you set
the base path.
Three pages carry the rest of the configuration: the registry,
its document and the state learned from it; identity
resolution, the cross-reference that finds each member’s
ehr_id; and queries and API areas, the settings a
federated query and each ITS-REST area run under. What the gateway sends a
node lists the id and the headers of each request to a
node.
Running it
ferrofed serve --config /etc/ferrofed/ferrofed.toml
ferrofed config check --config /etc/ferrofed/ferrofed.toml
ferrofed healthcheck --config /etc/ferrofed/ferrofed.toml
ferrofed report --config /etc/ferrofed/ferrofed.toml
--config names the file; without it the file is the one FERROFED_CONFIG
names, and without that every default stands. config check reads and
resolves the configuration exactly as serve would, secrets included, loads
a read-only stored-query directory, prints one line and exits, so a deployment
pipeline can test a file without binding a socket. It opens no store file,
connects to no database and writes nothing to disk, so it runs on a
read-only root filesystem without the gateway’s durable volume. An audit
spool directory is checked where it stands and never created: one that
exists must give its owner write access and its group and other users none,
and hold no file the gateway did not write; one that does not exist needs a
parent directory that does, whose mode lets a user write in it. Only the
modes are read, so whether the gateway’s own user may write there, and on a
filesystem mounted writable, is left to serve, which creates the directory
and refuses to start, naming its key, when it cannot. ferrofed report
writes the archive a complaint or a serious-incident report attaches,
described on the
Complaints and incidents page.
ferrofed admission check --endpoint <id> checks
one member against the admission conditions (Admitting a node).
healthcheck asks the gateway running on this host for its readiness and
exits 0 or 1, the two codes a container runtime’s health check reads
(Health probes).
A configuration the gateway refuses exits with code 78 (EX_CONFIG) and one
line naming the key at fault. It refuses an unknown key, a value of the wrong
type, a zero timeout or body limit, a log filter that does not parse, a secret
set both inline and through its _file sibling, a credentials section
that names no scheme or two, and a credential the Authorization header
cannot carry: a basic user or password holding a control character such as a newline (RFC 7617
§2), or a basic user holding a colon. That refusal names the key the value came from, and never
the value. The gateway never falls back to a default for a value you set.
A key a release renamed is still read under its old name for at least one
release. config check prints a warning: line for it, naming the key that
replaces it and the release that will refuse it, and serve logs the same
at start; setting both the old and the new key is refused
(the compatibility policy). No key
is deprecated in this release.
With a FHIR face ([fhir]), config check also prints a note: line for
each Art 14(1) category the face receives, naming the member its
[[fhir.receive]] entry declares, and one line naming the categories this
release carries that no member receives. These are the categories
GET {fhir-base}/metadata names
(Receiving a document).
A configuration with no [fhir] gets neither line.
ferrofed: note: [[fhir.receive]] Patient-Summaries: the FHIR face receives documents of this Art 14(1) category and writes them to the member node-a
A face with no [[fhir.receive]] entry prints this line instead:
ferrofed: note: [[fhir.receive]] names no receiving member for Patient-Summaries: the FHIR face refuses a document of this Art 14(1) category
It also prints a line for each category served resource by resource,
naming the members its [[fhir.serve]] entry declares, and one line naming
the categories no member serves, which the face answers as not served
(Prescriptions and dispensations):
ferrofed: note: [[fhir.serve]] Electronic-Prescriptions: the FHIR face serves this Art 14(1) category from the members node-a, each on its own endpoint
ferrofed: note: [[fhir.serve]] names no member for Electronic-Dispensations: the FHIR face answers this Art 14(1) category as not served
The startup banner
When the log renders for a person, serve prints a banner before the first
log line: the family’s banner with the wordmark and the version, then one
line each for the product, the maintainer, the manufacturer with
its contact and postal address, the releases the gateway serves, the end
date of its support period, and the deployment facts to check first. Built from
source and run on a terminal with the development configuration of
the quickstart, it prints this:
_____ _____ _____ ____
| ___|__ _ __ _ __ ___ | ___| ____| _ \
| |_ / _ \ '__| '__/ _ \| |_ | _| | | | |
| _| __/ | | | | (_) | _| | |___| |_| |
|_| \___|_| |_| \___/|_| |_____|____/
ferrofed 0.0.9
Product openEHR federation gateway
Maintainer Ruben Talstra · https://github.com/FerroHEALTH/FerroFED
Manufacturer Cadasto B.V. · info@cadasto.com
Address Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands
Federation Tier 1.0.1
ITS-REST 1.1.0
AQL 1.1.0
openehr-* 0.0.87
Support none, this build is no release
Base path /
Listen 0.0.0.0:8080
Registry 4 members, 4 endpoints
Stored queries off
Unencrypted credentials.node-a-query
credentials.node-b-query
credentials.node-c-query
credentials.node-d-query
DEVELOPMENT: this deployment runs the development profile, which relaxes
checks a production deployment must pass, such as encryption to a loopback
service. It must not hold or reach real patient data.
The Support line of a release from v0.0.10 on says that only the newest
release is supported and names the last day of its support period, until
which fixed releases are published, as
newest release only, fixes until YYYY-MM-DD, five years from its release
date (SECURITY.md).
The release build compiles the date in, and the gateway never reads it from
the network. A build from source and a pre-release have no support period,
and the line says so. Once the date has passed, the line reads
ended on YYYY-MM-DD, and an UNSUPPORTED notice under it says the release
receives no security fixes and to upgrade to the newest release, in red on
a terminal with colour. config check
prints the same notice as a warning: line, serve logs it at WARN, and
OPTIONS {base}/ names the date as supported_until, or null for a build
with none (Regulation (EU) 2024/2847 Art 13(19)).
The Registry line counts the members and endpoints of the registry
document. The gateway reads the document once, before the banner, and serves
that same read, so the counts are those of the registry it serves. The line
reads none when no document is set, and says the document does not load
when it cannot be read, in which case the boot stops on the next lines with
the reason. The development notice prints only under
profile = "development", in red on a terminal with colour and in the same
words without it, and an operator reads the same profile from
GET {base}/operator/deployment
(The operator surface). The
Unencrypted lines name, by key, each credential or
patient identifier that travels unencrypted, which only the development
profile allows
(What must travel encrypted).
Colour follows the terminal, and an explicit format = "pretty" keeps it
into a pipe. A NO_COLOR environment variable that is set and not empty
switches colour off in both the banner and the log, whatever the format
(https://no-color.org).
The banner prints only when the log renders as pretty: with
telemetry.format = "pretty", or with auto when stdout is a terminal. With
json, or with auto and stdout piped to a collector, the first line on
stdout is a JSON log line. config check, healthcheck and
admission check print no banner. The banner shows counts, an address, a
path, switches and configuration keys, and never a credential, a URL, a
header value or anything from a request.
The file
The sections, and the page that covers each:
| Key or section | What it sets | Page |
|---|---|---|
profile | production, the default, or development, which relaxes the transport rules below | What must travel encrypted |
[server], [telemetry], [credentials], [signing] | the listener, the console, the onward credentials, the signing keys | this page |
server.max_concurrent_requests, [server.caller_rate], federation.max_in_flight_per_node, federation.max_node_answer_size | the overload limits and the answer bound | Overload protection |
federation.offset_strategy, federation.max_offset_window, federation.max_node_rows | paging across nodes, and the most rows read of one node for a query with no LIMIT, 9999 by default (a node is asked for one row more, so set it one under the largest page your nodes serve) | Queries and API areas |
[telemetry.otlp] | the trace export and the metrics push to one OTLP collector | Tracing, Metrics |
[metrics], [config] | the admin listener; config.ui, off by default, and the store of the configuration revisions the admin listener serves under managed | Metrics, Configuration revisions |
[registry] | the registry document and its form, or the mCSD directory of [registry.mcsd] the registry is read from; a member’s own exchange-format endpoint is an hl7-fhir-rest endpoint of the document | The registry, A member’s own exchange-format endpoint |
[pixm] | the cross-reference; a [dev] table is refused | Identity resolution |
[pdqm] | the PDQm Supplier asked for the master identity of an identifier the cross-reference does not map | Demographics first |
[xcpd] | the XCPD localizer and its audit repository | XCPD localization, The audit repository |
[nl_gf.nvi] | the NVI localizer of the Dutch Generic Functions | Dutch localization |
[nl_gf.mitz] | the Mitz consent pre-filter of the Dutch Generic Functions | Dutch consent |
[pmir] | the PMIR identity feed: the subscription and the route the Registry sends to | The identity feed |
[audit] | where the access records and the audit records of the PIXm, PDQm, mCSD and PMIR transactions go; outside development a gateway with a registry needs destination = "repository" | The audit trail |
[access_log] | the category map every access record is classified with: template ids and archetype ids to Art 14(1) categories, national categories, or none; [access_log.retention], the years each record is kept by category and origin, at least three; [access_log.labels], the security labels each record carries by category and origin; and [[access_log.emergency_purpose]], the purposes of use that mark an emergency access (Art 11(5)) | The access log, Labelling the access records |
[federation] | the federation id, node selection, budgets, completeness, paging, aggregates and the optional facilities | The registry, Queries and API areas |
[stored_queries] | the stored-query registry and its backend | Queries and API areas |
[store] | FerroFED’s own PostgreSQL database, which a contact-point issuer and the postgres stored-query backend need: its connection string, a secret that requires TLS, the CA it is verified by, and its bounds | FerroFED’s own store |
[emergency] | the limit of the emergency accesses a national contact point relays for one health professional: threshold, 5 unless set, and window, 86400 unless set; a looser limit is refused (eHDSI Section II Security Services 10.0.0 §2.5) | The emergency access limit |
[fhir] | the FHIR R4 face of the European exchange format: its base, the operator, the FHIRconnect mappings, the categories it receives ([[fhir.receive]]), the members that serve prescriptions and dispensations ([[fhir.serve]]), receive_namespaces, the identifier systems a received document’s or dispensation’s patient is resolved in, and document_url_lifetime, how long a document URL of the document list stays valid (5min unless set); it needs [pdqm], which the summary header is asked of; [eehrxf.terminology], the optional FHIR terminology server the face asks for $translate, $lookup and $validate-code, over https outside development | The patient summary over FHIR, Terminology, Prescriptions and dispensations over FHIR |
[usage_report] | the installation report to FerroPULSE, on by default: enabled, state_dir, the writable directory the instance id lives in and without which nothing is sent, deployment (helm, compose, binary or unknown) and endpoint, the collector | The usage report |
The bindings and their features
The sections of a regional or national binding belong to that binding, and
each binding is one Cargo feature of the ferrofed-server crate:
| Feature | Binding | Sections |
|---|---|---|
| always built | the access log, no binding | [access_log], [audit] |
binding-ihe | IHE, Annex A | [pixm], [pdqm], [xcpd], [registry.mcsd], [pmir], and registry.format = "fhir", which reads the document with the mCSD reader |
binding-nl | Dutch Generic Functions, Annex B; it turns on binding-ihe, which resolves patients | [nl_gf] ([nl_gf.nvi], [nl_gf.mitz]), [credentials."<id>".nuts] |
Both features are on by default, so the released binary and the container
image carry every binding. A build without one, such as
cargo build -p ferrofed-server --no-default-features --features binding-ihe,
refuses that binding’s sections as unknown keys. The access record needs no
binding: every build writes it through [audit] (The audit
trail), so a build with either binding, or none, runs a registry
outside development.
[server]
listen = "127.0.0.1:8080" # the socket address to bind
base_path = "" # the path of the base URL every route sits under, empty for the root; see The base path
trusted_proxies = [] # the proxies whose forwarded client address is taken; see The public address
forwarded_header = "forwarded" # or "x-forwarded-for": the header those proxies name the client in
request_timeout = "30s" # a request past this answers 408; see Timeouts
drain_delay = "0ms" # after SIGTERM, readiness is 503 and the listener accepts this long; see Health probes
shutdown_timeout = "30s" # then the drain is bounded by this; unset, the request timeout, and never shorter
header_read_timeout = "30s" # a client that sends no request head within this, counted from its first byte, is closed
idle_timeout = "2min" # a kept-alive connection with no next request within this is closed
body_limit = "1MiB" # a body past this answers 413
max_concurrent_requests = 512 # one more at once answers 503 overloaded; see Overload protection
retry_after = "1s" # the Retry-After of that 503, in whole seconds
compression = true # gzip or brotli for a client that accepts it; false leaves every answer as written
compression_min_size = "1KiB" # an answer shorter than this is never compressed
# A per-caller rate, off unless set; see Overload protection.
[server.caller_rate]
requests_per_second = 10 # sustained, per verified issuer and client_id
burst = 20 # at once after a quiet spell; one more answers 429 rate-limited
# TLS on the listener, off unless set; see TLS on the listeners. Unset, the
# listener speaks plain HTTP for a proxy that terminates TLS in front of it.
# [server.tls]
# certificate_file = "/run/secrets/ferrofed/listener.crt" # the chain, its own certificate first
# key_file = "/run/secrets/ferrofed/listener.key"
# client_auth = "required" # none (the default), optional or required: ask a client certificate
# client_ca_file = "/run/secrets/ferrofed/proxy-ca.pem" # the CA it must chain to: set with client_auth, refused without it
# min_version = "1.3" # the default; "1.2" keeps TLS 1.2 clients
# reload_interval = "1h" # optional: read the files again on this period, beside SIGHUP
# healthcheck_identity_file = "/run/secrets/ferrofed/healthcheck.pem" # what ferrofed healthcheck presents then
# The gateway's own keys beside [server].
[gateway]
# public_url = "https://gateway.example.org" # the base URL clients reach; see The public address
bindings_drain_timeout = "5s" # after the drain, the bindings stop their processes within this; see Health probes
# replica = "gateway-eu-1" # the name every operator report carries; unset, HOSTNAME, else "unnamed"; see The replica name
[telemetry]
format = "auto" # auto, json or pretty; auto is json unless stdout is a terminal
filter = "info,hyper=warn,tower=warn,h2=warn" # what the console logs; never what is traced
# environment = "production" # optional: deployment.environment.name on every exported span and metric
# The trace export and the metrics push, off without this table; see Tracing and Metrics.
[telemetry.otlp]
endpoint = "http://127.0.0.1:4317" # an OTLP gRPC collector, http:// beside the gateway or https:// over TLS
traces = true # export the spans
metrics = true # push the metrics
sample_ratio = 1.0 # the share of requests whose spans are exported, 0.0 to 1.0
# trust_roots_file and client_identity_file add the TLS of an https:// collector.
# The metrics surface, off by default; see Metrics.
[metrics]
listen = "127.0.0.1:9464" # the admin listener: GET /metrics and the stored-query distribution; loopback unless allow_remote
# scrape_clients = ["prometheus"] # the clients whose client-credentials token GET /metrics admits; needed off loopback outside development, unless [metrics.tls] requires a client certificate
# [metrics.tls] takes certificate_file, key_file, client_auth, client_ca_file, min_version and reload_interval, as [server.tls] does.
# Outbound credentials, one section per endpoint id. Each section names one
# scheme: a user and a password, or a grant; see Onward credentials.
[credentials."clinic-b".credentials]
user = "ferrofed"
password_file = "/run/secrets/clinic-b-password"
# OAuth 2.0 client credentials with a signed JWT client assertion (§13.1).
[credentials."cdr-c".credentials.oauth]
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token"
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt"
private_key_file = "/run/secrets/cdr-c-client.pem" # its public half is in the gateway's JWK Set
scope = "system/aql-*.s system/composition-*.cru"
resource = "https://cdr-c.example.org/openehr" # optional, RFC 8707
# audience = "cdr-c" # optional
# The Nuts grant of the Dutch Generic Functions (Annex B §B.4): a DPoP-bound
# token for a Verifiable Presentation of the gateway's credentials.
[credentials."cdr-d".nuts]
authorization_server = "https://nuts.cdr-d.example.org/oauth2/cdr-d"
scope = "openehr-query"
# client_id = "https://nuts.gateway.example.org/oauth2/gateway" # optional
did = "did:web:gateway.example.org"
kid = "did:web:gateway.example.org#key-1"
key_file = "/run/secrets/nuts-holder.pem"
dpop_key_file = "/run/secrets/cdr-d-dpop.pem"
[[credentials."cdr-d".nuts.credential]]
input_descriptor = "organization_credential"
file = "/run/secrets/nuts-organization-credential.jwt"
# A FAPI 2.0 authorization server, the BgZ/eOverdracht track (Annex B
# §B.4a): an ES256 private_key_jwt assertion naming the issuer, a DPoP-bound
# token, and RFC 9396 authorization_details.
[credentials."cdr-e".credentials.oauth]
issuer = "https://as.cdr-e.example.org"
security_profile = "fapi2"
client_id = "urn:oid:2.16.528.1.1007.3.3.<URA>"
client_auth = "private_key_jwt"
assertion_audience = "issuer"
private_key_file = "/run/secrets/fapi2-client.pem"
dpop_key_file = "/run/secrets/cdr-e-dpop.pem"
authorization_details = '''[{"type": "nl-gis-v1", "purpose_of_use": "http://terminology.hl7.org/CodeSystem/v3-ActReason|TREAT"}]'''
# The gateway's signing keys, which sign the caller's identity on every
# request to a node, and are published as a JWK Set beside every oauth
# grant's client key. Required whenever a registry is configured, by
# registry.document or by [registry.mcsd].
[signing]
key_file = "/run/secrets/ferrofed-signing-key.pem"
# previous_key_file = "/run/secrets/ferrofed-signing-key-previous.pem"
# next_key_file = "/run/secrets/ferrofed-signing-key-next.pem" # published ahead, never signs
# next_key_algorithm = "ES256" # unset, the current key's algorithm
jwks_uri = "https://gateway.example.org/.well-known/jwks.json"
node_jwks_cache = "1h" # how long the nodes cache the JWK Set
rotation_overlap = "65min" # at least 300 + node_jwks_cache
Secrets, inline or from a file
Every secret has a _file sibling, read at boot and trimmed, so a secret
can come from a mounted file and never sit in the configuration or the
environment. A secret written inline, or set through a FERROFED__
variable, is accepted as well, under every profile, production included;
setting both forms of one secret is refused, naming the inline key. Prefer
the file: an inline value sits in ferrofed.toml and in every copy of it.
The credentials are read and checked at boot and again on each
reload, and the node client of an endpoint with a
credentials section sends them on every request to that endpoint.
The gateway never prints a secret. A password or a client secret, inline, from
the environment or from a _file sibling, shows as *** wherever the
configuration is rendered: a debug log line, a panic message, a test failure.
A URL that may carry a credential, such as the stored-query store’s
url or telemetry.otlp.endpoint, shows with its userinfo and its query
replaced: postgres://***@db.example.org:5432/ferrofed?***. A libpq
key/value connection string shows as *** whole. The value itself is sent
unchanged.
The endpoint table of a service
Every outbound service a binding names is written as one endpoint table,
the EndpointSection of ferrohealth-smart that every FerroHEALTH product
reads for an outbound integration: [pixm.manager.endpoint] under each
[[pixm.manager]], [pdqm.endpoint], [pmir.endpoint],
[registry.mcsd.endpoint], [xcpd.gateway.endpoint] under each
[[xcpd.gateway]], [nl_gf.nvi.endpoint] and [nl_gf.mitz.endpoint], as
[audit.repository.endpoint] and [eehrxf.terminology.endpoint] are:
[pdqm.endpoint]
url = "https://pdq.example.org/fhir/" # required
timeout = "1s" # one whole call, every try and wait included
# connect_timeout = "500ms" # opening the connection; unset, timeout bounds it
# retry = { max_attempts = 1, initial_backoff = "200ms", max_backoff = "5s" }
[pdqm.endpoint.tls]
client_identity_file = "/run/secrets/pdq-client.pem"
[pdqm.endpoint.credentials]
user = "ferrofed-pdq-consumer"
password_file = "/run/secrets/pdq-password"
urlis the service’s base. It carries no credential: one with a user name or a password in it is refused naming the key, as an endpoint URL in the registry document is.timeoutbounds one call. Each table keeps its own default, so a key you leave out keeps the value the service had before it moved underendpoint:1sfor[pdqm]and[nl_gf.mitz],5sfor[pmir], and10sfor the others.retrytries a call again when the service could not be reached, the try timed out, or it answered5xx. Each identity, localization and consent table tries a call once unless you raisemax_attempts, so a failure fails the step at once within the query’s budget.tlsis the TLS material of Mutual TLS to the identity services.credentialstakes a user and a password, or theoauthclient-credentials grant authenticated by a client secret or a client assertion (Identity resolution), never anutsgrant. Each grant a node’s section can name is described in Onward credentials. Mitz takes the same: a user and a password, or theoauthgrant, whose token each question carries as a bearer token. A grant a table does not take is refused at load, naming it, and never read as no credential.[nl_gf.nvi.endpoint.credentials]takes a user and a password and refusesoauth; the NVI’snutsgrant sits in[nl_gf.nvi.nuts](Dutch localization). An XCPD responding gateway takes no credential: the XUA assertion is the gateway’s, and a[xcpd.gateway.endpoint.credentials]is refused.
When a service refuses a token with 401, the gateway drops the token and
sends the call once more with a fresh one, and a second 401 fails the
call (RFC 6750 §3.1). The
url, credentials and tls keys sat on the table itself before release
0.0.19; a configuration that still sets one there is refused, naming the
endpoint key that replaces it (Upgrading).
A PIX Manager asked by GET or POST
ITI-83 is an HTTP GET with the parameters in the URL: “the HTTP GET
operation shall be used” (PIXm 3.1.0 §2:3.83.4.1.2), and FHIR R4 requires a
server to support that GET (FHIR R4 Operations §3.2.0.1). The patient
identifier is then in the request URL, where a proxy, a load balancer or the
Manager’s access log can record it. method = "get" is the default, because
every PIXm Manager answers it.
FHIR R4 invokes an operation “generally” by a POST of a Parameters
resource to the operation’s endpoint (FHIR R4 Operations §3.2.0.1), and the
PIXm IG gives the request in that form (its Query Parameters In profile and
request example). With method = "post" the gateway posts the same
sourceIdentifier and targetSystem parameters as valueStrings, with
Content-Type: application/fhir+json, to [base]/Patient/$ihe-pix, and the
request URL carries no parameter. PIXm does not require a Manager to accept a
POST, so set it only for a Manager that says it does:
[[pixm.manager]]
method = "post" # the patient identifier travels in the body, never the URL
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
Any other value is refused naming the key. The answer is read the same way
under either method, and the audit record of each exchange holds the request
as sent: the URL of a GET, or the request line, media type and body of a
POST (The audit trail).
Mutual TLS to the identity services
A national identity service often authenticates the gateway by its client
certificate instead of, or beside, a password or a token. Every outbound
service takes its TLS material in the tls table of its endpoint
(The endpoint table of a service), and a
node in the tls sub-table of its [credentials."<endpoint id>"]:
[[pixm.manager]]
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
[pixm.manager.endpoint.tls]
client_identity_file = "/run/secrets/pix-client.pem" # mutual TLS
trust_roots_file = "/etc/ferrofed/pix-roots.pem" # optional
# min_version = "1.2" # "1.3" unless lowered
client_identity, or itsclient_identity_filesibling, holds the PEM client certificate chain and its private key, which the gateway presents in the TLS handshake. It is a secret: it shows as***wherever the configuration is rendered, and setting both forms is refused naming<table>.endpoint.tls.client_identity.trust_roots_filenames a PEM bundle of trust roots the service’s certificate chains to, added to the platform’s roots.min_versionis the oldest TLS version the gateway speaks to the service:"1.3"unless lowered to"1.2". Lower it only for a service that speaks no TLS 1.3, such as a MyHealth@EU responding gateway (Localization).- Every file is read at boot and on each reload of a reloadable table. A file that cannot be read, or material that holds no certificate and key in PEM, is refused naming its key, without quoting the file.
- The keys sat on the table itself before release 0.0.18; set there, they are refused as unknown keys (Upgrading).
Each of these services is reached through the outbound client of
ferrohealth-smart its endpoint table builds, with no redirect followed, the
credential marked sensitive, and the TLS material above. A MyHealth@EU
responding gateway’s client completes a connection only over a chain its
revocation check cleared (Localization).
What must travel encrypted
Every outbound connection in the configuration is held to one of three rules, by what it carries.
Credentials and patient identifiers. Outside profile = "development",
a URL that a configured credential or a patient identifier is sent to must
be https. serve, config check, admission check, conformance run
and every reload refuse anything else, with exit
code 78 and one line naming the URL’s key and what would travel over it,
never a value. The rule covers:
- the URL of a registry endpoint that has a
[credentials."<id>"]section, which receives its basic credentials or the access token itsoauthgrant obtains; - the
token_endpointof anoauthsection, which receives the client secret or the client assertion; - the
authorization_serverof anutssection, which receives the gateway’s credentials in a Verifiable Presentation; - the
issuerof a node’soauthsection, whose token endpoint receives the client assertion and, under anonwardtable, the callers’ tokens; - the
endpoint.urlof every PIX Manager, which is asked for patient identifiers with or without[pixm.manager.endpoint.credentials]; - the
endpoint.urlof[pdqm], the PDQm Supplier, which is sent the patient identifier with or without[pdqm.endpoint.credentials]; - the
endpoint.urlof every XCPD responding gateway, which is sent the patient identifier and, when one is configured, the XUA assertion; - the
endpoint.urlof[nl_gf.nvi], which is sent the pseudonymised BSN and, when one is configured, its credentials; telemetry.otlp.endpointwhen it carries a user name or a password; anhttps://collector meets the rule.
ferrofed: cannot start: the url of endpoint hospital-a in registry.document is not an https URL, and credentials.hospital-a would travel over it in cleartext: outside profile = "development" a credential or a patient identifier is sent only encrypted
A node URL no credential is sent to may stay http, for example a node on a
private network whose transport a sidecar protects with mutual TLS: the
gateway sends a node its own ehr_id, never the patient identifier (§5.4,
N33). A node the gateway presents its own client certificate to
(Mutual TLS to a node)
is https under every profile.
A database password. FerroFED’s own store is reached over TLS under
every profile: its connection string must set sslmode=require, or
config check, the start and a reload refuse it, naming store.url
(FerroFED’s own store). Outside the development
profile, the revision store’s connection string, config.url, must set
sslmode=require when it carries a password and reaches its host over the
network. The driver reads sslmode as disable, prefer (its default,
which falls back to no TLS) or require, and refuses any other value, so
disable and prefer are refused, naming config.url, by the same
commands and on a reload. A revision store connection with no password, or
whose every host is a Unix socket and that names no hostaddr, crosses no
network with a secret and may leave sslmode as it is
(Configuration revisions).
Under the development profile the same configuration starts, so the
quickstart can reach its nodes over http
inside its Docker network. Each credential or patient identifier that
travels unencrypted is named by key in the startup banner, in a WARN log
line at boot and after each reload, and on stderr by config check.
Trust anchors. A URL the gateway verifies its callers against, an
issuer itself, the key set its metadata names, or an introspection endpoint
in [auth], must be https, or http to a loopback
host, under every profile, development included. A key set fetched in the
clear would let anyone on the network substitute keys and forge callers.
The profile takes a restart. A reload whose file changes profile is
refused (class profile), and the running configuration stays. Every
decision the development profile admits, these transport rules and an
http XCPD gateway, reads the profile the process started with.
The specification assumes a protected transport and leaves it to the security profiles (§2.2, §13); no specification governs these rules, which are FerroFED’s own design.
The environment
Any key can be set or overridden with FERROFED__<SECTION>__<KEY>, upper or
lower case, with __ between the segments:
FERROFED__SERVER__LISTEN=0.0.0.0:8080
FERROFED__CREDENTIALS__HOSPITAL_A__CREDENTIALS__PASSWORD_FILE=/run/secrets/hospital-a-password
A value reads as TOML syntax when it is one (9, true) and as the string it
is otherwise. An override that names no key, or a key the file does not
define, is refused like any other unknown key.
Configuration revisions
With [config] ui = "managed", every change to the configuration becomes a
revision: checked as config check checks the file, kept in history with a
diff, applied atomically or marked pending a restart, and rolled back in one
step. Operators read and save revisions through the admin listener’s API;
Configuration revisions describes it. The
listen address, the profile, [server], [metrics], [auth] and
[config] stay file-only, and a revision holds only _file references,
never a secret. With off, the default, nothing changes.
The console manages members and stored queries (#577), and onward credentials and binding sections with write-only secrets, a confirmation after a change applies and a second operator’s approval (#578; Credentials and binding sections). Any revision exports as TOML, and TOML imports as a revision you check before you apply it (#579; Export and import).
The base path
server.base_path is the path of the base URL the gateway is served at,
{base} in the specification, and every route in the table below sits under
it (§4.1, N28). It is empty by default, so the gateway serves at the root. Set
it to mount the gateway under a path of your choosing, for example behind a
reverse proxy that does not strip the path:
[server]
base_path = "/fed/openehr"
The gateway then serves GET /fed/openehr/, OPTIONS /fed/openehr/,
GET /fed/openehr/health/readiness, POST /fed/openehr/v1/query/aql and the rest of
the table, serves {base} without the trailing slash as {base}/, and
answers 404 for every path outside the base, the root included, so point a
health probe at {base}/health/liveness and {base}/health/readiness; ferrofed healthcheck asks under the base
by itself. The specification reserves no prefix, and
the gateway reserves none either: /rest/openehr is a valid base when you
choose it, and is not served unless you do. The base is checked at boot: it
is empty, or starts with / and has no trailing /, no query or fragment,
and no empty, . or .. segment, or the gateway refuses to start and names
server.base_path. Tell clients the full base URL, scheme,
host and this path, through the registry or service discovery; nodes never
see it, because the gateway asks each node at the node’s own base URL.
Write that full URL once in gateway.public_url, and the keys that repeat
it follow from it (The public address).
The HTTP surface
Every route is under the base path; with the default, the
root, {base}/ is /.
| Route | Answers |
|---|---|
GET {base}/ | the product name and version, and its manufacturer with the postal address and the contact |
OPTIONS {base}/ | the federation’s self-description (§7a.2) |
GET {base}/health/liveness | 200 while the process is up |
GET {base}/health | the status readiness answers, with the state alone |
GET {base}/health/readiness | 200 while the gateway serves and its own subsystems (the configuration, the registry, the outbound clients, the stored-query store) are up; 503 before boot completes and from the moment SIGTERM or SIGINT arrives, with the phase and each subsystem’s state, never its detail; no member node and no identity source gates it |
GET {base}/operator/dependencies | to a caller with the operator scope, always 200, with the state the gateway last observed of each member endpoint, of the resolver, of the consent pre-filter and of the mCSD directory: up, degraded, failing, down or unknown, with directory_fault while the directory is degraded; endpoint ids, states, the fault’s class and the replica name only |
GET {base}/operator/deployment | to a caller with the operator scope, always 200, with the profile the process started with, the product version and the replica name; no setting value |
GET and POST {base}/v1/query/aql | the federated RESULT_SET, the GET form reading the request from its query string; 501 when no registry is configured |
{base}/v1/ehr/{ehr_id} and below | routed to the one node that owns the ehr_id, found in the order of §12.5.1: the openEHR-federation-endpoint header, the session’s resolution binding, the ehr_id index, then for a read the ask-all probe; answered as that node answered; 501 when no registry is configured |
GET {base}/v1/ehr?subject_id=…&subject_namespace=… | the subject resolved at the gateway, and GET /v1/ehr/{ehr_id} sent to the one member that holds it, at that member’s own base, answered as that node answered; 501 when no registry is configured |
{base}/v1/definition/ and below | routed to the one node openEHR-federation-endpoint names, never merged; without the header a 400; a template upload naming * or several endpoints fanned out to each when federation.fan_out_template_upload is set; stored-query definitions held at the gateway when [stored_queries] is set, and distributed to the members a PUT names when federation.fan_out_stored_queries is set beside it; without [stored_queries], routed like every other definition request; 501 when no registry is configured |
{base}/v1/demographic/ and below | 501, never federated; when federation.demographic_endpoint is set, routed to that endpoint, with or without openEHR-federation-endpoint naming it; a header naming another endpoint is a 400 |
any other path under {base}/v1/ | 501 |
| any other path | 404 |
Every response carries an x-request-id: the client’s value when it is short
printable ASCII, the gateway’s own id otherwise.
The metrics are not on this surface: they have a listener of their own, off by default (Metrics).
The replica name
Each gateway process keeps its integrity incidents, its learned routing
table and its dependency states to itself, so every report of the
operator surface and the
export of the identity state names the process
that produced it. [server] replica sets the name: 1 to 64 ASCII letters,
digits, ., _ and -. Any other value is refused at load with
gateway.replica is no replica name. Unset, the name is the HOSTNAME the
process runs with, which Kubernetes sets to the pod’s own name, and
unnamed when HOSTNAME is unset or is no such name. A start is never
refused for its host name.
Give each replica a name you can tell apart, or leave it to the pod name. The name is not a secret and names no patient, and it never reaches a node.
TLS on the listeners
The client listener and the admin listener each run in one of two modes.
- Plain HTTP, the default. Use it behind a reverse proxy, a load balancer or a service mesh that terminates TLS and reaches the gateway over a network you trust, such as the loopback interface or a pod’s own network namespace. Bearer tokens and patient identifiers cross the hop from the proxy to the gateway in the clear, so pick this mode only where that hop cannot be read.
- TLS, with
[server.tls]or[metrics.tls]. Use it when the hop from the proxy to the gateway crosses a network others share, or when your policy forbids a cleartext hop inside the network (NEN 7510 is one such policy). Setclient_auth = "required"with aclient_ca_fileto admit only the proxy: a client that presents no certificate that CA signed fails its handshake and reaches nothing.
[server.tls]
certificate_file = "/run/secrets/ferrofed/listener.crt"
key_file = "/run/secrets/ferrofed/listener.key"
client_auth = "required"
client_ca_file = "/run/secrets/ferrofed/proxy-ca.pem"
healthcheck_identity_file = "/run/secrets/ferrofed/healthcheck.pem"
certificate_fileholds the chain the listener presents, its own certificate first, andkey_fileits private key, both PEM.client_authsays whether the listener asks a client certificate:none, the default, asks none;optionalverifies one a client presents;requiredrefuses a client that presents none.client_ca_fileholds the CA certificates a client certificate must chain to. It is required withoptionalorrequired, and refused withnone, where it would do nothing (a client sends a certificate only when the listener asks for one, RFC 8446 §4.3.2).min_versionis the oldest TLS version the listener speaks:"1.3", the default, or"1.2"to keep a TLS 1.2 client."1.1"and"1.0"are refused (RFC 8996).reload_interval, optional, reads the files again on that period, besideSIGHUPand an applied configuration revision.healthcheck_identity_file,[server.tls]only, holds a client certificate chain and its key, PEM, whichferrofed healthcheckpresents to a listener that requires one. It is refused anywhere else.
The listener speaks TLS 1.3 (RFC 8446) alone by default. With min_version = "1.2" it speaks TLS 1.2 too, and picks TLS 1.3 whenever the client offers
it, as BCP 195 asks (RFC 9325 §3.1.1); its TLS 1.2 cipher suites are then the
four ECDHE AES-GCM suites RFC 9325 §4.2 recommends, each with forward
secrecy. It offers HTTP/2 and HTTP/1.1 in ALPN. A client that does not
finish its handshake within 10 seconds is dropped, and one that does not
send a request head within server.header_read_timeout (30 seconds by
default, counted from the head’s first byte) is disconnected. A kept-alive
HTTP/1.1 connection that sends no next request within
server.idle_timeout (120 seconds by default) is closed with a clean end
of stream; keep it longer than the idle timeout of the clients and proxies in
front, so they close an idle connection first.
config check reads every file, and refuses, with exit code 78 and the key
named, a missing certificate or key, a file that holds no PEM, a key that
does not match its certificate, and a client CA that is no usable trust
anchor. It never prints the content of a file.
To renew the certificate, write the new files over the old ones and send
SIGHUP, as for a registry reload. Every handshake after the
signal presents the new certificate and checks clients against the new CA
file; connections already open keep the certificate they began with. Files
that do not form a valid pair refuse the whole reload, logged with their
table, and the running certificate, registry and [auth] stay. Where no
signal reaches the process, POST /admin/reload on the admin listener runs
the same reload. A changed path in [server.tls] or
[metrics.tls] takes a restart.
ferrofed healthcheck follows [server.tls]: it asks over https on
loopback and accepts exactly the certificate certificate_file holds. If
the files were replaced and the gateway has not been sent SIGHUP, it
reports so and exits 1.
What the log records
One line per request: the method, the matched route, the status, the latency,
the gateway’s request id (request_id) and whether the client sent its own
(client_named). A façade query carries the patient identifier, so the line
never carries a request body, the AQL text, a header value, the request path,
or a query value other than the ITS-REST paging parameters offset and
fetch, and those only when they are one to ten digits. That list is fixed
in code: serve and config check refuse [telemetry] logged_query_parameters, so a deployment cannot widen it. The line is
written under the target ferrohealth_telemetry::request. The route is a path template,
never the path the client sent: a request the gateway routes or answers
under {base}/v1/ is logged under the template of the ITS-REST operation it
addresses, so a routed composition read is logged as
/v1/ehr/{ehr_id}/composition/{uid_based_id}, with the configured base path
in front of it once, and its ehr_id and version uid stay out of the line.
An OPTIONS request is logged under the template of the resource it
describes. A path that names no route, and any other method the ITS-REST
operation does not declare, is logged as <unmatched>. The client’s own
x-request-id is a header value too, and it is never logged, so a request the client named is found in the log by its time, route
and status, and in a node’s log by the request_id of that line. A request
the gateway answers before its handler finishes has its line too, with the
status it answered: 500 for a handler panic, 408 past the request
timeout, 413 over the body ceiling. A handler panic is also logged under
the gateway’s id, without its message, which could quote a value the handler
held. Every panic in ferrofed serve, inside a request or not, writes one
more line, “a thread panicked; the message is withheld”, with the thread,
the source file and line and, inside a request, the gateway’s
request_id. The panic message is never written:
the gateway replaces Rust’s default panic hook, which prints it to stderr,
so a panic writes nothing to stderr. A federated query the gateway fails with a 500 also logs “the
federated query failed” with its error code and the same request_id as its
request line. A fan-out template upload, a stored-query distribution or
repair, and a drift check whose task for one member panics fail the same
way, with a 500, and log that the fan-out could not tell what became of
every member.
The public address
Clients, the members’ authorization servers and the identity services reach the gateway at a public address, usually through a reverse proxy that terminates TLS. This page covers the two settings that address takes: the public base URL, which several keys repeat, and the proxies whose forwarded client address the gateway takes.
The public base URL
Three keys name the URL clients and services reach the gateway at: each
[[auth.issuer]] audience, the JWK Set’s signing.jwks_uri and the PMIR
feed’s pmir.callback_url. Name it once in gateway.public_url, with the scheme,
the host, any port and the base path, and
leave the three out:
[server]
base_path = "/fed"
[gateway]
public_url = "https://gateway.example.org/fed"
Each of the three that is not set then takes its value from it:
| Key | Value |
|---|---|
auth.issuer[].audience | gateway.public_url as written |
signing.jwks_uri | {public_url}/.well-known/jwks.json |
pmir.callback_url | {public_url} followed by pmir.path, /pmir/feed by default |
The gateway refuses to start, naming the key, when gateway.public_url is
no http or https URL, carries a user name, a password, a query or a
fragment, or names a path other than server.base_path (a trailing /
aside). A signing.jwks_uri or pmir.callback_url you still set must name
the route the gateway serves under gateway.public_url, or config check
refuses it and names the key and the URL it expects. A copy that misses the
base path is then found before a node fails to fetch the JWK Set or the
Registry sends the feed nowhere. An issuer’s audience you set is taken as
written: it is the gateway’s identifier at its issuers, which need not be
its URL. Without gateway.public_url, each key is read as written and
nothing is compared.
Behind a reverse proxy
The gateway names each request by the address it came from, which the access log of #623 records (The audit trail). That is the address of the connection’s peer, so behind a reverse proxy it is the proxy’s. The client’s own address reaches the gateway only in a header the proxy writes, which any client can write as well, so the gateway reads it only from a proxy you list (RFC 7239 §8.1):
[server]
trusted_proxies = ["127.0.0.1", "10.20.0.0/16"]
forwarded_header = "x-forwarded-for"
trusted_proxieslists each proxy as an IP address or a CIDR block. Empty, the default, the gateway names every request by its peer and reads no forwarded header.forwarded_headeris the header the proxies write:forwarded, the default, reads thefor=parameters ofForwarded(RFC 7239);x-forwarded-forreads the comma-separated list ofX-Forwarded-For.
A request from a listed proxy is named by the rightmost address in that
header that is no listed proxy. Each proxy appends the address it received
the request from, so anything to the left of the last address a listed
proxy wrote may have come from the client. A hop that names no address,
such as unknown, stops the walk at the last address a listed proxy gave.
A request from any other peer is named by that peer, whatever headers it
sends.
Have the proxy at the edge set the header rather than append to it, as
deploy/nginx/ferrofed.conf does with
proxy_set_header X-Forwarded-For $remote_addr, and drop the header it
does not write. The per-caller rate limit keys on the verified caller,
never on an address, so a forwarded address changes no limit
(Overload protection).
What the gateway sends a node
Each request the gateway sends a node carries an id the gateway mints, and headers the gateway composes or forwards by what the ITS-REST operation declares. This page says where each comes from.
Request ids
The gateway mints its own id, a fresh version 4 UUID, for every request. That
id is the X-Request-Id of every request the gateway sends to a node for it,
the same id on every node of one fan-out. The client’s x-request-id never
reaches a node: it is free text, and the gateway cannot tell whether it names
a patient (§5.4.1, N33). When the client sends no id, the response carries the
gateway’s id, so the client, the log and every node name the same request.
The outbound gate searches every other part of a node request for the
identifiers resolution consumed, and skips the minted id: it holds no client
input, and a short hexadecimal identifier can occur inside a random UUID.
Every other header the gateway sends to a node for a federated query is fixed
by the gateway: Accept and Content-Type (application/json),
Authorization (the endpoint’s configured onward credential, when it has
one), openEHR-federation-client (the verified caller, signed for that node
with the [signing] key;
What a node is told about the caller),
and the Host, Content-Length and Accept-Encoding the HTTP client
writes. None is copied from the client request. Host is the authority of
the endpoint URL in your registry, never a value from a request, so the
outbound gate does not search it, or the URL’s host and port, for a
withheld identifier (§5.4.1, N33). It searches the path, the query string
and the other headers, except the minted id above, and every caller claim
of openEHR-federation-client.
A request routed to one node ({base}/v1/ehr/{ehr_id} and below) carries the
same Authorization, openEHR-federation-client, X-Request-Id, Host,
Content-Length and Accept-Encoding, and each request header the matched
ITS-REST operation declares, of Accept, Content-Type, If-Match, Prefer,
openehr-version, openehr-audit-details, openehr-template-id,
openehr-item-tag and openehr-version-item-tag, only those that operation
lists. The list comes from the openehr-its parameter table, never from the
gateway’s own copy. Accept, Content-Type and Prefer are composed by the
gateway as values the operation lists; every other declared header is the
client’s value byte for byte (see Declared values). Every
other client header is stripped, the client’s Authorization and
x-request-id and the federation’s own headers included, and the outbound
gate reads every forwarded value.
Declared values
A routed request resolves no patient, so the outbound gate has no identifier to compare a forwarded value against. The gateway works from what the operation declares instead, before anything is sent.
It composes three headers itself, so a node receives a value the operation lists, in the operation’s own spelling, and never the client’s text:
Acceptis read as a list of media ranges with weights (RFC 9110 §12.5.1). Each listed media type takes the weight of the most specific range that covers it, and the node receives the heaviest one, the first listed on a tie.*/*, or noAcceptat all, sends the first media type the operation lists; ITS-REST declares no default media type, so the first listed is the gateway’s own choice,application/jsonin every EHR operation. A range with a parameter other thanqorcharset=utf-8covers nothing. AnAcceptthat admits no listed type is a406(media-type-not-acceptable), as a node would answer it.Content-Typeis read as a media type (RFC 9110 §8.3). When its type and subtype are a listed value, the node receives that value. Acharset=utf-8is accepted and dropped, since the listed value carries no parameter and JSON is UTF-8 (RFC 8259 §8.1). Any other parameter, or a type that is not listed, is a415(media-type-unsupported). The listed values are the media types the operation’s request body is declared in (openehr-its’srequest_media), which are the values of itsContent-Typeparameter where it declares one; the versioned stored-queryPUTdeclares no parameter and takestext/plain. A body never travels without aContent-Type: when the client sends none, the node receives the first media type the operation lists. ITS-REST makesContent-Typeoptional and declares no default, and RFC 9110 §8.3 names none, so the first listed is the gateway’s own choice, as forAccept. Wherever an ITS-REST operation lists several,application/json, the canonical JSON, comes first.Preferis read as a list of preferences (RFC 7240 §2). The node receives only the preferences the operation lists, in their listed spelling: a preference name is compared without regard to case, its value exactly, and only its first instance counts. Any other preference is dropped and never refused, as RFC 7240 allows a server to ignore it.
It holds every other value to what the operation declares, and a value that
does not match is a 400 (parameter-value-invalid) with nothing sent:
- a path identifier is the openEHR identifier class the
openehr-itstable states for it: aversion_uid, and theuid_based_idof a delete, anOBJECT_VERSION_ID; any other textuid_based_idanOBJECT_VERSION_IDor aHIER_OBJECT_ID; and a path parameter the table states as a UUID, such asversioned_object_uidor theuid_based_idof a composition update, a UUID in its canonical hyphenated form, each parsed byopenehr-base(a composition delete whose path names noOBJECT_VERSION_IDispreceding-version-invalidinstead, since the path names the version it amends). The path then travels as the client sent it, since an openEHR uid is never rewritten (N22). Theehr_idis parsed by the routing itself; - a date-time, such as
version_at_time, is an extended ISO 8601 date-time in the openEHR BASE sense, with an offset only when needed (ITS-REST Overview, “Datetime format”), read by theopenehr-baseparser; - an enumerated query value, such as
detail_level, is exactly one of the values the operation lists; - a UUID, an integer, a number and a boolean are each parsed as one.
The refusal names the header, or the path or query parameter by its position
and declared name, and never the value (§5.4.3). A value of such a kind
carries only what the kind admits: a four-digit year is a valid partial
date-time, and a HIER_OBJECT_ID admits a bare number as a one-arc ISO OID.
ITS-REST states the identifier class of a path parameter only in its
description; the openehr-its table carries it, and the gateway reads it
from there, never from a copy of its own.
The parameter table states no kind for the rest, so the gateway cannot
classify them and forwards them as the client sent them. In the EHR area they
are the headers If-Match, openehr-audit-details, openehr-item-tag,
openehr-template-id, openehr-version and openehr-version-item-tag, the
path parameter key of an item tag, and the query parameters path,
tag_key, tag_value and tag_target_path. The creation of an EHR adds
none beyond openehr-version and openehr-audit-details. The definition
area has the path parameters qualified_query_name, template_id and
version, and the query parameters concept, query_type, template_id
and version. The DEMOGRAPHIC area, where it is routed, has the same ones as
the EHR area except path.
N33 forbids an identifier in the parts of a request the gateway composes
(§5.4.1). Whether a client value the gateway forwards unchanged is one of
those parts is a question the specification leaves open, recorded on
#212.
| [xcpd] | xcpd.audit and [xcpd.audit_repository] |
| [nl_gf] | |
The registry
This page covers the registry document (its FHIR form has
a page of its own), the registry read from an mCSD directory, the
federation id, node selection, the state the
gateway learns (resolution bindings and the ehr_id index), reloading the
registry, and integrity incidents.
See how it works: follow-ups and writes, routed with what the registry holds.
The registry document
registry.document names a second TOML file: the federation’s members as
the operator admitted them. It declares each [[organisation]], each
[[node]] with its openEHR system_id, and each [[endpoint]] with its base
URL, connection type and managing organisation. An unknown key, a dangling
reference or a duplicate id refuses the whole document, and so does an
organisation that manages no endpoint (§8.1: “an Organization manages 1..*
Endpoints”); a native hl7-fhir-rest endpoint counts as one.
The document is also the follow-up routing table (N21). A follow-up for a
version is routed on the creating_system_id inside its uid (§12.2). A
member’s own system_id routes to that member without being written down.
A CDR can hold versions another system created, because an imported
composition keeps its original uid. Map every other creating_system_id you
know of to the endpoint that answers for it with a [[creating_system]]
entry:
[[creating_system]]
creating_system_id = "legacy-a.example.org" # the middle segment of the uid
endpoint = "hospital-a" # an endpoint id this document declares
config check refuses, naming the creating_system_id, a mapping that names
an endpoint the document does not declare, a creating_system_id mapped
twice, and a mapping of a member’s own system_id. Two spellings that differ
only in ASCII case are one creating_system_id.
An endpoint may list the ITS-REST Error codes its node marks a consent
refusal with. A 403 from that node whose Error carries one of them is
reported consent-denied; every other refusal is node-error
(Consent). The list is empty by default, and an empty
code refuses the document:
[[endpoint]]
id = "hospital-b"
# ... node, url, connection_type, managing_organisation
consent_refusal_codes = ["consent-refused"] # what this node writes in Error.code
A member’s own exchange-format endpoint
A member CDR may serve the patient summary in the European exchange format
itself. Declare that endpoint as a second [[endpoint]] of the node, of
connection type hl7-fhir-rest, naming the categories it serves, the
profiles its documents follow, and the identifier system the gateway writes
the member’s ehr_id in. The member is then in the native mode for those
categories, and in the map mode, the default, for every other one:
[[endpoint]]
id = "hospital-a-eps"
node = "hospital-a"
url = "https://cdr-a.example.org/fhir"
connection_type = "hl7-fhir-rest"
managing_organisation = "org-a"
categories = ["Patient-Summaries"] # Art 14(1) priority category codes
profiles = ["http://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps"]
ehr_id_system = "urn:oid:2.999.4.1" # the system the member reads its ehr_id in
For a patient summary, the gateway leaves the member out of its section
queries and asks this endpoint GET {url}/Patient/$summary with
identifier=<ehr_id_system>|<ehr_id> and profile, the member located by
its own ehr_id alone (§5.4.1, N33). The request carries the onward
credential, the DPoP proof and the signed caller identity of the node’s
openehr-rest-query endpoint, and passes the same outbound gate. No
specification defines an identifier system for an ehr_id on a FHIR
interface, so the operator declares one with the member.
The endpoint is never an openEHR Query API endpoint (N19, §15.2): no query is
sent to it, OPTIONS {base}/ does not list it (§7a.2), and a FROM ENDPOINT
directive or an openEHR-federation-endpoint header naming it is a 400
endpoint-unknown (§8). A node needs an openehr-rest-query endpoint
beside it. The FHIR form and an mCSD directory declare one too, as
a member’s own endpoint in FHIR form
shows.
config check refuses, naming the endpoint:
- an
hl7-fhir-restendpoint with noehr_id_system, or an empty one; - one with no category or no profile, or an empty one;
- one with
consent_refusal_codes, which only an ITS-RESTErrorcarries; - an
openehr-rest-queryendpoint that setscategories,profilesorehr_id_system; - two native endpoints of one node for one category;
- a category code that is no Art 14(1) priority category
(
Patient-Summaries,Electronic-Prescriptions,Electronic-Dispensations,Medical-Imaging,Laboratory-Reports,Discharge-Reports); - an endpoint whose profiles leave out the one the face asks its category in:
the EPS composition,
MedicationRequest-eu-mpdorMedicationDispense-eu-mpd.
A suspended native endpoint serves nothing, and the member’s data for its
categories are mapped as any other member’s. The native mode and its keys
are FerroFED’s design: no specification governs them.
The registry document in FHIR form
The registry document can also be a FHIR R4 Bundle of Organization and
Endpoint resources (registry.format = "fhir", N19). Its elements, its
refusals and the definitions behind FerroFED’s https://ferrofed.eu/fhir/
URLs are on The registry in FHIR form.
The registry read from an mCSD directory
The gateway can read its members from an IHE mCSD 4.0.0 care services
directory instead of a document, and keep them in step with it. The
specification proposes mCSD for addressing, and the registry is the local
materialisation of that mapping (§15.1, N21, Annex A.5). Set
[registry.mcsd] in place of registry.document; a configuration that sets
both is refused.
[registry.mcsd]
refresh_interval = "5min" # the default; 0 is refused
deadline = "30s" # one whole read or refresh, the default
max_pages = 200 # the defaults of the three caps
max_size = "64MiB" # 64 MiB of answer bodies
max_entries = 50000
[registry.mcsd.endpoint]
url = "https://directory.example.org/fhir" # the directory's FHIR base
# timeout = "10s" # each page; deadline bounds the whole read
[registry.mcsd.endpoint.credentials] # when the transport does not authenticate
user = "ferrofed"
password_file = "/run/secrets/directory-password"
The members are the directory’s Organizations that carry an
https://ferrofed.eu/fhir/sid/organisation-id identifier and its Endpoints
that carry an https://ferrofed.eu/fhir/sid/endpoint-id identifier, written
exactly as the FHIR form above. The rest of the directory is not the
federation’s and is never read into the registry. A member organisation may
also list endpoints that are no member: endpoints of other services, such as
its XCA endpoints, one that lost the federation’s identifier, or one the
directory deleted. The gateway ignores such a listing, where the document
refuses it, and logs the listing’s reference, never the endpoint, once each
time it reads the directory’s content. A start and a refresh over the same
content give the same registry. The URL is http or
https with no user name or password; the credentials take basic credentials,
the password through its _file sibling, or the client credentials grant of
oauth (A PIX Manager behind OAuth 2.0). A directory with credentials is https outside
profile = "development", and the configuration is refused before the
directory is asked otherwise; under that profile the site is reported as
every other cleartext credential is.
At start, the gateway reads the members with ITI-90, Find Matching Care
Services: GET [base]/Organization?identifier=…| and
GET [base]/Endpoint?identifier=…|, every page. The content then passes
every check the FHIR form passes: the connection type of §15.2 (N19), one
managing organisation per endpoint (N20), unique ids, and everything the
native form refuses. A directory that cannot be read within the deadline
and the caps, or holds a registry that breaks a rule, stops the start; config check reads the directory the
same way and names the fault.
Every refresh_interval the gateway asks for the changes since the last
read with ITI-91, Request Care Services Updates:
GET [base]/Organization/_history?_since=… and the same for Endpoint. It
asks from 60 seconds before the Date the directory stamped its previous
answer with, so the directory’s own clock decides and no change is missed;
a directory that sent no readable Date is read again whole with ITI-90. The
newest version of each resource wins, a deletion removes it, and a version
that no longer carries the federation’s identifier takes it out of the
registry. A query never waits on the directory: the refresh runs on its own,
and a request that started before a refresh finishes on the registry it
started with.
A refresh that changed something goes through the same checks as a reload:
- When the changed registry passes, it replaces the running one, with the
effects of a reload (learned routes held to it, entries for a member that
left dropped). It logs
registry reloadedand counts as an applied reload. - When it breaks a rule, it is refused. The running registry stays, the
gateway logs
registry reload refusedwith theclassof the reload classes, and the refusal counts as a refused reload. A content that breaks a registry rule (anhl7-fhir-restendpoint without the keys of a native endpoint, asystem_idgiven to two nodes, a registry left with no node) isregistry-invalid. A sound registry that the rest of the configuration no longer fits is refused under the class of that part:dev-cross-referenceorpixmfor a resolver row naming a member the refresh removed,localization,demographic-endpoint,credentialsfor a[credentials]section naming a removed endpoint,node-clients,http-client,self-description,signingorfederation. A deleted endpoint is no break of its own: its organisation’s listing is ignored and the endpoint leaves the registry. The next refresh asks again from the same instant, so the registry follows the directory once the directory, or the configuration, is put right. - When the directory’s answer runs past
deadline,max_pages,max_bytesormax_entries, the refresh is refused the same way withclass = "registry-budget"; an answer that does not hold to ITI-91 is refused withclass = "registry-unreadable". Both count as a refused reload. Each limit bounds one whole read or refresh, over every page of both resource types, so a directory that links its pages in a cycle or answers without end cannot hold the gateway or fill its memory. A partial answer never becomes the registry. No specification governs these limits; they are FerroFED’s own design. - When the directory cannot be reached, or refuses the request with an HTTP error, the running registry stays and the gateway logs a warning.
GET {base}/operator/dependencies reports the directory as directory: up
after an answer the gateway accepted, degraded after an answer whose change
it refused, failing after an HTTP error, a malformed answer or one past a
cap, and down when it did not answer before the deadline or could not be
reached. A 401 or a 403 is failing with
directory_fault = "refused-credentials": the directory refused the
credentials of [registry.mcsd.endpoint].
While the directory is degraded, directory_fault says which kind of
refusal holds the change back: registry-invalid for a content that breaks a
registry rule (the registry-invalid class above), and
configuration-mismatch for a sound registry the rest of the configuration
no longer fits (every other class). It stays degraded, and the gateway
serves the registry it last accepted, until a refresh is accepted: the
directory is put right, or the configuration is changed and reloaded. The
body names the class only; the log line registry reload refused carries
the precise class. The directory’s state never gates readiness.
A SIGHUP reload with a directory rebuilds the federation over the registry
the directory gave, applying [credentials], [pixm], [pdqm],
[xcpd] and [nl_gf]; it never asks the directory. A change to
[registry.mcsd] takes a restart, and a change between a document and a directory is refused as
registry-presence.
A directory change goes through the same checks, so it is refused when the
localizer cannot place the new members. Under [nl_gf.nvi], or [xcpd], a
member the directory adds without a custodian URA (or a community) mapped to
it makes the change refused as localization: the gateway keeps the previous
registry, logs the refusal and counts it in ferrofed_registry_reloads_total
(Metrics), and asks again from the same instant at the next
refresh. GET {base}/operator/dependencies shows the directory degraded with
directory_fault = "configuration-mismatch" until the change is accepted. Map
the member first, then publish it.
The registry keeps each organisation’s identifiers as the directory
publishes them, beside the organisation-id one: every identifier with a
system and a value, ordered and each once. They name organisations, never
patients, and a registry document in the native form carries none. Under
[nl_gf.nvi] the URA among them (http://fhir.nl/fhir/NamingSystem/ura,
Annex B §B.2) gives the localizer its custodian map, so a Dutch directory
needs no custodians table: a member organisation that publishes its URA
before its endpoint is never unmapped, and a refresh that adds it applies
(Dutch localization).
Federation id
A gateway that federates names its federation, and refuses to boot without the name:
[federation]
id = "rso-example"
The id is federation.id of the OPTIONS {base}/ self-description (§7a.2,
N30). It has no default, because it is the deployment’s to choose, and an
empty id is refused. It is named in the startup log line.
Node selection
A gateway that federates (registry.document or [registry.mcsd] is set)
declares how an undirected patient query finds its nodes, and refuses to
boot without the declaration:
[federation]
node_selection = "ask-all"
ask-all is the selection for a deployment with no localization service (the
specification’s reference flow, Variant B; N4). Every active member is a
candidate: the gateway asks every member’s cross-reference where the patient
is, dispatches the query only to the members that return an ehr_id, and
reports the others as not-resolved without failing the query. The selection
is named in the startup log line.
localized derives the node set from a localizer (N4, N10, §14.1):
[federation]
node_selection = "localized"
[federation.localization]
on_failure = "closed" # the default; "ask-all" widens on failure
timeout = "5s" # the default; below overall_timeout, 0 is refused
For an undirected patient query, the gateway first asks the localizer which
members might hold the patient’s data. A member it does not name is reported
not-localized and is never asked, and complete stays true, because that
member was never in scope. The cross-reference then resolves the patient at
the named members only. A directed query (the FROM ENDPOINT directive or the
targeting headers) is never localized: the directive selects its node set
(§8). A query that names no patient is refused with a 400 under this
selection, because localization is keyed on the patient and no node set is
defined (N4). The read of an EHR by subject, GET {base}/v1/ehr?subject_id=…,
names the patient and no node, so it is localized as an undirected query is:
the specification does not limit N4 to AQL, and a deployment that relies on
its localizer to narrow which members learn of a patient keeps that narrowing
on every patient route
(Reading an EHR by subject).
When the localizer does not answer within timeout_ms, or fails, the gateway
fails closed: it asks no member, reports every member not-localized with the
localizer’s error, and carries the same error as
meta.federation.localization.error, so an outage never reads as a patient
with no data (§14.1). The status stays 200 and complete stays true,
since no member in scope failed. on_failure = "ask-all" asks every member
instead, still with the error in meta.federation; it holds only where it is
written, and OPTIONS {base}/ declares the policy either way. A localizer
that answers that no member holds the patient’s data leaves every member
not-localized with no error.
The localizer is the IHE XCPD binding when [xcpd] is set
(XCPD localization), and the NVI of
the Dutch Generic Functions when [nl_gf.nvi] is
(Dutch localization); setting
both refuses to boot. Otherwise it is
the resolver: the PIXm resolver when [pixm] is set, which names the members
whose domain holds the patient at the PIX Manager
([pixm] as the localizer). The localized
selection with no localizer refuses to boot, and so do
[federation.localization], [xcpd] and [nl_gf.nvi] under ask-all.
Resolution bindings
The specification lets a query’s resolution leave a binding behind for the
client session: which member holds which ehr_id, so a follow-up on a path
ehr_id reaches the right node (§12.5.1 step 2). The session is the caller
the gateway verified (Client authentication): the
token’s issuer, subject and client together. A federated query and a read of
an EHR by subject record the {node, ehr_id} pairs their resolution found
under that caller, and a routed request by the same caller is routed by them
before the ehr_id index. Another caller never sees them. A consent denial
of a member drops every binding of that caller naming it (N27a); a binding
records where an ehr_id was resolved, never a consent decision. Bindings
live in memory only, keyed by ehr_id and never by a patient identifier,
and a restart forgets them.
Both bounds are settings, checked at boot:
[federation]
binding_ttl = "15min" # 15 minutes after the last resolution that returned the binding, the default; 0 is refused
binding_capacity = 100000 # ehr_id bindings over every caller, the default; 0 is refused
Each binding has a lifetime of its own. A resolution that returns a
{node, ehr_id} pair holds it, or renews it, for binding_ttl; a binding
the caller’s later resolutions no longer return expires binding_ttl
after the last one that did, however many other resolutions the caller makes
meanwhile. One exception holds for as long as the caller has a live binding:
an ehr_id the caller’s resolutions placed at two members or more stays a
collision, and a follow-up on it is refused 409 (ehr-id-collision),
even after one claimant’s binding lapses (§12.5.2, N42). A caller’s bindings
can span several patients, so the gateway never lets a lapse narrow a
collision to the member where one patient resolved. Only an identity change,
a consent denial of a claimant, a claimant leaving the registry, or your
clear clears it.
When the bindings are full, a new one first drops every binding that has expired, then the bindings of the other callers whose last binding expires soonest, whole; a binding that still does not fit is not kept, which costs that caller’s follow-up the next step of §12.5.1, never a wrong node.
The lifetime is a correctness bound. An identity merge or split at the
identity source can make a binding stale, and a resolution after the change
no longer returns it, so a stale binding is routed on for at most
binding_ttl after the last resolution that returned it. Set it no longer
than you would accept a follow-up being routed on a superseded identity. With [pmir], a merge the
identity source reports drops the bindings it could have made stale as it
happens, on the replica that receives it
(The identity feed,
Several replicas); the
specification marks this lifecycle track provisional.
The ehr_id index
The gateway also keeps an index of which member holds which ehr_id, shared
by every client. It learns an entry when a resolution finds the patient’s
ehr_id at a member, and when a member answers a request under that ehr_id
with a success. A follow-up on a path ehr_id that names no node and has no
binding is routed by the index before the gateway falls back to asking every
member (§12.5.1). The index holds ehr_ids and member ids only, lives in
memory, and forgets the least recently used ehr_id once it is full:
[federation]
ehr_index_capacity = 100000 # ehr_ids held, the default; 0 is refused
A forgotten or never-learned entry costs a later request one fallback step,
never a wrong route: a read then asks every member, and a write is refused
until the client names its node. An ehr_id seen at two members is held at
both, the index raises the index-insert alarm of §12b.2 once (an
IndexInsertCollision incident, see Integrity incidents),
and from then on it routes neither: a request for that ehr_id that names
no node is refused 409 (ehr-id-collision). A held collision has no expiry
of its own, because nothing the gateway observes shows that a node was
remedied. It lasts until you clear it
(Clearing a held collision), the entry is
forgotten as least recently used, or the gateway restarts; after that, a
read probes every member again, and a collision that still stands is found
and reported again.
Reloading the registry
Send SIGHUP to a running ferrofed serve to apply a changed registry
document without a restart:
kill -HUP <pid of ferrofed>
docker kill --signal HUP <container>
The gateway reads the configuration again from where it read it at start:
the --config file, or the file FERROFED_CONFIG names, with the process’s
FERROFED__ environment over it. It checks the result exactly as serve and
config check do at start, secrets and _file siblings included. The
gateway reloads on the signal only and never watches the file, so write the
new document completely, then send the signal.
Nine sections take effect on a reload:
| Reloaded | Needs a restart |
|---|---|
[registry]: the document’s contents, its path and its format | [registry.mcsd], the directory the registry is read from |
[credentials], each endpoint’s onward TLS included | [server], trusted_proxies and forwarded_header included, [gateway], and [signing], rotation_overlap included |
[pixm] | [federation], federation.demographic_endpoint included, [stored_queries] and [store] |
[pdqm] | [pmir], [telemetry] and [metrics] |
[xcpd] | xcpd.audit and [xcpd.audit_repository] |
[nl_gf] | [audit] and [config] |
[access_log] | [fhir] and [eehrxf] |
[auth]: the issuers, how each is verified, its key set read again | auth.issuer.audience of an issuer already trusted, and a changed [server.tls] or [metrics.tls] path |
[trust]: each issuer’s policy, its edge and its purpose of use | [usage_report] |
The same reload reads the certificate files of [server.tls] and
[metrics.tls] again (TLS on the listeners).
A request admitted before the reload finishes as admitted, and every request
that arrives after it is verified against the new [auth], on the client
face and the admin listener alike. Every file and check is read before
anything changes, so a reload whose [auth], certificate files or registry
fails is refused as a whole.
federation.demographic_endpoint keeps its running value until a restart,
and the document must still declare it: a reload whose document drops that
endpoint is refused (demographic-endpoint, below).
A reload whose file changes profile is refused (profile, below), so
everything the development profile admits, such as a credential or patient
identifier over plain http
(What must travel encrypted),
follows the profile the process started with.
A valid configuration replaces the running registry at once. A request that started before the reload finishes on the registry it started with, nodes and credentials included; every request that starts after it uses the new one. An added endpoint gets its node client and its credentials, and a removed endpoint is never called again. What the gateway has learned stays, held to the new document:
- a learned
creating_system_idroute the new document maps to another node is withdrawn and raises aRegisteredCreatingSystemConflictincident (see Integrity incidents), and stays withdrawn; - every
ehr_idindex entry and resolution binding that names a member the document no longer holds is dropped. An entry that names such a member beside others is dropped whole, so a collision is never narrowed to the member that remains; a later read asks every member again. An entry a request already running learns after the reload, naming a member that left, is dropped the first time a request looks it up, with the same effect: a read asks every member, and a write without a target header is refused400(target-required).
The reload logs registry reloaded at INFO with members (how many the
registry now holds), endpoints_added, endpoints_removed,
members_removed, incidents, index_dropped and bindings_dropped. A
changed setting outside the reloaded sections is logged at WARN under
settings, by key (server.listen, federation.binding_ttl), and keeps
its running value until a restart; the rest of the reload applies.
A configuration that does not load is refused, and the running registry
stays. The gateway logs registry reload refused at ERROR with the failure
class, the config file and the registry document, and never a value of
either file, a credential or a header. Run ferrofed config check against
the same file to see the fault. The classes are:
class | The fault |
|---|---|
configuration | the configuration file does not read or resolve |
registry-unreadable | the registry document, or the directory, cannot be read |
registry-invalid | the registry document, or the directory’s content, breaks a registry rule |
registry-directory | the directory of [registry.mcsd] cannot be asked as configured |
registry-budget | a refresh of the directory ran past its deadline or a cap |
credentials | a [credentials] section names an endpoint the document does not declare |
demographic-endpoint | federation.demographic_endpoint names an endpoint the new document does not declare |
dev-cross-reference, pixm, resolvers | the resolver refuses the new members, or both resolvers are set |
localization | the localizer refuses the new members, or the node selection has none |
node-clients, http-client, self-description | the node clients or the OPTIONS {base}/ body cannot be built |
federation | a registry is configured without federation.node_selection or federation.id |
signing | a registry is configured without [signing] |
registry-presence | registry.document or [registry.mcsd] was set, unset or swapped for the other, which takes a restart |
profile | profile was changed, which takes a restart |
cleartext | a credential or a patient identifier would travel over a URL that is not https, outside the development profile the process started with |
auth | the gateway federates and [auth] trusts no issuer |
listener-certificate | the certificate files of the listener named in table do not form a pair |
Where no signal reaches the process, as in a container with no shell,
POST /admin/reload on the admin listener runs the same reload
(Reloading over the admin listener).
Each reload is counted by result on the metrics surface, and
the log lines record what it changed.
Integrity incidents
A federation integrity defect is reported to you, the federation operator, as
an incident: one ERROR line under the log target ferrofed::integrity,
written once when the gateway detects the defect (§12.5.2, §12b.2, N42). The
line carries a stable kind, the routing ids involved and a message, and
never a request body, a header value or a patient identifier. These kinds
reach the log today:
kind | When | Fields |
|---|---|---|
EhrIdCollision | A request addressed an ehr_id that two members or more claim, and was refused 409 (ehr-id-collision). One line per refused request. | ehr_id, detection (binding, index or ask-all: the routing step that found the claimants), claimants (their endpoint ids) |
IndexInsertCollision | The ehr_id index learned an ehr_id it already held at another member: the index-insert alarm of §12b.2. One line when the second claimant is learned, and one more for each further claimant. | ehr_id, claimants (the member node ids) |
PrescriptionIdentifierCollision | A dispensation named a prescription that two members or more hold for its patient, and was refused 409. One line per refused dispensation; the prescription identifier is never carried. | claimants (the members’ own endpoint ids) |
LearnedCreatingSystemConflict | A creating_system_id the registry document does not map was seen at two nodes, so the route learned for it is withdrawn and neither node is routed on (§12.2, N21). One line when the route is withdrawn. | creating_system_id, first_endpoint_id, second_endpoint_id |
RegisteredCreatingSystemConflict | A route learned for a creating_system_id names another node than the registry document maps it to, seen in an answer or found when the registry is reloaded (see Reloading the registry). The learned route is withdrawn and the document’s mapping is used. One line when the route is withdrawn. | creating_system_id, node_id (the node the document maps it to), endpoint_id (the endpoint the learned route named) |
The ehr_id is node-local and names no patient (§5.2), so the line names it
when it is a bare UUID. Any other form could be a patient identifier a client
wrote in a path, so the line then leaves the ehr_id field out.
Two nodes holding one ehr_id breaks the identifier-integrity conditions of
§12b.2, which admission should have checked, so the remedy is at the node.
The claimants name the members that hold the ehr_id. Have the node that
issued or adopted it in error fix it, then clear the collision the gateway
holds (Clearing a held collision); a restart
forgets it too, and the index forgets it when the entry is the least
recently used one past the index capacity. Until then, requests that name
no node are refused, and a client can still reach one of the members by
naming its endpoint in the openEHR-federation-endpoint header.
Clearing a held collision
Nothing the gateway observes shows that a node remedied a collision, and
what a federation does about one is left to its governance (§12b.2, §18),
so the clear is yours. Once the node at fault has fixed it, send the
ehr_id to the admin listener
with an access token that carries auth.product_scope:
POST /admin/ehr-ids/7d44b88c-4199-4bad-97dc-d78268e01398/clear
Authorization: Bearer <an access token with the operator scope>
The operator console sends the same request from its integrity view, to one
replica or to each in turn, when its [admin] table names the replicas’
admin listeners (The operator console).
-
The gateway forgets that
ehr_idfrom theehr_idindex and from the resolution bindings of every caller. The request takes the node-localehr_idfrom theclaimantsincident and nothing else: no body and no patient identifier. -
The next read of the
ehr_idthat names no node asks every member again. One member holding it is routed to; two or more are refused409(ehr-id-collision) again, with a newEhrIdCollisionincident (detectionask-all). A write is never probed for, so one that names no node answers400(target-required) until a read or a resolution teaches the gateway its owner (§12.5.1, N41). -
It answers
200with what this process held, also when it held nothing:{ "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398", "held": "collision", "claimants": ["node-a", "node-b"], "indexed": true, "sessions": 1, "scope": "replica" }heldiscollision(two members or more),owner(one) ornothing;claimantsare member node ids;indexedsays whether the index held an entry;sessionscounts the callers whose binding was dropped.ehr_idisnullfor anehr_idthat is no bare UUID. -
It is refused
400for a path value that is noHIER_OBJECT_ID(ehr-id-invalid) or a body (body-invalid),401and403as every admin write action is, and501when the gateway runs without a registry. Where[metrics] listenis unset, the action does not exist. -
scopeisreplica: the index and the bindings are per process, so with several replicas send the clear to each replica’s admin listener (Running several replicas). -
The clear is recorded with the operator who ran it, as every admin write action is (Who the admin listener serves), logged at
INFOunderferrofed::integrity, and a cleared collision is listed underclearedinGET {base}/operator/incidentsbeside the incidents, which stay as they were reported.
The gateway counts every incident by kind on its metrics
surface, ferrofed_integrity_incidents_total, and the line carries the
routing ids you act on. The request line of a refused request carries
its 409 and its request_id; the incident line does not name the request.
The registry in FHIR form
The specification recommends the FHIR Endpoint and Organization resources
for the registry (N19). Set registry.format = "fhir" and registry.document
names a FHIR R4 JSON Bundle of type collection or searchset instead,
holding only Organization and Endpoint resources: the shape an mCSD
directory delivers (§15.1). The default, registry.format = "toml", is the
native form (The registry document).
[registry]
document = "/etc/ferrofed/registry.json"
format = "fhir"
The form loads into the same members as the native form, and the gateway
routes over it identically. FHIR has no place for a node or an openEHR
system_id, and a resource’s logical id belongs to the server that holds it,
so FerroFED carries the registry’s ids as identifiers in its own systems (no
specification governs these systems; they are FerroFED’s design):
| FHIR element | Registry fact |
|---|---|
Organization.identifier with system https://ferrofed.eu/fhir/sid/organisation-id | the organisation id, exactly one |
Organization.name | the organisation’s display name |
Organization.endpoint | the endpoints whose node the organisation operates |
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/endpoint-id | the stable endpoint id used in directives (N19), exactly one |
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/node-id | the node the endpoint belongs to, exactly one |
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/system-id | that node’s openEHR system_id, exactly one |
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/creating-system-id | each further creating_system_id the endpoint answers for (N21), zero or more |
Endpoint.connectionType | openehr-rest-query in https://ferrofed.eu/fhir/CodeSystem/connection-type, or hl7-fhir-rest in http://terminology.hl7.org/CodeSystem/endpoint-connection-type for a member’s own exchange-format endpoint |
Endpoint.managingOrganization | the one managing organisation (N20) |
Endpoint.status | active, or suspended for an endpoint taken out of service |
Endpoint.address | the ITS-REST base URL |
Endpoint.extension with url https://ferrofed.eu/fhir/StructureDefinition/consent-refusal-code | one valueCode per consent refusal code, zero or more; the consent_refusal_codes of the native form |
An endpoint for the openEHR Query API never carries hl7-fhir-rest (§15.2).
No openEHR or HL7 code for it is registered yet, so FerroFED binds the one
code N19 names, openehr-rest-query, in a code system of its own. The mCSD
4.0.0 Endpoint profile binds connectionType to the HL7 endpoint connection
types extensibly, so a code from another system is admitted where the value
set has none for the purpose.
References resolve inside the Bundle as FHIR R4 §2.36.4.1 resolves them: a
relative Organization/org-a against the root of a REST fullUrl such as
https://registry.example.org/fhir/Endpoint/node-a-pub, and an absolute
reference, a urn:uuid: included, against an entry’s fullUrl. Give every
entry a fullUrl. Other elements (payloadType codings outside the category
system below, payloadMimeType, period, header and the rest) are not
read. A node’s product, version and node identifiers have no place in
this form; a registry that needs them uses the native form.
A member’s own endpoint in FHIR form
An Endpoint whose connectionType is hl7-fhir-rest in HL7’s
http://terminology.hl7.org/CodeSystem/endpoint-connection-type is
a member’s own exchange-format endpoint,
the native form’s hl7-fhir-rest [[endpoint]]. It carries the identifiers,
the managing organisation, the status and the address of any endpoint, is
listed by the organisation that operates its node, and declares the three
keys of the native form:
| FHIR element | Native form key |
|---|---|
Endpoint.payloadType.coding with system http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs | categories: one coding per category it serves, by its Art 14(1) priority category code, one or more |
Endpoint.extension with url https://ferrofed.eu/fhir/StructureDefinition/payload-profile | profiles: one valueCanonical per profile its documents follow, one or more |
Endpoint.extension with url https://ferrofed.eu/fhir/StructureDefinition/ehr-id-system | ehr_id_system: one valueUri, exactly one |
FHIR R4 defines payloadType as the type of content the endpoint serves, so
the categories are codings there, in the code system of HL7 Europe’s Health
Data API that the gateway’s category codes come from; a coding in another
system describes other content and is not read. FHIR R4 has no element for a
document profile (payloadMimeType names a serialisation) or for the
identifier system of an ehr_id, so those two are FerroFED’s extensions (no
specification governs them; they are FerroFED’s design):
{
"resourceType": "Endpoint",
"id": "hospital-a-eps",
"extension": [
{"url": "https://ferrofed.eu/fhir/StructureDefinition/payload-profile",
"valueCanonical": "http://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps"},
{"url": "https://ferrofed.eu/fhir/StructureDefinition/ehr-id-system",
"valueUri": "urn:oid:2.999.4.1"}
],
"identifier": [
{"system": "https://ferrofed.eu/fhir/sid/endpoint-id", "value": "hospital-a-eps"},
{"system": "https://ferrofed.eu/fhir/sid/node-id", "value": "hospital-a"},
{"system": "https://ferrofed.eu/fhir/sid/system-id", "value": "cdr-a.example.org"}
],
"status": "active",
"connectionType": {
"system": "http://terminology.hl7.org/CodeSystem/endpoint-connection-type",
"code": "hl7-fhir-rest"
},
"managingOrganization": {"reference": "Organization/org-a"},
"payloadType": [{"coding": [{
"system": "http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs",
"code": "Patient-Summaries"
}]}],
"payloadMimeType": ["application/fhir+json"],
"address": "https://cdr-a.example.org/fhir"
}
The endpoint meets every rule the native form’s does, and config check
names it when it breaks one: an hl7-fhir-rest endpoint with no
ehr_id_system extension, no category coding or no profile extension, or
with a consent refusal code extension, is refused, and so is an
openehr-rest-query endpoint that carries a category coding or either
extension. A category coding with no code, a profile extension with no
valueCanonical, and an ehr_id_system extension with no valueUri or
given twice are refused as the FHIR form’s own faults.
config check refuses the document with the configuration exit code, naming
the resource, when:
- an endpoint’s
connectionTypecarries no system (an informal string), or is a system and code other thanopenehr-rest-queryin FerroFED’s system andhl7-fhir-restin HL7’s (N19, §15.2). CP-20 is an operator point, and this check is how the gateway helps the operator meet it; - an endpoint has no
managingOrganization, or one that names noOrganizationof the Bundle (N20); - an endpoint is listed by no organisation, or by two;
- an organisation or an endpoint has no id in its system or more than one, or an id repeats;
- the endpoints of one node disagree on its
system_idor its operator; - an endpoint’s status is neither
activenorsuspended, or an organisation is marked inactive; - a resource carries a
modifierExtension, which FerroFED does not read; - a consent refusal code extension carries no
valueCode; - anything the native form refuses: a duplicate
system_id, an unusable base URL, or acreating_system_idthat is a member’s own.
The FHIR form and an mCSD directory keep an organisation whose endpoints left
the directory, so a refresh that deletes an organisation’s last endpoint is
applied; a selector that names it then selects nothing, and the request
answers 404 with no-destination (§11.2).
The definitions behind FerroFED’s URLs
Every https://ferrofed.eu/fhir/ URL of the registry in FHIR form resolves
to its FHIR R4 definition, so a receiving system can read it and a validator can check
a document against it:
| URL | Definition |
|---|---|
…/StructureDefinition/consent-refusal-code | an extension of Endpoint, zero or more, with a valueCode |
…/StructureDefinition/payload-profile | an extension of Endpoint, zero or more, with a valueCanonical |
…/StructureDefinition/ehr-id-system | an extension of Endpoint, at most once, with a valueUri |
…/CodeSystem/connection-type | a complete code system with the one code openehr-rest-query |
…/sid/organisation-id, …/sid/endpoint-id, …/sid/node-id, …/sid/system-id, …/sid/creating-system-id | a NamingSystem of kind identifier whose preferred uri is the URL itself |
The access log’s default origin system,
…/CodeSystem/access-log-origin,
resolves the same way, and so does every other https://ferrofed.eu/fhir/
URL FerroFED writes. The medication extension
https://ferrobridge.eu/fhir/StructureDefinition/remaining-dispensations and
the archetype-local code systems are
the interoperability component’s, under FerroBRIDGE’s base. A member writes
the medication extension, and the gateway carries it as the member wrote
it: no setting replaces its URL. FHIR R4
gives a NamingSystem no canonical URL, so
each identifier system’s NamingSystem is served at the system’s own URL.
The documentation site is a static site on GitHub Pages, which sets the
content type from the file extension and cannot redirect or negotiate on
Accept. Each definition is therefore served twice with the same bytes: at
its URL with no extension, which Pages sends as
application/octet-stream, and at the URL plus .json, which Pages sends
as application/json; charset=utf-8 (both read from the deployed site on
2026-10-16). A FHIR client that resolves a URL gets the definition’s JSON at
either; one that reads only a JSON content type fetches the .json form.
Neither is sent as application/fhir+json, the FHIR media type, because
Pages has no setting that attaches it to a file. No specification decides how a
canonical URL is hosted: this is FerroFED’s own design.
The archetype-local code systems
A FHIR document the gateway maps from openEHR writes an archetype’s own
codes, the at codes of its local terminology, in a code system per
archetype: https://ferrobridge.eu/fhir/CodeSystem/archetype-local/ followed
by the archetype id, for example
…/archetype-local/openEHR-EHR-EVALUATION.ferrofed_allergy.v1. No openEHR,
HL7 or FHIRconnect text defines a URI for those codes, and two versions of
an archetype are two archetypes, so each archetype id names its own system.
These URLs do not resolve. The site publishes no CodeSystem for them,
because there is one per archetype a deployment maps, and an archetype’s
codes are defined by the archetype itself. FHIR R4 makes resolution a
recommendation, not a requirement, in 4.1.2 Choosing a system: “An http:
address SHOULD resolve to some useful description of the code system.” The
same section asks that “A scope of the code system URI and the correct
usage of codes and displays in its namespace SHOULD be clearly defined”,
and this section is that definition: a code under
…/archetype-local/<archetype id> means what the archetype with that id
defines it to mean. The URI is the interoperability component’s, which
FerroBRIDGE publishes; FerroFED writes it as the component does.
Identity resolution
A patient query names its patient by an identifier and that identifier’s
issuing namespace. The gateway resolves the pair, outside the query, to the
local ehr_id each member holds for the patient, and sends each member a
query scoped to that ehr_id alone (§5.2, N3, N7). This page covers the
cross-reference the gateway asks and how you configure it. Which members are
asked at all is Localization, and which may not be is
Consent.
See how it works: where the patient identifier stops.
Where the identifier comes from
The gateway reads the patient on either carrier §5.4.3 names (N33, CP-38):
- the subject predicate of §7,
e/ehr_status/subject/external_ref/id/value = '…', with the namespace ine/ehr_status/subject/external_ref/namespace; - an
ENTRY-level subject,…/subject/identifiers/id = '…', with the namespace in…/subject/identifiers/issueror…/subject/identifiers/type.
GET {base}/v1/ehr?subject_id=…&subject_namespace=… names the patient in
its query string instead
(Reading an EHR by subject).
A query that names no namespace resolves in federation.default_namespace
when you set one, and is refused 400 (no-namespace) when you do not.
§5.2 requires a namespace and names no default, so the setting is
FerroFED’s own:
[federation]
default_namespace = "urn:oid:2.999.1"
A PIX Manager: [pixm]
The identity binding FerroFED ships is IHE PIXm, the Patient Identifier
Cross-reference Manager’s ITI-83 query (Annex A.1). Each registry member is
resolved by one Manager, in its ehr_id domain there: the assigning
authority whose identifiers are that member’s ehr_ids.
[[pixm.manager]]
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
[pixm.manager.members]
"hospital-a" = "urn:oid:2.999.10" # a registry node id = its ehr_id domain
"clinic-b" = "urn:oid:2.999.20"
[pixm.manager.endpoint.credentials]
user = "ferrofed-pix-consumer"
password_file = "/run/secrets/pix-password"
[pixm.namespaces]
"2.999.1" = "urn:oid:2.999.1" # a client namespace = the PIX assigning authority
- Name one
[[pixm.manager]]per Manager. Every member of the registry is resolved by exactly one: a member no Manager names, a member two Managers name, and a name the registry does not hold each refuse the configuration. - Each domain, and each value of
[pixm.namespaces], is an absolute URI. [pixm.manager.endpoint]is the Manager’s endpoint table. Itsurlis the Manager’s FHIR base,https, with no query or fragment. The Manager is asked for patient identifiers, sohttpis refused namingpixm.manager[N].endpoint.url, with or without credentials, unless the profile isdevelopment(What must travel encrypted). A user name or password in it refuses the configuration, namingpixm.manager[N].endpoint.urland never quoting it; the credentials go in[pixm.manager.endpoint.credentials], with the keys of an endpoint’s credentials:userandpassword, the password with its_filesibling, or anoauthgrant (A PIX Manager behind OAuth 2.0). Leave the section out when the transport, such as mutual TLS or a private network, authenticates the gateway.[pixm.namespaces]maps the namespace a client writes to the assigning authority the Manager knows it by. A namespace that is itself an absolute URI needs no entry.methodis"get", the default, or"post", which keeps the patient identifier out of the request URL (GETorPOST).
For each query, the gateway asks each Manager once, with one targetSystem
per member that Manager resolves, and reads the identifier the answer holds
in a member’s domain as that member’s ehr_id. The patient identifier goes
to the Manager and nowhere else; no node, log line or error body carries it
(§5.4.1, N33).
| The Manager answers, for one member | The member is | The query |
|---|---|---|
exactly one identifier in the domain, which reads as an ehr_id | sent its node query | goes on |
| no identifier in the domain, or the patient is unknown | not-resolved | goes on; complete is false (N6) |
two different identifiers, one that is no ehr_id, an error, no answer within the budget, or a namespace with no mapping | not-resolved, with the reason in error | fails 424 under all-or-nothing |
A member the Manager could not answer for may hold the patient, so the
gateway fails the query by default rather than answer without it (§11.1;
§11.3 covers only an answered lookup, so this rule is FerroFED’s own). With
openEHR-federation-completeness: partial, the members that did resolve
answer. Resolution runs inside the request’s overall budget
(Timeouts).
Two identical identifiers in one domain, the same system and the same
value, are one identifier: ITI-83 returns one targetIdentifier per
business identifier of the patient (PIXm 3.1.0 §2:3.83.4.2.2.1), and
resolution yields a set of {node, local ehr_id} (§5.2). A Manager that
lists the same identifier from a master record and a local record resolves
the member. Two different values in one domain are never guessed between.
A PIX Manager behind OAuth 2.0
A Manager that issues expiring tokens, as an IHE IUA Authorization Server
does, is asked with a token the gateway obtains itself by the OAuth 2.0
client-credentials grant (RFC 6749 §4.4; IUA ITI-71 §3.71.4.1.2.1). The
oauth table is the one of ferrohealth-smart, which every FerroHEALTH
product reads for an upstream it calls as itself, and the same table serves
[pdqm.endpoint.credentials], [pmir.endpoint.credentials] and
[registry.mcsd.endpoint.credentials].
[pixm.manager.endpoint.credentials.oauth]
token_endpoint = "https://mpi.example.org/auth/oauth2_token"
client_id = "ferrofed-pix-consumer"
client_auth = "client_secret_basic" # or client_secret_post, or private_key_jwt
client_secret_file = "/run/secrets/pix-consumer-secret"
scope = "*" # the scopes the Manager's server defines
# issuer = "https://mpi.example.org" # in place of token_endpoint: read it from the RFC 8414 metadata
# resource = "https://mpi.example.org/fhir/" # RFC 8707, when the server takes one
# audience = "https://mpi.example.org"
client_authsays how the gateway authenticates at the token endpoint:client_secret_basic, the client id and secret in the HTTP Basic scheme, as ITI-71 prescribes;client_secret_post, the two in the request body, for a server that reads them there (RFC 6749 §2.3.1); orprivate_key_jwt, a client assertion signed by the key inprivate_key_file(RFC 7523 §2.2), whose public half the gateway publishes in its JWK Set, with no secret.client_secretorclient_secret_fileholds the secret. It is required with the two secret methods and refused besideprivate_key_jwt.scopeis required here, each value an RFC 6749 §3.3 scope token.token_endpointishttpsoutside the development profile: the client secret or assertion travels to it. One discovered fromissueris alwayshttps.- The
oauth2andfapi2tables of earlier releases are refused here, naming theoauthtable that replaces them, and so is anonwardtable, which only a node takes (Onward credentials).config checknames every key it refuses, and never a secret.
The gateway caches the token until refresh_margin_ms, 30 seconds by
default, before the expires_in the token response states, and incorporates it in each ITI-83 request as
Authorization: Bearer (ITI-72 §3.72.4.2). A token with no stated lifetime
serves the one request it was obtained for. When the Manager answers 401,
the gateway drops the token, obtains a new one, and sends the request once
more; it never retries with the refused token (§3.72.4.3). A token endpoint
that refuses or does not answer within the per-node timeout leaves the
members of that Manager not-resolved with the reason in error, and the
query fails 424 under all-or-nothing; nothing is sent to the Manager
without a token.
[pixm] as the localizer
Under federation.node_selection = "localized" with no [xcpd], the
[pixm] resolver is also the localizer, the “demographic-registration” kind
of §14.2. The candidates are the members whose domain holds an identifier
for the patient at its Manager. Every other member is not-localized and is
never asked. The resolution of the same query reuses that ITI-83 answer, so
each query still asks each Manager once.
A member whose domain holds two identifiers, or one that is no ehr_id, is a
candidate, because the Manager holds the patient there; its resolution then
reports why it could not be asked. A Manager that fails, answers in a form
the gateway cannot read, or does not answer within
[federation.localization] timeout_ms fails the localization closed
(§14.1): every member is not-localized with the error, and no member is
asked unless on_failure = "ask-all". OPTIONS {base}/ declares
localization.mode as "pixm". With [xcpd] set, XCPD localizes and
[pixm] only resolves.
Keeping the PIX Manager current
The gateway reads each member’s ehr_id from the Manager and never writes
to it. The Manager answers only from what its identity feeds delivered, so
for every patient it must hold:
- the identifier clients name the patient by, in the system
[pixm.namespaces]maps the client’s namespace to (or the namespace itself when it is an absolute URI); - for each member that holds an EHR for the patient, that EHR’s
ehr_idas an identifier whosesystemis the member’sehr_iddomain, the value[pixm.manager.members]maps the member to; - the demographics the Manager links records by. The PIXm Patient profile
requires a
name(PIXm 3.1.0, StructureDefinitionIHE.PIXm.Patient).
Annex A puts it the same way: the ITI-104 identity feed “seeds the cross-reference with, for example, a local id or an openEHR id” (Annex A §A.1). How the Manager links records from different domains to one patient is its own business: PIXm “does not specify the rules and algorithm applied by the Patient Identifier Cross-reference Manager” (PIXm 3.1.0 §1:41.4.1).
Who feeds a member’s domain
Each ehr_id domain has one feeding system. “The Patient Identifier
Cross-reference Manager shall only recognize a single Patient Identity
Source per domain” (PIXm 3.1.0 §2:3.104.4.1.3). Make that Source the member
itself, or the integration engine at the member that creates its EHRs or
sees them created. Whether a CDR product can send a feed message on its own
depends on the product. FerroFED makes no claim for any CDR.
Register each member’s domain at the Manager before you add the member to
the registry. A Manager that does not know a domain the gateway asks for
refuses the whole call (403, PIXm 3.1.0 §2:3.83.4.2.2.4), and every member of
that Manager is then unavailable for the query.
ITI-104: the Patient Identity Feed FHIR
ITI-104 is PIXm’s own feed (PIXm 3.1.0 §2:3.104). The Source sends it when
it adds a patient, revises one, merges two of its own records, and, with
the Remove Patient Option, when it removes one (§2:3.104.4.1.1,
§2:3.104.4.2.1, §2:3.104.4.3.1). For a member, adding a patient is creating an EHR. The
message is a conditional update keyed on the identifier in the Source’s
own domain (§2:3.104.4.1.2), here the ehr_id:
PUT https://pix.example.org/fhir/Patient?identifier=urn:oid:2.999.10|aaaaaaaa-aaaa-4aaa-8aaa-000000000001
Content-Type: application/fhir+json
{
"resourceType": "Patient",
"identifier": [
{ "system": "urn:oid:2.999.10", "value": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001" },
{ "system": "urn:oid:2.999.1", "value": "ffd-test-0001" }
],
"name": [ { "family": "Synthetic", "given": [ "Example" ] } ]
}
The Manager’s CapabilityStatement requires conditional update for ITI-104
and conditional delete with the Remove Patient Option (PIXm 3.1.0,
CapabilityStatement IHE.PIXm.Manager). A removed EHR is that conditional
delete on the same identifier (§2:3.104.4.3.2).
ITI-93: the PMIR Mobile Patient Identity Feed
When the Manager is a PMIR Patient Identity Registry, the Source sends the
Mobile Patient Identity Feed instead (PMIR 1.6.0 §2:3.93), on each create,
update, merge or delete (§2:3.93.4.1.1). It is an HTTP POST of a message
Bundle: a MessageHeader whose event is urn:ihe:iti:pmir:2019:patient-feed,
whose focus is a history Bundle that holds the Patient (§2:3.93.4.1.2). The
Patient carries the same identifiers as the ITI-104 example. A Registry
answers ITI-83 as the Patient Identifier Cross-reference Manager (PMIR 1.6.0
§1:49.1.1.1), and its CapabilityStatement declares $ihe-pix on Patient
(CapabilityStatement IHE.PMIR.PatientIdentityRegistry).
PMIR keeps a single master identity per patient (PMIR 1.6.0 §1:49), so each
member’s ehr_id is one more identifier on it. This feed runs from each
member to the Registry. It is a different flow from [pmir]
below, where the gateway subscribes to hear of identity changes.
A bulk load for the EHRs that already exist
Neither profile defines a bulk transaction, so each EHR that existed before the feed started reaches the Manager as one feed message. At each member, as that member’s operator, read the pairs from the member’s own CDR:
SELECT e/ehr_id/value,
e/ehr_status/subject/external_ref/id/value,
e/ehr_status/subject/external_ref/namespace
FROM EHR e
Then send one ITI-104 or ITI-93 message per row, as the member’s Source, and switch the Source to the live feed before the load ends, so an EHR created during the load is not missed. Run the query at the member, never through the gateway: the member is the Source of its domain, and the pairs are the member’s to publish. A member whose EHRs carry no subject has no pairs to read, and its feed has to come from the system that knows which patient each EHR belongs to.
Why the gateway does not feed the Manager
FerroFED could read each member’s EHRs and post the pairs itself. It does not (no specification governs this: our own design):
- The Manager recognizes one Source per domain (PIXm 3.1.0 §2:3.104.4.1.3), and the member creates, merges and removes its own EHRs. A copy the gateway posts in batches is a second Source that lags the first.
- FerroFED holds no clinical data and stores no patient identifier. A feed would make it read and send every patient’s identifier in bulk, which no federation request needs: a query consumes one identifier, once, at resolution (§5.4.1, N33).
- Feeding a Manager is the identity source’s function. A helper that does it belongs beside the MPI, never on the gateway’s request path.
A PIX Manager verified with FerroFED: SanteMPI
SanteMPI, SanteSuite’s master patient index (Apache License 2.0), answers
ITI-83 and takes the ITI-93 feed. The end-to-end lane runs
santesuite/santedb-mpi:2.5.12, pinned by the digest in the example below,
against two FerroEHR members: each member’s feed application registers
the patient with its own ehr_id over ITI-93, the gateway resolves the
patient over ITI-83 GET, both members answer the federated query, and a
patient no member fed is not-resolved at both.
Set it up as follows. The image settings are the ones the lane uses, and every domain, address and secret is an example.
-
Run the image with SanteSuite’s documented SanteMPI features and a PostgreSQL database (SanteSuite: Using Docker Containers):
services: santempi: image: santesuite/santedb-mpi:2.5.12@sha256:608484de046a932ec2f92e9991a32507fc8ec89d53d7639cbab886a63dbf6207 environment: SDB_FEATURE: "LOG;DATA_POLICY;AUDIT_REPO;ADO;PUBSUB_ADO;RAMCACHE;SEC;SWAGGER;OPENID;FHIR;HDSI;AMI;BIS;MDM;MATCHING;IHE_PIXM;IHE_PDQM;IHE_PMIR" SDB_MATCHING_MODE: WEIGHTED SDB_MDM_RESOURCE: Patient=org.santedb.matching.patient.default SDB_MDM_AUTO_MERGE: "false" SDB_DB_MAIN: "server=db;port=5432; database=santedb; user id=santedb; password=…; pooling=true;" SDB_DB_AUDIT: "server=db;port=5432; database=auditdb; user id=santedb; password=…; pooling=true;" SDB_DB_MAIN_PROVIDER: Npgsql SDB_DB_AUDIT_PROVIDER: Npgsql SDB_OPENID_INSECURE_CLIENT_AUTH: "true"Its FHIR base is
{origin}/fhir/and its token endpoint{origin}/auth/oauth2_token. -
Create one security application per member’s feed, and one for the gateway, through SanteMPI’s administration interface, each granted the client-credentials policies
1.3.6.1.4.1.33349.3.1.5.9.2.1.0.0.1and1.3.6.1.4.1.33349.3.1.5.9.2.1.0.0.1.0, and setSDB_OPENID_INSECURE_CLIENT_AUTH: "true"so an application can obtain a token with no device credential. SanteMPI denies the client-credentials grant otherwise. Grant the two policies by hand: SanteDB 2.x loads its OAuth flow policies from000-OAuthPolicies.datasetafter its initialisation script has granted the built-in applications every policy that existed then, so no application holds them out of the box. -
Register the identity domains: the patient namespace, open to every source, and one per member, with
urlset to the member’s domain URI and the member’s feed application as its assigning application, so that application is the domain’s one authoritative Source (SanteSuite: Patient Identity Feed, Blocks Inappropriate Assigner). SanteMPI writes the domain’surlas the identifiersystemin its answers, so it must equal the value in[pixm.manager.members]. -
Feed each member’s EHRs as its feed application, over ITI-93 to
POST {origin}/fhir/Bundle, live and as the bulk load above. -
Point the gateway at it:
[[pixm.manager]] method = "get" [pixm.manager.endpoint] url = "https://mpi.example.org/fhir/" [pixm.manager.members] "hospital-a" = "urn:oid:2.999.10" "clinic-b" = "urn:oid:2.999.20" [pixm.manager.endpoint.credentials.oauth] token_endpoint = "https://mpi.example.org/auth/oauth2_token" client_id = "FFD_PIX_CONSUMER" client_auth = "client_secret_post" # SanteMPI reads the client from the body client_secret_file = "/run/secrets/mpi-consumer-secret" scope = "*"
Before you rely on it, weigh these:
- The image. 2.5.12, published on 2023-07-04, is the newest stable
image SanteSuite publishes. It is built for
linux/amd64only and runs on Mono as root. The 3.0 line is still an alpha. Put it through your own security review. - The token. SanteMPI issues bearer tokens by the client-credentials
grant, and they expire. The gateway runs that grant itself and refreshes
the token before it expires
(A PIX Manager behind OAuth 2.0).
SanteMPI reads the client id and secret from the request body, so
client_authisclient_secret_post. - What was verified. Resolution by
GET, which ismethod’s default, and the feed over ITI-93. ITI-83 byPOSTand the ITI-104 feed were not verified against SanteMPI. - Every domain registered. SanteMPI refuses a call naming a domain it
does not know with
403, as PIXm requires, so the members of that Manager are unavailable and the query fails424.
Products checked that do not answer as the gateway needs
| Product | What it is | Why it is not a Manager for FerroFED |
|---|---|---|
| HAPI FHIR JPA server | a FHIR server with a master data management module | its MDM operations do not include $ihe-pix (HAPI FHIR: MDM Operations) |
| OpenCR (IntraHealth client registry) | a client registry on a FHIR server | its $ihe-pix is served on GET alone, compares a single targetSystem, so a call for two members’ domains finds none, and writes targetId without a Reference value (server/lib/routes/fhir.js) |
| IHE Gazelle Patient Manager | a simulator that plays IHE actors, the PIXm Manager among them, for testing | a test tool, not a production MPI (Gazelle Patient Manager) |
| IPF | a Java integration library | a component to build a Manager with, not a server (IPF: ITI-83) |
| OpenHIM | an interoperability layer | it relies on a client registry behind it and holds no cross-reference of its own (OpenHIM: About) |
When the hospital MPI does not speak PIXm
The cross-reference role need not sit in one place. “Whether the node, its organization’s MPI, or a regional service fills that role is immaterial to the federation” (N34, §5.5). When the MPI you have does not answer ITI-83:
- It speaks PIX or PIXV3. A PIXm Manager “can be grouped with either PIX or PIXV3 Patient Identifier Cross-reference Consumer and Source to proxy” ITI-83 and ITI-104 (PIXm 3.1.0 §1:41.6.1). A PIXm front grouped with the MPI in that way serves the gateway. FerroFED has verified no such front.
- Run a PIXm Manager for the federation beside it. SanteMPI, set up as
above, holds the members’
ehr_iddomains, keyed on an identifier the hospital MPI issues, such as its enterprise identifier. The hospital MPI stays the authority for its own identifiers, and the federation’s Manager only cross-references them toehr_ids. The feed to it comes from the members, as above. - Clients name a local identifier. If clients know the patient by an
identifier only the hospital MPI holds, and the MPI answers PDQm,
[pdqm]translates it to the master identifier first. - A Manager per organization. Each
[[pixm.manager]]names the members it resolves, so each member’s own organization can run its own Manager.
FerroFED keeps no patient index of its own, in any profile. Without a PIX
Manager, every patient query fails 424, as
Choosing one says.
Demographics first: [pdqm]
A client may name the patient by an identifier the cross-reference does not know, such as a hospital-local number. Annex A places a PDQm query ahead of localization and resolution for that case (Annex A §A.2 and §A.7): the gateway asks a Patient Demographics Supplier which person the identifier names, takes the identifier that person carries in the master domain, and localizes and resolves that identifier as it would the client’s own.
[pdqm]
transaction = "iti-78" # or "iti-119"
master = "urn:oid:2.999.1" # the master domain's identifier system
[pdqm.endpoint]
url = "https://pdq.example.org/fhir/"
timeout = "1s" # each exchange with the Supplier, the default
[pdqm.namespaces]
"urn:oid:2.999.7" = "urn:oid:2.999.7" # a client namespace = the system it is sent in
[pdqm.endpoint.credentials]
user = "ferrofed-pdq-consumer"
password_file = "/run/secrets/pdq-password"
- Only an identifier in a namespace
[pdqm.namespaces]names goes to the Supplier. Every other one is resolved as it is, and a namespace[pixm.namespaces]maps is refused here, naming the key. transaction = "iti-78", the default, sends the Mobile Patient Demographics Query:identifier=<system>|<value>andidentifier=<master>|, which asks the Supplier for identifiers in the master domain alone (PDQm §2:3.78.4.1.2.3)."iti-119"sends the Patient Demographics Match instead, with the identifier on the input Patient andonlyCertainMatchesset (§2:3.119.4.1.2); declare it only where the Supplier offers$match.masteris the identifier system of the master domain. The master identifier is resolved in the namespace of the same name, so[pixm]must resolve it;[pdqm]without it refuses the configuration.[pdqm.endpoint], the credentials and thehttprule are those of a PIX Manager: the Supplier is sent the patient identifier, so plainhttpis refused, namingpdqm.endpoint.url, unless the profile isdevelopment. No redirect is followed, and an answer is read up to 8 MiB.- A
SIGHUPreload applies a change to[pdqm], as it does to[pixm]: the reloaded federation asks the Supplier the new table names.
| The Supplier answers | The members are | The query |
|---|---|---|
one active Patient carrying exactly one identifier in the master domain (for ITI-119, graded certain) | localized and resolved by the master identifier | goes on |
| no Patient, or one with no master identifier (PDQm §2:3.78.4.1.3 Case 3, §2:3.119.4.1.3 Cases 4, 5 and 7) | not-resolved | goes on; complete is false (N6) |
more than one Patient, a Patient with two master identifiers, or an ITI-119 match not graded certain | not-resolved, with the reason in error | fails 424 under all-or-nothing |
a failure, or no answer within timeout_ms | not-localized with the error under on_failure = "closed"; not-resolved under "ask-all" and on a directed query | 200 under "closed" (§14.1); 424 otherwise |
A Patient with active set to false is a deprecated record and never a
match (§2:3.78.4.1.3 Case 6). The gateway never picks one of several
matches: ITI-78 counts them in Bundle.total (§2:3.78.4.1.3 Case 1), and
ITI-119 returns one entry for each (§2:3.119.4.1.3 Case 2). The step feeds
localization, so an outage on an undirected query follows the localization
failure policy, closed by default, as a localizer outage does; an exchange
whose audit record cannot be stored fails closed under every policy. The
read of an EHR by subject answers 404 for no match and 424 for the
rest.
The client’s identifier reaches the Supplier and nowhere else; the master
identifier reaches the cross-reference and nowhere else. The outbound gate
withholds both from every request to a node, and no log line, metric label
or error carries either (§5.4.1, N33). Each exchange is audited through
[audit], which is required outside development. The step’s
state shows as demographics on
GET {base}/operator/dependencies, and each call is counted in
ferrofed_demographics_requests_total (Metrics).
No patient index: [dev] is refused
FerroFED keeps no table of patient identifiers, not even for a laptop or a
test (no specification governs this: our own design). Earlier releases read
a static cross-reference, [[dev.crossref]], under profile = "development". A configuration that still sets [dev] is refused at load:
ferrofed: cannot start: [dev] is no longer read: FerroFED keeps no patient index, so it resolves every patient through a PIX Manager; remove [dev] and configure [[pixm.manager]]
The refusal quotes no row of the table. To try FerroFED with synthetic
patients, run the quickstart: it starts
FerroPIX, seeds it with the synthetic patients over ITI-104, and resolves
through [[pixm.manager]].
Choosing one
Set [pixm] to resolve patients. Without it, the gateway still starts, and every patient query fails closed: each
member is not-resolved with “no cross-reference service is configured”,
and the query is a 424. A query that names no patient needs no resolution
and runs as written.
The section takes effect on a
reload. GET {base}/operator/dependencies
reports the resolver’s last observed state
(Health probes).
Under federation.node_selection = "localized", [pixm] is also the
localizer ([pixm] as the localizer,
Node selection).
The identity feed: [pmir]
A merge at the identity source can leave a caller’s
resolution bindings naming the ehr_id of an identity that no
longer exists. Each binding expires in any case federation.binding_ttl
after the last resolution that returned it, and a resolution after the merge
no longer returns the stale one. With
[pmir] set, the gateway also hears of each change as it happens: it
subscribes at your IHE PMIR Patient Identity Registry with ITI-94, and the
Registry sends every Patient Master Identity change to the gateway as an
ITI-93 message (PMIR 1.6.0, Annex A.4).
[pmir]
callback_url = "https://gateway.example.org/pmir/feed" # where the Registry sends the feed
path = "/pmir/feed" # the route under {base}, the default
feed_clients = ["pmir-registry"] # the clients the Registry sends as, at an [[auth.issuer]]
# identifier_system = "urn:oid:2.999.1" # only Patients with an identifier here
# check_interval = "1min"
# on_drain = "keep" # or "unsubscribe"; see Several replicas
[pmir.endpoint]
url = "https://pmir.example.org/fhir" # the Registry's FHIR base
# timeout = "5s" # each exchange with the Registry
[pmir.endpoint.credentials.oauth] # how the gateway authenticates to the Registry
token_endpoint = "https://pmir.example.org/auth/token"
client_id = "ferrofed-pmir-subscriber"
client_auth = "client_secret_basic"
client_secret_file = "/run/secrets/pmir-subscriber-secret"
scope = "*"
- The subscription. At start the gateway creates a
Subscriptionatendpoint.urlwith amessagechannel tocallback_urland a FHIR JSON payload. Its criteria isPatient, orPatient?identifier=<system>|withidentifier_systemset, so the Registry sends only the Patients that carry an identifier from that authority. Before it creates one, the gateway searches the Registry for its own (GET [base]/Subscription?url=<callback_url>) and adopts the one it finds, so a create the Registry answered late is never made twice. When the search lists several, it adopts the usable one whose location sorts first and deletes the others. A delete the Registry answers404or410found the subscription already gone. A Registry that answers that search400or404does not support it, and the gateway creates. Everycheck_intervalthe gateway reads the subscription back. It deletes and recreates one the Registry reportserrororoff, and recreates one the Registry no longer holds. Each failed check doubles the wait before the next, up to 32 timescheck_interval, and a check that succeeds resets it. If the Registry answers a create201with noLocation, or with one outsideendpoint.url, the gateway cannot manage that subscription. It creates no other until a restart and reports the fault asunmanageable; delete that subscription at the Registry. On a drain the gateway stops checking and lets the check in flight end. Withon_drain = "keep", the default, it leaves the subscription at the Registry, for the other replicas and for its next start, which adopts it. Withon_drain = "unsubscribe"it then deletes its subscription, found by search when it never learned where it was. PMIR lets a subscriber delete a subscription (§2:3.94.4.5) and does not say when. - Authenticating the feed. The subscription carries no credential for the
feed (PMIR §2:3.94.5), so you register the Registry as a client of an
[[auth.issuer]]the gateway trusts, name itsclient_idinfeed_clients, and agree the client with the Registry’s operator. The Registry sends a client-credentials token (RFC 6749 §4.4) in the bearer scheme. A message without one, or with one that does not verify, is answered401with aBearerchallenge, one of another client or of a person403, and neither changes anything. - The route.
POST {base}{path}takes one ITI-93 message. The route sits outside the ITS-REST surface and its client authentication, so the path may not be under/v1,/healthor/.well-known. A message that does not hold to the PMIR profiles is answered400(415for a media type other than FHIR JSON) with anOperationOutcome, and changes nothing. An applied message is answered with the ITI-93 response. - What a message changes. Only routing state, never a record. A merge
or a delete drops every binding of the
ehr_ids its Patient carries in a member’sehr_iddomain, the domains[pixm]maps. An update states the identity as it now is and never what it lost, so it drops every binding, as does a merge or delete that carries no suchehr_id. A create drops nothing. A dropped binding costs one resolution, never a wrong node. Theehr_idindex stays: a merge moves no EHR between nodes. - What is logged. The kind of each change and their counts, and how many bindings went. The messages carry patient identifiers and demographics, so no identifier reaches a log line, a metric or an answer.
endpoint.url and callback_url carry patient identities, so both are https
outside profile = "development", and neither may carry a user name or a
password. GET {base}/operator/dependencies reports the Registry as
identity_registry: up while the gateway holds a subscription in
requested or active; failing after a refusal, an answer that breaks
ITI-94, or a create it cannot manage; and down when the Registry did not
answer. identity_registry_fault names the reason (Health). Each message is counted by result in
ferrofed_identity_feed_messages_total (Metrics). A change to
[pmir] takes a restart.
PMIR 1.6.0 defines no unmerge (§2:3.93.4.1.3), so the gateway hears of a merge and never of a split. The specification marks this lifecycle track provisional, and FerroFED claims no propagation of an identity change to a later resolution: the conformance matrix keeps track 8 deferred.
Several replicas
A subscription is named by its callback_url, and the Registry sends each
ITI-93 message once to that URL. How your replicas share the feed follows
from the callback_url you give each of them:
- One
callback_urlfor every replica, your balancer’s address. The replicas share one subscription: the first to start creates it, and the others adopt it. Two replicas that create one each at the same moment are sent each change twice until the next replica start, which keeps one and deletes the other. Keepon_drain = "keep", so a rolling restart never deletes the subscription the other replicas rely on. Each message reaches the one replica the balancer picks, which drops the stale bindings. The other replicas keep theirs until they expire: a binding livesfederation.binding_ttlafter its caller’s last resolution (Resolution bindings), so on those replicas a stale binding is routed on until its caller has made no resolution for that long. Lowerbinding_ttlto narrow the window. - A
callback_urlof its own for each replica, an address the Registry reaches each replica at, such as a StatefulSet pod’s stable name. Each replica holds its own subscription, so the Registry sends every change to every replica and each drops its stale bindings as it happens. Seton_drain = "unsubscribe", so a replica deletes its own subscription when it stops; it never touches another replica’s. A replica that stops without draining leaves its subscription until it starts again under the samecallback_urland adopts it, or until you delete it at the Registry.
To stop using the feed with a shared callback_url, set on_drain = "unsubscribe" on the last replica before it stops, or delete the
subscription at the Registry.
Localization
Under federation.node_selection = "localized", the gateway first asks a
localization service which members may hold the patient, and asks only
those (§14, N4). This page covers IHE XCPD, the Dutch NVI, and the audit
repository XCPD reports to. A PIX Manager and the development table can
localize as well (Identity resolution).
XCPD localization: [xcpd]
The [xcpd] table makes the gateway an IHE XCPD Initiating Gateway, the
specification’s proposed localization binding (N4, §14.1, Annex A.3). Under
federation.node_selection = "localized", each undirected patient query
first asks every configured Responding Gateway, by the patient’s identifier
alone, which communities hold the patient (ITI-55 Cross Gateway Patient
Discovery, ITI TF-2 §3.55, Revision 20.1). The members serving those
communities are the candidates; every other member is not-localized and is
not asked. A match is a candidate to ask, never a release decision: each
node still enforces consent (§14.3).
profile = "production"
[federation]
node_selection = "localized"
[federation.localization]
on_failure = "closed" # the default (§14.1)
timeout = "5s"
[xcpd]
sender_device = "2.999.40.1" # the gateway's device OID
home_community = "2.999.40" # optional: the gateway's own community
audit = "log" # required: "log", or "off" in development
assertion_file = "/run/secrets/xua.xml" # optional
[[xcpd.gateway]]
device = "2.999.50.1" # the receiver device OID
# community = "2.999.50" # optional: ask for this community only
# myhealth_eu = true # optional: a MyHealth@EU route, an eHDSI link
[xcpd.gateway.endpoint]
url = "https://xcpd.region.example.org/RespondingGateway"
[xcpd.gateway.endpoint.tls]
client_identity_file = "/run/secrets/xcpd-client.pem"
trust_roots_file = "/etc/ferrofed/xcpd-roots.pem" # optional
# min_version = "1.2" # only for a MyHealth@EU route, below
[xcpd.communities] # every member needs one
"2.999.50" = "node-a"
"urn:oid:2.999.60" = "node-b"
[xcpd.namespaces] # only for a namespace that is no OID
"region-mrn" = "2.999.1"
The request names the patient by the shared identifier mode of ITI-55:
one LivingSubjectId whose root is the assigning authority and whose
extension is the value, with no name, birth date or other demographics.
A namespace that is an OID, dotted or as urn:oid:, is the assigning
authority; any other needs an entry in [xcpd.namespaces]. A namespace with
no mapping fails the query closed.
What a deployment must provide:
- The device OIDs of the gateway (
sender_device) and of each responding gateway (device). ITI TF-2 Appendix O requires an ISO OID for each, and every identifier here is refused at boot unless it is one. - The community map. Every registry member must be served by a community, or boot is refused, since no discovery could ever name it. A community a gateway answers that the map does not name belongs to no member and adds no candidate.
- One endpoint table per gateway. Each
[[xcpd.gateway]]names its own endpoint table, since the gateway posts to a responding gateway only on the origin its table’surlnames. It takes nocredentials: the XUA assertion is the credential, and a credentials table there is refused. - TLS. Every XCPD actor is an ATNA Secure Node or Secure Application
(ITI TF-1 Table 27.1.3-1), so a gateway URL must be
https. In each gateway’s[xcpd.gateway.endpoint.tls],client_identity(orclient_identity_file) holds the PEM client certificate chain and private key for mutual TLS,trust_roots_fileadds the network’s roots to the platform’s, andmin_versionis the oldest TLS version the gateway speaks,"1.3"unless you lower it to"1.2". A plainhttpURL is refused at boot, naming its key, unless the configuration isprofile = "development". - The XUA assertion, where the network requires one. ITI-55 requires
none, but many networks require a SAML 2.0 assertion (IHE XUA, ITI-40).
The gateway signs nothing: your identity provider or security token
service issues and signs the assertion, and the gateway sends its bytes
unchanged in a WS-Security header, so its signature still verifies.
assertion(orassertion_file) must hold exactly onesaml2:Assertionelement that declares every namespace prefix it uses; anything else is refused at boot. An assertion expires: replace the file before itsNotOnOrAfterand reload. - An audit destination. The Initiating Gateway records an audit message
for every exchange (ITI TF-2 §3.55.5.1.1), so
audithas no default.audit = "repository"sends each message to an ATNA Audit Record Repository over ITI-20 (The audit repository).audit = "log"writes each message as a structured event at the log targetferrofed::audit: the event, its outcome (0success,4the gateway answered with a failure,8no answer), this process’s id, the responding gateway’s endpoint and host, and thehomeCommunityIdthe request named. The query parameters, which name the patient identifier, are never logged; the event says only that they were recorded. Route that target to your audit repository.audit = "off"records nothing and is refused outsideprofile = "development".OPTIONS {base}/declares the choice aslocalization.audit. A message the destination cannot accept fails the discovery closed under every policy,on_failure = "ask-all"included, since that policy covers a localizer outage and never an exchange the gateway could not audit: no answer is used without its audit, and no member is asked.
The discovery fails closed as a whole. One responding gateway that faults,
answers an error (Case 5 of §3.55.4.2.3), asks for demographics (Case 3),
answers outside ITI-55, or stays silent past timeout_ms leaves every member
not-localized with the error, and the query asks no node; a community
behind that gateway might hold the patient. on_failure = "ask-all" asks
every member instead (Node selection).
OPTIONS {base}/ declares localization.mode as "xcpd".
A MyHealth@EU route
myhealth_eu = true on a [[xcpd.gateway]] marks it as a MyHealth@EU
route, an eHDSI link. Such a gateway is held to the eHDSI rules, and boot
refuses a configuration that breaks one, naming the gateway as
xcpd.gateway[<index>]:
- No fixed assertion.
assertionandassertion_filehold one long-lived assertion, which never serves an eHDSI path. The eHDSI Messaging Profile 10.0.0 §1.5 admits no alternative binding, and the identity assertion lives at most 4 hours and issender-vouches(SAML Profile 10.0.0 §2.1). A marked gateway beside either key is refused. - Mutual TLS under the eHDSI PKI. The URL is
httpsin every profile, development included, and its[xcpd.gateway.endpoint.tls]must nameclient_identity_fileandtrust_roots_file(Section II Security Services 10.0.0 §5.9.1 and §5.9.2). The link trusts those roots alone, never the platform’s. - TLS 1.2 for a peer that speaks no TLS 1.3. eHDSI Section II Security
Services 10.0.0 §5.9.1 makes TLS 1.2 the minimum between NCPeH, with TLS
1.3 recommended and TLS 1.0 and 1.1 prohibited, and eHDSI Cryptographic
Algorithms 9.0.0 says “at least TLS 1.2 SHALL be used. TLS 1.3 is
RECOMMENDED.” The gateway speaks TLS 1.3 alone by default, so set
min_version = "1.2"in its[xcpd.gateway.endpoint.tls]when a responding NCPeH speaks only TLS 1.2. The revocation check speaks the same versions. - Revocation checked before any data is sent. Before each discovery,
the gateway opens a TLS connection to the responding gateway and sends
nothing over it. It checks every certificate the peer presents, up to the
root, against the CRL of its CRL distribution point or, failing that, the
OCSP responder of its Authority Information Access extension (X.509
Certificates Profile 7.0.0 §3.1, §3.2). The discovery request then goes
over a connection that admits only the chain the check cleared. A revoked
certificate fails the discovery closed, with nothing sent to that
gateway. So does a certificate whose status no source gives: a source
that cannot be reached, a stale CRL, or an OCSP status of
unknown. Failing closed on an unreachable source is FerroFED’s own design, deny-biased as Section II §2.2 asks of the decision point. A good status is kept for at most an hour, and never past the CRL’s or the OCSP response’s own next update.
A gateway without the mark is reached as before, with no revocation check.
FerroFED sends the synchronous exchange only, with an immediate response: it
claims neither the Asynchronous Web Services Exchange nor the Deferred
Response option (ITI TF-1 §27.2), and it caches no correlation between
queries. [xcpd] takes effect on a reload, except where its audit messages
go, which takes a restart; under node_selection = "ask-all" it refuses the
configuration.
The audit repository
With audit = "repository", the gateway sends each exchange’s audit
message to an ATNA Audit Record Repository as ITI-20 Record Audit Event
(ITI TF-2 §3.20): the DICOM PS3.15 audit message in an RFC 5424 syslog
message with the PRI <85> and the MSGID IHE+RFC-3881, over TLS (RFC
5425). That message must name the gateway’s own homeCommunityID (ITI TF-2
§3.55.5.1.1), so [xcpd] home_community is required with
audit = "repository": config check, serve and a reload refuse the
configuration without it, naming xcpd.home_community. Under
audit = "log" it stays optional, and the log event carries it when it is
set.
[xcpd]
audit = "repository"
home_community = "2.999.40" # required with audit = "repository"
[xcpd.audit_repository]
url = "tls://arr.example.org:6514" # the port defaults to 6514
hostname = "gateway.example.org" # syslog HOSTNAME and the message's host
spool_dir = "/var/lib/ferrofed/audit-spool"
# app_name = "ferrofed" # syslog APP-NAME
# source_id = "gateway.example.org" # AuditSourceID, the hostname by default
# enterprise_site = "2.999.40" # AuditEnterpriseSiteID
# spool_max_size = "64MiB"
# spool_max_events = 100000
# spool_write_timeout = "2s" # storing one message in the spool
# connect_timeout = "5s" # opening the TCP connection
# send_timeout = "5s" # the TLS handshake, each write and flush
# retry_max = "1min" # the longest wait between two attempts
[xcpd.audit_repository.tls]
client_identity_file = "/run/secrets/atna-client.pem" # when the repository asks
trust_roots_file = "/etc/ferrofed/atna-roots.pem" # optional
# min_version = "1.2" # "1.3" by default
Every message is written to the spool first, flushed to disk, and
delivered from there in order, so a message counts as recorded once it is
on disk. ITI-20 has a sender that cannot reach its repository store the
record and send it when it can (ITI TF-2 §3.20.4.1.1): while the repository
is down, discovery goes on and the messages wait in the spool, and a restart
keeps them. Only a message the gateway can neither deliver nor store is an
audit failure: a full spool (spool_max_events or spool_max_size), one
that cannot be written, or one that does not store the message in time
fails the discovery closed, as any audit failure does above. Recording an
exchange only ever writes to the spool, so a slow or hung repository never
holds a query, and a slow disk holds it for a bounded time
(A slow disk).
Delivery is bounded at every step: the TCP connection by
connect_timeout, and the TLS handshake, each write and each flush by
send_timeout, so a repository that accepts a connection and then stops
answering cannot hold the sender. After any timeout or transport failure the
gateway drops the connection, keeps the message in the spool, and tries
again after a wait that doubles from 250 ms up to retry_max, with
jitter. A spooled message that cannot be read, or is no whole syslog frame,
is moved to the quarantine subdirectory, logged at error level with its
sequence number and never its content, and the drain goes on with the next
one; a quarantined message stays counted under spool_max_events and
spool_max_size until you remove it. Syslog over TLS has no
acknowledgement (RFC 5425), so only a transport failure is retried, and a
message written to a connection the repository has just closed can be lost;
the gateway checks the connection before each write to keep that window
short. A file in the spool directory the gateway did not write refuses the
start, naming the file to move out.
The spool is the one place FerroFED writes a patient identifier to disk:
each message carries the query parameters, base64-encoded, as the audit
table requires. The gateway creates the directory readable by its own user
alone (0700) and every file 0600, and refuses to start, and
config check refuses the configuration, when the directory gives its group
or other users any access or cannot be written. Put it on an encrypted
volume: the gateway holds no key to encrypt it with, so encryption at rest
is the deployment’s.
The url is tls:// outside profile = "development"; under that profile
tcp://host:port is accepted and named on the banner, and without
spool_dir the spool is held in memory, which a restart loses and the
banner says so. GET {base}/operator/dependencies reports the repository as
audit_repository: up, degraded while the gateway retries a failed
delivery, while messages wait in the spool and while any sits in
quarantine, and unknown before the first message. The metrics carry the
spool’s depth, its quarantine, the deliveries and the retries
(Metrics). The PIXm, mCSD and PMIR transactions are audited
over the other option of ITI-20, the FHIR Feed of RESTful ATNA, under
[audit] (The audit trail).
Dutch localization: [nl_gf.nvi]
A deployment in the Netherlands can localize through the national index of
the Dutch Generic Functions, the NVI, in place of XCPD (Annex B §B.1,
GF-Localization of the Generic Functions IG fhir.nl.gf 0.3.0). Under
federation.node_selection = "localized", each undirected patient query
first asks the NVI’s Localization Service which care providers hold data
for the patient: GET [endpoint.url]/DocumentReference?patient.identifier=<pseudonym> &type=http://loinc.org|55188-7. The service answers with one localization
record per care provider, named by its URA, and the members that hold those
providers’ data are the candidates. Every other member is not-localized
and is not asked.
profile = "production"
[federation]
node_selection = "localized"
[federation.localization]
on_failure = "closed" # the default (§14.1)
timeout = "5s"
[nl_gf.nvi]
namespaces = ["pseudo-bsn"] # client namespaces that stand for the pseudonym
[nl_gf.nvi.endpoint]
url = "https://nvi.example.org/fhir"
credentials = { user = "ferrofed", password_file = "/run/secrets/nvi-password" } # optional, or the Nuts grant
tls = { client_identity_file = "/run/secrets/nvi-client.pem", trust_roots_file = "/etc/ferrofed/nvi-roots.pem" } # optional, mutual TLS
[nl_gf.nvi.custodians] # optional with a directory that publishes URAs
"ura-test-0001" = "node-a"
"ura-test-0002" = "node-b"
"ura-test-0003" = "node-b" # one member may hold several providers' data
[[pixm.manager]] # resolution, Step 1c of Annex B §B.7
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
[pixm.manager.members]
"node-a" = "urn:oid:2.999.21"
"node-b" = "urn:oid:2.999.22"
The NVI is keyed on a pseudonymised BSN, never on the BSN. A client names
the patient by the pseudonym, in the namespace
http://fhir.nl/fhir/NamingSystem/pseudo-bsn or in one namespaces lists,
as in the walkthrough of Annex B §B.7. The gateway never pseudonymises: a
patient in any other namespace, a BSN included, cannot be localized, so the
query fails closed and the NVI is never asked. The pseudonym is personal
data like the BSN it stands for, so it is handled as every patient
identifier is: it reaches the NVI and the PIX Manager, never a node, a log
line or an error. A BSN system is never accepted in namespaces: listing
http://fhir.nl/fhir/NamingSystem/bsn, or the BSN’s OID as
urn:oid:2.16.840.1.113883.2.4.6.3 or dotted, refuses the configuration, so
a BSN can never reach the NVI labelled as a pseudonym.
What a deployment must provide:
- The custodian map. Every registry member must be mapped from at least
one URA, or boot is refused, since no localization could ever name it. A
care provider the NVI returns that the map does not name is outside the
federation and adds no candidate. When the registry comes from a care
services directory (
[registry.mcsd], or a registry document in FHIR form) whose member organisations publish their URA, as the Dutch directory does with the LRZa as its source (Annex B §B.2), the gateway reads the map from it: each organisation’s URA maps to the members it operates. Thecustodianstable is then optional. If you write it anyway it must give exactly the same map, or the configuration is refused; the gateway never merges the two. An organisation that publishes two different URAs is refused as well. The map is rebuilt on every reload and directory refresh (The registry from an mCSD directory). - TLS and credentials.
[nl_gf.nvi.endpoint]is the NVI’s endpoint table. Itsurlmust behttpsoutsideprofile = "development"; a plainhttpURL is refused at boot, naming its key, as is a URL that carries a user name or a password. The gateway authenticates with the basic credentials ofendpoint.credentialsor with the Nuts grant of GF-Authentication in[nl_gf.nvi.nuts](The NVI’s Nuts grant), one of them; anoauthtable is refused, naming its key, as the IG defines no such grant for the Localization Service. The IG asks a requester for authorization attributes (its organization, practitioner and role); the gateway sends only the credentials configured here. - A resolver. The NVI answers where; the PIX Manager of
[pixm]still answers under whichehr_ideach candidate knows the patient.
The NVI checks the requester’s access at each data holder before it
returns a record, so its answer is consent-aware. It is still only a list of
candidates: each node checks consent before it releases data (§14.3, N27).
The localization fails closed as a whole. A service that answers a failure,
answers outside the IG, or stays silent past timeout_ms leaves every
member not-localized with the error, and the query asks no node.
on_failure = "ask-all" asks every member instead. OPTIONS {base}/
declares localization.mode as "nl-gf-nvi".
Set [nl_gf.nvi] or [xcpd], never both: both refuse the configuration.
[nl_gf.nvi] takes effect on a reload; under node_selection = "ask-all"
it refuses the configuration. The consent pre-filter of the Dutch binding is
Mitz.
Consent
A node checks consent itself, and a deployment may add a pre-filter that drops members before dispatch. This page covers both, and the Dutch pre-filter, Mitz.
Each node checks consent before it releases data, whatever the gateway did first (N26, N27). The gateway never decides on release itself, and a node it dispatches to is not thereby cleared: a localization or consent service that named the node only means nothing upstream ruled it out (§14.3).
A node’s own refusal. ITS-REST defines no consent signal, so the gateway
does not infer one from a status code. A node’s answer is reported
consent-denied only when it is a 403 whose ITS-REST Error body carries a
code that the registry lists for that endpoint in consent_refusal_codes
(The registry). Every other refusal is
node-error. The list is empty by default, so until you name the codes a
node uses, its consent refusal fails the query 424 as any node error does.
This key is FerroFED’s own design, because no specification defines the
signal. A refusing node contributes no rows, never fails the query in either
completeness mode, clears meta.federation.complete, and its record carries
the latency_ms of the request it refused (§11.3, N40).
The optional Step-1 pre-filter. A deployment with a consent service may
drop members before dispatch (N27a). The pre-filter runs after localization
and before resolution. Each member it denies is reported consent-denied with
no latency_ms, is never resolved and never sent a request, and any ehr_id
the client session cached for it is dropped. A member it does not deny is
asked, and its node decides. When the consent service cannot answer, Step 1
carries no consent signal, which is the state of a deployment with no consent
service at all, so every candidate is asked and each node checks consent
itself (§13.2.1, N27a). This pass-to-node policy is FerroFED’s own design.
The outage is never silent: the answer to a query carries it as
meta.federation.consent.error, mirroring localization.error of §14.1
(The client contract), the pre-filter’s
state on GET {base}/operator/dependencies turns down or failing
(Health probes), and each call is counted in
ferrofed_consent_prefilter_requests_total (Metrics). A call
the pre-filter could not put to its service at all, for the patient’s
namespace or for missing caller claims, is counted as not-asked with that
reason and leaves the health state as it was, since the service saw
nothing. The client’s answer is the one a pre-filter that found nothing
gives. The pre-filter applies to every patient route: a federated query and the read
of an EHR by subject
(Follow-ups).
OPTIONS {base}/ declares a configured pre-filter under federation.consent,
with its mode, that policy and disclose; a deployment with no pre-filter
declares nothing there. A deployment under Regulation (EU) 2025/327 Art 8
sets [federation.consent] disclose = false, so an answer never shows an
exclusion (Withholding consent exclusions).
An emergency request. A caller asserts an emergency access through the
purpose of use of its verified token, matched against
[[access_log.emergency_purpose]]
(Emergency access). Whether the pre-filter’s
exclusion applies to such a request is your choice, under
[federation.consent] emergency:
[federation.consent]
emergency = "apply" # or "pass-to-node"
"apply", the default: the pre-filter applies to every request, an emergency one included. A member it denies is not asked."pass-to-node": for a request whose token declares an emergency purpose, the pre-filter is still asked, and every member it denies is asked all the same. Its node decides on its own consent, restrictions and the purpose the request conveys (N26, N27). The access record names each such member in itsehds-emergency-accessentity (Emergency access), whatever the node answered; a request whose record cannot be stored is refused503 access-unrecorded. The answer, the status and the log line say nothing of it (Art 8). The setting-aside holds for that one request: the gateway learns no resolution binding and noehr_idroute from it, so the next request is filtered again. A request without an emergency purpose is filtered as before.
The gateway never decides on its own that an emergency justifies setting a
consent decision aside. Regulation (EU) 2025/327 leaves whether the vital
interests of the data subject override a restriction or an opt-out to the
Member State (Art 8, Art 10(2), Art 11(5)), and release is the node’s
decision (N26, N27). The pre-filter is optional (N27a), so whether it
applies to an emergency request is a deployment setting, and the default
keeps it for every purpose. "pass-to-node" needs at least one
[[access_log.emergency_purpose]], or the configuration is refused.
OPTIONS {base}/ declares the setting as federation.consent.emergency.
In the Netherlands the pre-filter is Mitz, below. FerroFED holds no consent
table of its own, so without [nl_gf.mitz] no pre-filter runs and release
stays each node’s decision.
Dutch consent: [nl_gf.mitz]
Mitz keeps the consent Dutch patients record and answers one closed
question about it, the gesloten autorisatievraag (Annex B §B.6): may this
data holder make this patient’s data of these categories available to this
data user, for this purpose? The gateway asks it once per data holder among
the candidates, after localization and before resolution. A member whose
holder Mitz denies for every category asked is consent-denied and never
asked. Every other member is asked, and its node checks consent itself:
Mitz is a filter in front of the gate, not the gate (§14.3, N27).
The wire is the VZVZ Implementatiehandleiding Open en gesloten
autorisatievraag 3.8.2: a SOAP 1.2 request carrying one XACML 3.0
XACMLAuthzDecisionQuery, over mutual TLS, with an X-Request-Id on every
request. VZVZ states no licence for the document, so the repository pins it
by sha256 and does not ship it; scripts/vendor/mitz.sh fetches it for
reading.
profile = "production"
[nl_gf.mitz]
namespaces = ["urn:oid:2.999.1"] # client namespaces that stand for the BSN
purpose = "TREAT" # TREAT or COC
data_categories = ["GGC002"] # the Mitz data categories asked about
[nl_gf.mitz.endpoint]
url = "https://mitz.example.org/geslotenautorisatievraag"
timeout = "1s" # one round of questions, the default, within the query's budget
tls = { client_identity_file = "/run/secrets/mitz-client.pem", trust_roots_file = "/etc/ferrofed/mitz-roots.pem" } # mutual TLS; the roots are optional
credentials = { user = "ferrofed", password_file = "/run/secrets/mitz-password" } # optional
[nl_gf.mitz.holders] # each member's care provider
"node-a" = { type = "V6" } # the URA from [nl_gf.nvi.custodians] or the directory
"node-b" = { type = "V6", ura = "ura-test-0002" }
[[auth.issuer]] # the issuer of your callers' tokens
issuer = "https://issuer.example.org"
kind = "federation"
audience = ["https://gateway.example.org/fed"]
[[auth.federation]] # what that issuer asserts
issuer = "https://issuer.example.org"
contact_point = false
mapping = "iua"
countries = ["NL"]
issuing_authorities = ["CIBG"]
provider_identifier_prefixes = ["urn:oid:2.16.528.1.1007.3.3."]
[auth.federation.fields] # the claims of its tokens that name the requester
"professional.country_code" = [{ constant = "NL" }]
"professional.issuing_authority_name" = [{ constant = "CIBG" }]
"nl.professional_number" = [{ claim = "uzi_number" }] # the professional's UZI number
"nl.role" = [{ claim = "uzi_role" }] # the professional's UZI role code
"nl.ura" = [{ claim = "ura" }] # the organisation's URA
"nl.organisation_type" = [{ claim = "organisation_type" }]
endpoint.timeout bounds one round of questions, and it is a part of
federation.overall_timeout: with pdqm.endpoint.timeout and the localizer’s
budget it must end before the overall budget, or the configuration is
refused, naming the keys (§11.5). OPTIONS {base}/ declares it as
timeout.consent_ms.
What a deployment must provide:
- The BSN. Mitz is asked by BSN. A client names the patient in a BSN
system (
http://fhir.nl/fhir/NamingSystem/bsn, or the BSN’s OID asurn:oid:2.16.840.1.113883.2.4.6.3or dotted) or in onenamespaceslists. A patient named by the pseudonymised BSN, as the NVI requires, cannot be asked about: the pre-filter then carries no consent signal and every candidate is asked, and the call is counted asnot-askedwith the reasonnamespace(Metrics). The pseudonym’s system is never accepted innamespaces. The BSN reaches Mitz and nothing else: never a node, a log line or an error. - A holder per member. Every registry member needs a
type, and one URA from itsura,[nl_gf.nvi.custodians]or a directory that publishes URAs; where more than one gives it they must agree. A member with no holder, a holder with no URA, or a holder naming no member refuses the configuration. - The requester, in the caller’s token. The question names the
professional who asks, by UZI number and role, and their organisation, by
URA and type. That is always the verified caller: Mitz records the
professional and decides on their role, so the gateway never asks for
anyone else. The caller is a professional of another domestic party, read
by
ferrohealth-smartunder thefederationissuer’s[[auth.federation]]table, whosenl.professional_number,nl.role,nl.uraandnl.organisation_typefields name the four (Client authentication); no specification the gateway binds names their claims, so each is configured and none has a default. A caller of apersonsissuer names no requester. A caller whose token does not carry all four is not asked about: Mitz is not called, no member is filtered, and each node checks consent itself (N27). The call is counted asnot-askedwith the reasoncaller-claims. A token that carries them in a form the question does not take, such as a UZI number that is not alphanumeric, is treated the same way, with the reasoncaller-claims-invalid, and so is a patient value the question does not take as a BSN, withpatient-value. Neither is reported as a Mitz outage, since Mitz is never called, and no claim value appears in a label, a log line or an error. - TLS.
[nl_gf.mitz.endpoint]is Mitz’s endpoint table. Itsurlmust behttpsoutsideprofile = "development".credentialstakes basic credentials or the OAuth 2.0 client credentials grant of[nl_gf.mitz.endpoint.credentials.oauth](RFC 6749 §4.4), never anutsgrant. The gateway obtains the token at the token endpoint, which must behttpsoutsideprofile = "development", caches it while it is fresh, and sends it with each question as a bearer token (RFC 6750 §2.1). A question Mitz answers401is asked once more with a fresh token, within the same budget. Whether a gateway may ask Mitz at all is a matter of admission to the Mitz afsprakenstelsel.
The question carries the purpose purpose configures, TREAT or COC,
the only values Mitz 3.8.2 admits (§3.2.4.2), never the caller’s purpose
of use. Mitz has no emergency consultation situation, and the Wabvpz has
no emergency exception to its Art 15a, so config check notes
emergency = "pass-to-node" beside [nl_gf.mitz]: a member Mitz denies is
then asked under a legal basis you hold, and its node applies the rules
that bind it.
Mitz answers Permit or Deny per category. Indeterminate, a fault, a
status other than 200, silence past timeout_ms and an answer that does
not hold to the question are no decision: the members of that holder are
asked, and the failure is carried in meta.federation.consent.error. Mitz
3.8.2 §3.2.4.6 tells the data holder’s own system to treat Indeterminate
under explicit consent as a Deny; the node is that system, and its own
Mitz check decides (N27). When
Mitz denies one holder and fails for another, the denied members are
consent-denied, the others are asked, and the failure is still carried.
OPTIONS {base}/ declares the pre-filter as "nl-gf-mitz".
Withholding consent exclusions
A member the consent pre-filter excludes (Consent) is
reported consent-denied by default, as the specification requires (N27a).
consent-denied tells the requesting clinician, member by member, that the
patient restricted access there. Regulation (EU) 2025/327 (the European
Health Data Space) Art 8 forbids that: “The fact that a natural person has
restricted access … shall not be visible to healthcare providers.” A
deployment in the EU sets:
[federation.consent]
disclose = false # the default is true: the specification's consent-denied (N27a)
A request a national contact point relays is always served as under
disclose = false, whatever this setting says: the healthcare provider of
another Member State it relays is a healthcare provider too (Art 8, Art
11(5); National contact
points). The setting decides
only what the deployment’s own callers see.
With disclose = false, a member the pre-filter excludes is still never sent
a request, and the gateway asks its own cross-reference about it with the
other candidates, so its record matches theirs. The node learns nothing of
that lookup. Then:
- A federated query reports it as a member the cross-reference does not
know the patient at:
not-resolved, with the sameerrortext that member carries in this deployment. The gateway resolves the excluded member with the others, so when the cross-reference cannot answer, it carries the same failure as every other member.not-resolvedclearsmeta.federation.completeand fails nothing, asconsent-denieddoes (§11.1, §11.3), so the answer never presents itself as whole while a member that may hold data was not asked (N16, N37).not-localizedwould leavecompletetrue, which N37 requires of a member that was never in scope, so the gateway does not use it. - A read of an EHR by subject that only an excluded member could serve
answers
404 subject-unavailable, the same answer as for a subject no member knows: in this deployment that code replacesno-destinationfor a subject with no EHR the gateway may reach. A404covers both, since HTTP defines it for a resource the server did not find or “is not willing to disclose” (RFC 9110 §15.5.5). The gateway never answersconsent-deniedthere. - Nothing a client sees names consent: no status,
error, header or error body.OPTIONS {base}/declares the choice asfederation.consent.disclose: false, so a client knows anot-resolvedmember may be one the patient restricted. - The operator still sees every exclusion:
ferrofed_consent_prefilter_requests_total{outcome="denied"}counts each call that excluded a member, and an outage of the consent service stays inmeta.federation.consent.errorand onGET {base}/operator/dependencies, since it says nothing about a patient.
A node’s own consent refusal, a 403 whose ITS-REST Error carries a code
the registry lists in consent_refusal_codes
(Consent), is withheld the same way on every path:
- A federated query reports the node
not-resolved, with the sameerrortext as a member that does not know the patient and nolatency_ms, so its record is identical to that member’s. N40 asks for the latency of every node the gateway sent a request to; a record carrying one would show the refusal, so this deployment leaves it out, a conflict with the specification that is recorded on #212. - A read of an EHR by subject whose holder refuses answers
404 subject-unavailableand names no acting endpoint, as for a subject no member knows. The two can still differ in how long they take: a refusal costs one request to the holder, and a subject no member knows costs none. - A routed request under
{base}/v1/ehr/, the creation of an EHR and a DEMOGRAPHIC request that the node refuses answers404 subject-unavailable, still naming the endpoint the request was routed to. So that this answer never stands for a refusal alone, a node’s own404on those paths gets the same gateway answer in this deployment, instead of passing through as §11.2 has it, and the node’s body is never passed on. Both take one request to the node. The definition area holds no patient’s data, so its answers pass through unchanged. - An ask-all probe reads the refusing member as one that does not hold the
ehr_id; when no member answers with the EHR, the answer is404 subject-unavailable, which in this deployment also replacesno-destinationfor anehr_idno member holds. Every member is asked either way.
Every subject-unavailable answer carries the one message “the requested
resource is not available to this request (§11.2)”.
- The operator still counts the refusal in
ferrofed_node_requests_total{outcome="consent-denied"}, and a refusal on a routed path is logged with the endpoint and the request id.
Record the choice with your §13.4 deployment decisions.
The audit trail
Every IHE transaction the gateway makes or receives is audited as its profile requires, to an ATNA Audit Record Repository over ITI-20 Record Audit Event. Two configurations decide where the records go:
[xcpd] auditand[xcpd.audit_repository]for the XCPD localizer’s ITI-55 exchanges, which XCPD audits as a DICOM audit message over syslog (The audit repository);[audit]for the PIXm, PDQm, mCSD and PMIR transactions, which each profile audits as a FHIRAuditEventbuilt on the IHE Basic Audit Log Patterns (BALP), and for the access records (The access log), which every build writes whatever its bindings. BALP has the gateway send it over the ATX: FHIR Feed Option of ITI-20: a FHIRcreateat the repository’s FHIR base (the RESTful ATNA supplement, ITI TF-2 §3.20.4.2).
What each transaction records
| Transaction | When | Record | Patient named | Section |
|---|---|---|---|---|
| ITI-55 Cross Gateway Patient Discovery | each discovery the XCPD localizer makes | the Initiating Gateway’s DICOM audit message, over syslog | inside the base64 query parameters | [xcpd] |
| ITI-83 Mobile Patient Identifier Cross-reference Query | each query the PIXm resolver or localizer asks a PIX Manager | PIXm Query Consumer audit (BALP Patient Query): the request as sent, base64; for method = "post", its request line, media type and Parameters body | the source identifier, system and value | [audit] |
| ITI-78 Mobile Patient Demographics Query | each search the [pdqm] step sends the PDQm Supplier | PDQm Query Consumer audit (BALP Query): the request as sent, base64 | inside the base64 request; a demographics search identifies no patient on its own | [audit] |
| ITI-119 Patient Demographics Match | each match the [pdqm] step sends under transaction = "iti-119" | PDQm Match Consumer audit (BALP Query): the request as sent, base64 | the identifier on the input Patient, system and value | [audit] |
| ITI-90 Find Matching Care Services | each search of the mCSD directory the registry is read from, one per resource type | mCSD Care Services Query audit (BALP Query): the first request of the search, base64 | none | [audit] |
| ITI-91 Request Care Services Updates | each history of a registry refresh, one per resource type | mCSD Care Services Updates audit: the first request, base64 | none | [audit] |
| ITI-93 Mobile Patient Identity Feed | each message the Patient Identity Registry sends to the feed route | PMIR Feed audit, the Registry as the source and the gateway’s callback as the destination: the MessageHeader | one entity per Patient Master Identity the message names, by its Patient/<id> reference or an identifier | [audit] |
| ITI-94 Subscribe to Patient Updates | each subscription create, read and delete the identity feed makes | PMIR Subscription Create, Read and Delete audits (BALP Create, Read, Delete): the Subscription and, for a create, its criteria | none: a subscription names an identifier system at most | [audit] |
| The search of the Registry’s subscriptions by callback | before each create | BALP Query: the request as sent, base64; ITI-94 defines no search | none | [audit] |
Every record names the gateway as source.observer and as its own agent,
by source_id (the hostname by default), at the network address
hostname gives, and names the other party by its FHIR base URL without
its userinfo or query.
The caller each record names
A transaction the gateway makes for a client’s request is made on behalf of the caller it verified (Client authentication). PIXm asks that its audit record be augmented with the agent details of the caller’s OAuth token, following BALP (PIXm §2:3.83.5.2.1), so each such record names the caller as BALP maps the token (BALP §3:5.7.5.4):
| Record | Where the caller is named |
|---|---|
ITI-83, ITI-78 and ITI-119 (AuditEvent) | the agent:user of the BALP pattern: type IRCP, who.identifier.system the token’s iss, who.identifier.value its sub, requestor true, purposeOfUse every purpose of use the token declares, which purposeOfEvent repeats (Where the purposes of use are found); and an Application agent (DICOM 110150) with the token’s client_id as who.identifier.value |
| ITI-55 (DICOM audit message) | the Human Requestor ActiveParticipant (ITI TF-2 §3.55.5.1.1): UserID the token’s sub, UserName written aud<sub@iss> (IUA ITI TF-2 §3.72.5.1), UserIsRequestor true, and the gateway’s own participant UserIsRequestor false. The DICOM schema has no element for the client or the purpose of use, so the message names neither |
A transaction the gateway makes on its own behalf names no caller: the
admission check’s ITI-83 queries, every ITI-90 search and ITI-91 history
of a registry read or refresh, every ITI-94 subscription exchange and the
search before it, and every ITI-93 message the Registry sends. Their
AuditEvent has no agent:user and no Application agent, as BALP’s
examples of an event no user caused have none, and an ITI-55 message of
its own names the gateway as the requestor.
The caller’s identity goes to the Audit Record Repository alone. Both log
destinations, [audit] destination = "log" and [xcpd] audit = "log",
record on_behalf as caller or gateway, and never the caller’s sub,
client_id or issuer; no metric label carries them. Each
node is told of the caller by the signed conveyance alone
(Onward credentials), never by an audit record.
Where the purposes of use are found
An AuditEvent carries every purpose of use the caller’s token declares
in two places, emergency purposes such as BTG among them:
| Element | What it holds |
|---|---|
agent.purposeOfUse of the agent:user | every purpose of use the token declares, in the token’s order, where BALP 1.1.4 places a purpose of use it knows to be the agent’s |
purposeOfEvent | the same codings, each once, in the order the agents first name them |
An Audit Record Repository that keeps its records in the DICOM audit
message form reads the second. The RESTful ATNA supplement (Rev. 3.6)
maps purposeOfEvent to the EventIdentification PurposeOfUse and
gives agent.purposeOfUse no DICOM counterpart (Tables 3.20.4.2.2.1-1 and
3.81.4.2.2.1-1), and ITI TF-2 §3.20.7.1.2 asks that a purpose of use be
encoded in EventIdentification. BALP binds both elements to the HL7 v3
PurposeOfUse value set, so writing both keeps the record’s pattern. A
record of a transaction the gateway makes on its own behalf names no
caller and carries neither.
The address a request came from
The access log records the address each request came from,
so it says from where a caller reached patient data. The gateway takes that
address from the connection, so behind a reverse proxy it is the proxy’s,
127.0.0.1 for a proxy on the same host. The client’s address reaches the
gateway only in the Forwarded or X-Forwarded-For header the proxy
writes, and any client can write those headers too, so the gateway reads
them only from a proxy listed in server.trusted_proxies (RFC 7239 §8.1).
With the proxy listed, a request is named by the client the proxy names;
with none listed, the default, every request is named by its peer, and a
forwarded header changes nothing
(Behind a reverse proxy).
The access log
Every access to patient data the gateway intermediates is recorded as a
BALP AuditEvent, which goes where the [audit] records go, through the
same spool. What a record names, how it is classified and kept, how it is
read and how [access_log] is configured are on
The access log.
[audit]
[audit]
destination = "repository" # "log" or "off" under development, or without a registry
[audit.repository]
hostname = "gateway.example.org" # the gateway's network address in each record
spool_dir = "/var/lib/ferrofed/audit-feed-spool"
# source_id = "gateway.example.org" # source.observer, the hostname by default
# enterprise_site = "2.999.40" # source.site
# spool_max_size = "64MiB"
# spool_max_events = 100000
# spool_write_timeout = "10s" # storing one record in the spool, within the request's own time
[audit.repository.endpoint]
url = "https://arr.example.org/fhir" # the repository's FHIR base
# timeout = "5s" # each delivery
# retry.max_backoff = "1min" # the longest wait between two delivery rounds
[audit.repository.endpoint.tls] # when the repository asks for a client certificate
client_identity_file = "/run/secrets/atna-client.pem"
trust_roots_file = "/etc/ferrofed/atna-roots.pem" # optional
[audit.repository.endpoint] is ferrohealth-smart’s outbound table, the
one every FerroHEALTH product reaches a service through: url, timeout,
connect_timeout, the retry table (max_attempts, initial_backoff,
max_backoff), tls and credentials (HTTP Basic or a client credentials
grant under credentials.oauth). One delivery is one round of the retry
table’s tries, and a failed round waits retry.max_backoff before the next.
destinationhas no default outsideprofile = "development"once a registry is configured, or[pixm],[pdqm]or[pmir]is set, since every access to patient data is recorded:config check,serveand a reload refuse the configuration without it, namingaudit.destination. Every build records accesses here, with either binding or none.offis refused outside development; under development an unsetdestinationrecords nothing.logwrites each record as a structured event at theferrofed::auditlog target: the profile, the subtypes, the action, the outcome, the other party, and how many patient, query and resource entities the record holds. It never writes a patient identifier, a patient reference, the caller or the request, which may name one. An access record written there names no one, so outside development a gateway with a registry refuseslog:config check,serveand a reload nameaudit.destinationand ask forrepository(Regulation (EU) 2025/327 Annex II 3.2).logstays accepted under development, and outside it for a gateway with no registry, whose PIXm, PDQm or PMIR records it writes.repositoryposts each record to[url]/AuditEventas FHIR JSON. The url ishttpsoutside development; under development plainhttpis accepted and named on the banner, and withoutspool_dirthe spool is held in memory, which a restart loses.- A change to
[audit]takes a restart, as[xcpd.audit_repository]does: one forwarder drains each spool.
The spool and the failure policy
The FHIR Feed records follow the policy of the ITI-55 audit trail. Every
record is written to the spool first, flushed to disk, and delivered from
there in order, so it counts as recorded once it is on disk. While the
repository is down, or answers 5xx, 408 or 429, the transactions go on
and the records wait in the spool. One delivery is one round of the
[audit.repository.endpoint] retry table’s tries, and a failed round
waits retry.max_backoff before the gateway tries again. A record the
repository refuses with any other 4xx would be refused again, so it is
moved to the spool’s quarantine subdirectory, logged with its sequence
number and status and never its content, and the drain goes on; a spooled
file that is no FHIR AuditEvent is quarantined the same way. A quarantined
record stays counted under the spool’s bounds until you remove it.
On SIGTERM, once the bindings have stopped, the gateway stops every audit
trail within gateway.bindings_drain_timeout. Each forwarder finishes the
record it is writing, ends an ITI-20 syslog connection with a TLS
close_notify (RFC 5425 §4.4) and returns, so the frames it wrote last
reach the repository; a socket dropped with the repository’s session
tickets unread would be reset and could lose them. A forwarder also closes a
connection the repository ended before it opens a new one. A record not yet
written stays in the spool for the next start. A trail that does not stop in
time is logged.
Only a record the gateway can neither deliver nor store is an audit
failure: a full spool (spool_max_events or spool_max_size), one that
cannot be written, or one that does not store the record in time
(A slow disk). The transaction then fails closed, and its
answer is never used:
| Transaction | When its record cannot be stored |
|---|---|
| An access to patient data | the answer is withheld: 503 access-unrecorded with none of the data (The access log) |
| ITI-83 | the member’s resolution is unavailable: no member is asked, and the query fails 424 under all-or-nothing completeness |
| ITI-90, ITI-91 | the directory read fails as a directory that did not answer: the boot is refused, or a refresh keeps the registry in place |
| ITI-93 | the message is answered 503 and nothing is applied, so the Registry sends it again |
| ITI-94 | the exchange fails, and GET {base}/operator/dependencies reports the Registry failing with the fault audit-failed |
A slow disk
Storing a record flushes it to the device twice, once for the file and once for its directory, and the transaction waits for that before it uses its answer. A disk that is slow or has stalled could hold the transaction without limit, so the wait is bounded, on both spools:
- by
spool_write_timeout, in[audit.repository]("10s"by default) and in[xcpd.audit_repository]("2s"by default), the longest one record may take to be stored, the wait for the write before it included; - for the record of an access, by what is left of the request’s own time:
server.request_timeoutless the one-second combining margin, so the gateway answers503 access-unrecordedbefore the server’s own timeout does; - and, for an ITI-83 query, an ITI-78 search or ITI-119 match of the
[pdqm]step, or an ITI-55 discovery, by the time the transaction was given: what is left of the patient query’s budget, of the step’stimeout_ms, or of the localization’s time. Whichever bound comes first applies.
On a loaded host a durable write waits for the journal to commit other
writers’ data as well (ext4 in its default data=ordered mode), so one
record can take seconds. The access log’s ten-second bound and the
request’s own time leave room for that, and the record still fails closed
past them (#1812). A record that spool_write_timeout cuts off is an audit failure, as a
full spool is, with the outcomes in the table above, and an ITI-55
discovery fails closed under every on_failure policy
(The audit repository). A record still
being stored when its transaction’s time runs out fails a transaction that
succeeded the same way: an ITI-55 discovery or a [pdqm] exchange then
fails closed under every on_failure policy, never widened to ask-all. The
gateway gives the localizer and the [pdqm] step a deadline a tenth of
their time short of its own, so a record cut off at that deadline reaches it
as the audit failure it is, before the gateway stops waiting. A transaction
that failed already, such as a PIX Manager or a responding gateway that did
not answer, reports its own failure instead, which the late record would
only hide. Either way the query never waits on the disk past its budget.
The write that missed its bound is not abandoned. It runs on until the disk answers. Once it stores the record, the record is delivered like any other, and the gateway logs a warning with the record’s sequence number; a write that fails in the end is logged as an error. Neither log line carries the record. The spool’s depth counts the record only once the write has stored it. A transaction that failed this way may therefore still appear in the repository, as the transaction it was: the gateway made or received it, and ITI-20 has every stored record sent (ITI TF-2 §3.20.4.1.1).
One write runs at a time. While a write is stalled, every record behind it
waits for its turn and its transaction is answered at its own bound, so a
stalled disk holds one thread of the gateway, never one per transaction.
A record still waiting when its transaction stopped waiting stays queued:
it is written once the writes ahead of it end, and delivered like any
other, in the order the records were queued. A queued record holds its
place under spool_max_events and spool_max_size as 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, without
waiting: its transaction fails as with any full spool, the refusal is
logged with its count and never the record, and
ferrofed_audit_refused_total counts it (Metrics). The mCSD directory
reads and the PMIR subscription exchanges run outside any patient query,
so spool_write_timeout alone bounds their records.
The spool holds records that name patients. The gateway creates the
directory readable by its own user alone (0700) and every file 0600,
and refuses to start when the directory gives its group or other users any
access. Put it on an encrypted volume. A spool belongs to one repository
configuration: the FHIR Feed spool and the ITI-55 syslog spool are two
directories.
GET {base}/operator/dependencies reports the FHIR Feed repository as
audit_feed: up, degraded while the gateway retries a failed delivery,
while records wait in the spool and while any sits in quarantine, and
unknown before the first record. The audit metrics of
Metrics count every spool, the FHIR Feed’s included.
The access log
Every access to patient data the gateway intermediates is recorded, with
the caller who made it. This is the GDPR record of who read and wrote
which patient’s data, and the logging component Regulation (EU) 2025/327
asks of an EHR system: a record “on every access event or group of events”
(Annex II 3.2). The record goes where the [audit] records go, as a BALP
AuditEvent, through the same spool.
| Access | When it is recorded | BALP pattern | Action |
|---|---|---|---|
A federated AQL query, POST or GET {base}/v1/query/aql | once at least one member was sent it; a query no member was sent reached no data | IHE.BasicAudit.PatientQuery when the query names a patient, IHE.BasicAudit.Query otherwise | E |
A stored-query execution, POST {base}/v1/query/{name} | as for an ad hoc query, the stored query named in the record | as for an ad hoc query | E |
| A routed read: an EHR resource, the read of an EHR by subject, a DEMOGRAPHIC read | once a node acted for it (openEHR-federation-endpoint on the answer) | IHE.BasicAudit.PatientRead with the patient, IHE.BasicAudit.Read otherwise | R |
| A routed write: a create, an update or a delete under an EHR, the creation of an EHR, a DEMOGRAPHIC write | once a node acted for it | Create, Update or Delete, the Patient variant with the patient; a failed delete claims no pattern, since the Delete pattern fixes outcome to 0 | C, U, D |
A definition request (a template, a stored-query definition) reaches no patient data, so it is not recorded. The operator console reaches the gateway through the same ITS-REST surface, with the operator’s own access token, so a query run from the console is recorded with the operator as the caller.
What a record names
| Annex II 3.2 | Where the AuditEvent carries it |
|---|---|
| (a) the provider or other individuals who accessed the data | an agent whose who is an Organization by its identifier: the requester’s organisation where the issuer states one (Consent), the IHE IUA subject_organization_id otherwise; and the Application agent (DICOM 110150) with the token’s client_id |
| (b) the specific natural person who accessed the data | the agent:user (IRCP): the token’s iss and sub, the professional the token names, the assurance level of the authentication, who acts, altId the professional identification the requester claims state, and every purpose of use: see The person behind an access |
| (c) the categories of the data accessed | the entity named ehds-categories: see Categories; each category’s security label is on it (Labelling the access records) |
| (d) the time and date | recorded |
| (e) the origin or origins of the data | one entity named origin per endpoint the access was sent to: the endpoint id, its node, its system_id, how it answered (the Federation Tier §11.1 status: active, node-error, time-out, and so on, for a query, a routed request and a received document alike), the rows it sent, and for a write the version it left: written-version, the OBJECT_VERSION_ID the member’s ETag or Location named, or written-version-unread, unnamed or malformed, when its answer named none or no version id; and for an EHR create the EHR it left: created-ehr, the ehr_id the member’s ETag or Location named, held to the HIER_OBJECT_ID form, or created-ehr-unread, unnamed or malformed, when its answer named none, no ehr_id or two that differ |
Beside them, a record names the data subject: the patient by the
identifier and namespace the request named (entity:patient, as the ITI-83
record names it) or, for a request that named none, the patient the
identity binding holds under the ehr_id it reached
(The patient behind an ehr_id), and one
entity named ehr per ehr_id the access reached, with its endpoint and
how its patient was looked up. It carries the request as entity:query, the
gateway’s request id as entity:transaction (the id the request log names
too), the address the request came from, and the outcome.
The person behind an access
The agent:user carries what client authentication verified about the
natural person (Annex II 3.1, 3.2(b); Professionals and
assurance),
and no other part of the record carries it:
| From the verified token | In the agent:user |
|---|---|
the IHE IUA subject_name | who.display, as BALP 1.1.4 §3:5.7.5.4 maps it |
the IHE IUA national_provider_identifier | an ihe-otherId extension, its valueIdentifier typed NPI (HL7 v2 table 0203), as BALP 1.1.4 §3:5.7.5.4 maps it |
the assurance level, low, substantial or high (Regulation (EU) No 910/2014 Art 8(2)) | an ihe-assuranceLevel extension, its valueCodeableConcept coded with the level’s name |
who acts: person when the token’s sub names a natural person, client when a client application acts for the professional the token names | a role coded person or client |
The assurance level is recorded only when the issuer’s
[auth.issuer.assurance] table declares the claim and the token states a
value it maps; the gateway never infers one, so a record of an issuer that
declares no mapping carries no level. A client acting with no person behind
it reaches no patient data, so every record names a person as the one who
acts. BALP fixes no
vocabulary for the assurance level and names no element for who acts, so
both codes, written with no system, are FerroFED’s own design.
A request a national contact point relays
A national contact point’s connector acts for the professional of another
Member State its token names (National contact
points). Its record names the
foreign provider as the provider agent, the foreign professional in the
agent:user by their identifier, and the connector as the
Application agent, the client that relayed the request. Beside them, one
entity named ehds-relayed carries what the contact point asserted of the
Implementing Regulation (EU) 2026/2099 Annex Tables 1 and 2 and of the
eHDSI identity assertion, each a detail:
detail | Attribute |
|---|---|
relayed-via, relayed-issuer | contact-point, and the contact point that asserted them, by its issuer |
asserted | true: the values are the contact point’s assertion, never verified by the gateway |
country-code | Table 1 country_code |
hp-name, hp-family-name, hp-given-name | Table 1 hp_name, the assertion’s subject-id as one full-name string, and the family and given names when the token states them apart |
hp-identifier, hp-issuing-authority | Table 1 hp_identifier, the assertion’s NameID, and its issuing_authority_name |
hp-professional-role, hp-on-behalf-of | Table 1 hp_professional_role, once per role, system|code, and the role the professional acts for |
hp-name-id, hp-authentication-method | the assertion’s NameID with its issuer, and its authentication class |
provider-identifier, provider-issuing-authority, provider-name, provider-address | Table 2 healthcare_provider_identifier, its issuing_authority_name, and the provider’s name and address when the token states them |
provider-facility-type, provider-home-community-id, point-of-care | the facility type, the home community and the point of care the assertion states |
identity-assertion-id, trc-assertion-id | the ids of the identity assertion and of the treatment relationship confirmation |
asserted-field | every further field the token asserts, such as the speciality or the assertion windows, as <name>=<value>; the patient fields are never listed, since the patient is the record’s subject |
The ehds-accessor entity names the provider acted for, acts-for, as
<issuing authority>|<identifier> (Annex II 3.2(a)). A node is still told
the professional by their identifier alone (GDPR Art 5(1)(c)).
A correlation identifier the connector sent in the header its issuer
declares is a correlation-id detail of entity:transaction, so the
record can be joined with the contact point’s own exchange log. No log line
carries any of them. The entity and detail names are FerroFED’s own design;
no text the gateway reads asks the national side for an audit format.
Emergency access
Regulation (EU) 2025/327 lets a healthcare provider or health professional be granted access to data a person restricted, “where necessary in order to protect the vital interests of the data subject”, and asks that “such cases shall be logged in a clear and understandable format and shall be easily accessible for the data subject” (Art 11(5); Art 8 grants the restriction).
The caller asserts an emergency access through the purpose of use its
token declares (Purpose of use), and
you name the codes that assert one in [[access_log.emergency_purpose]].
A record whose token declares one of them carries one more entity:
| Element | Value |
|---|---|
type | 4 (other), as the other entities FerroFED adds |
name | ehds-emergency-access |
description | a sentence that states the mark in words, citing Art 11(5) |
detail ehds-emergency-access | true |
detail ehds-emergency-purpose | each declared purpose that marked it, system|code |
detail ehds-consent-set-aside | under [federation.consent] emergency = "pass-to-node", each member the consent pre-filter denied that was asked all the same, by its node id |
The purposes stay where every purpose of use is written, in the
agent:user purposeOfUse and in purposeOfEvent (Where the purposes
of use are found), so the record
keeps its BALP pattern. The entity lets a person’s access service show the mark without knowing which
codes your deployment maps.
What the mark does and does not say:
- It records what the caller asserted. The gateway reads it from the verified token’s purposes alone, matching the code and its system exactly, and never infers it from the query, the data or a node’s answer.
- It changes no request to a node. The purpose reaches every node in the
openEHR-federation-clienttoken as any purpose does (What a node is told about the caller), and the node decides whether to release restricted data (Federation Tier §13, N26). The gateway sends the same request it would send without the mark, asks no node again, and answers a node’s refusal as it would without the mark. A refused or failed emergency access is marked too. - It does not say that restricted data were reached. Consent and restrictions stay with the node, and Art 8 keeps the fact of a restriction from healthcare providers, so the gateway cannot tell. The node that released restricted data records that in its own log.
- The optional consent pre-filter is asked as configured, never with the
caller’s purpose: Mitz is asked with the
TREATorCOCthat[nl_gf.mitz] purposeconfigures (Consent). By default a member it drops asconsent-deniedis not asked, under an emergency purpose too. Under[federation.consent] emergency = "pass-to-node"every member it denies is asked all the same and its node decides, and the entity carries oneehds-consent-set-asidedetailper such member, naming its node (An emergency request). The answer never shows it.
One request is held to more than its mark: an emergency access a national
contact point relays counts against the professional’s limit, five per 24
hours unless [emergency] tightens it, and past the limit it is denied
before any node is asked. Its record carries the mark, and its outcome
description names the warning
EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS (eHDSI Section II Security
Services 10.0.0 §2.5; The emergency access
limit).
For each marked access the gateway writes one warn line, “the access was
declared an emergency access by its purpose of use”, under its request id
and with no other value, so your security monitoring can watch for it.
The person learns of the access through the access service of your Member
State, which reads the record with ITI-81 (Reading the
log). Art 9(1) gives the person information “including
through automatic notifications” on any access, “including access provided
in accordance with Article 11(5)”; that notification is the access
service’s, which can search the repository for records naming the person
and the ehds-emergency-access entity. The gateway sends no notification
of its own.
FerroFED names no emergency purpose by default, so no access is marked
until you declare the codes your issuers use, and config check notes a
configuration that declares none. The HL7 v3 ActReason code system
(https://terminology.hl7.org/CodeSystem-v3-ActReason.html) defines two
candidates:
| Code | Display | Definition, in part |
|---|---|---|
BTG | break the glass | policy override operations for “immediately needed health care for an emergent condition”, which “may include override of subject of care consent directive restricting access” |
ETREAT | Emergency Treatment | operations “for provision of immediately needed health care for an emergent condition” |
BTG names the override Art 11(5) describes. ETREAT is broader, and you
map it only when your national rules treat every emergency treatment as an
access in the vital interests. The IHE IUA example token declares BTG
beside TREAT (ITI TF-2 3.71.4.2.2.1.1).
Information a person inserted
Regulation (EU) 2025/327 Art 5 lets a person insert information in their own EHR, which “shall be clearly distinguishable as having been inserted by the natural person”. A document the FHIR face receives from a person under their own grant, a token acting for that person and confined to their patient (Patient grants), is written with each entry naming the record’s subject as its provider (Inserted by the person). The record of that receipt carries one more entity:
| Element | Value |
|---|---|
type | 4 (other) |
name | ehds-subject-inserted |
description | a sentence that states the mark in words, citing Art 5 |
detail ehds-subject-inserted | true |
The entity names no identifier of the person; the record names the person as the caller and as the data subject, as any record does. A receipt the member refused, and a professional’s receipt, carry no such entity.
A document a representative inserts for the person under the person’s
grant, whose token’s issuer declares the act claim a representative
(Patient grants), carries the entity
ehds-representative-inserted in place of ehds-subject-inserted, with
the same type, a description citing Art 4(2) and Art 5, and the
detail ehds-representative-inserted set to true. It names no
identifier of the representative.
Categories
A record names the categories of the data accessed: the six priority
categories of Art 14(1) and the national categories you declare. Each is a
coded value, a system and a code in it, as a FHIR Coding is. The six
priority categories carry the codes of HL7 Europe’s
EEHRxFDocumentPriorityCategoryCS, in the system
http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs
at version 1.0.0-ballot (the EU Health Data API, hl7.fhir.eu.health-data-api
1.0.0-ballot), whose displays are the Art 14(1) terms. That code system is
the only one any EU artefact publishes for the categories, and the HL7
Europe Imaging Report guide requires its code on every imaging report. No
adopted act fixes how a log writes a category, so FerroFED writes these
codes until the common specifications of Art 36(1) say otherwise.
| Art 14(1) | Category | Code |
|---|---|---|
| (a) | patient summaries | Patient-Summaries |
| (b) | electronic prescriptions | Electronic-Prescriptions |
| (c) | electronic dispensations | Electronic-Dispensations |
| (d) | medical imaging studies and related imaging reports | Medical-Imaging |
| (e) | medical test results, including laboratory and other diagnostic results and related reports | Laboratory-Reports |
| (f) | discharge reports | Discharge-Reports |
The codes are case-sensitive, as the code system declares. In the map and
the retention table you name a priority category by its bare code, or by
<system>|<code>. A national category (Art 14(1) third subparagraph) is
the code your Member State’s code system defines, in that system: you
declare it as <system>|<code> in national_categories, where the system
is the absolute URI of the national code system (or a URI you control,
until your Member State publishes one), and you name it the same way
everywhere else. Its code is held to the FHIR code rules only: no
leading, trailing or repeated whitespace. A record writes every category
as <system>|<code>, the FHIR search token form.
Neither AQL nor ITS-REST carries a category, so the gateway
reads the openEHR model ids of what an access reached and looks them up in
the category map you declare in [access_log]. FerroFED ships no map. No
specification governs the map: it is FerroFED’s own design.
- The ids are read from the archetype roots the access delivered, read or
wrote: the
archetype_detailsof eachCOMPOSITION, or of eachORIGINAL_VERSION’s data, a delivered row holds, theCOMPOSITIONa routed read returns, and theCOMPOSITIONa write sends in canonical JSON. The body is read, never changed (N22). - A template key wins over an archetype key. An archetype’s
nonenever stands for a template the map does not hold. - Where the access delivered a leaf value, an aggregate or no row at all,
the ids the bound query constrains its data to classify it too: its
archetype predicates in
FROMand in the paths it selects, and=onarchetype_node_id,archetype_details/archetype_id/valueandarchetype_details/template_id/valuein the top-levelANDchain ofWHERE, read from the syntax tree, never from a comment, a string compared with another path,NOT,!=orNOT CONTAINS. A selected path that names its archetype by a pattern or a parameter leaves the recordunbound. - An access spanning categories records all of them, and
nonenever removes one. AnEHR, anEHR_STATUS, aDIRECTORY, tags and revision history hold no category. - Whatever the map cannot classify exactly is recorded
ehds-unclassified, with the reason and the ids as evidence. The reasons are a closed set:unmapped(an id the map holds no key for),unbound(a query reading a class bound to no id),named-nothing(no root object and no id),operation-not-read(an operation whose data the gateway does not read),format-not-read(a body in a format it does not read, such as a simplified format),body-not-read(a body that holds no root object it can name) andno-object-returned. No category and unclassified are states of the record, never categories: neither is ever written as anehds-category. A key matches only exactly: an id that differs by case, a space or a specialisation is unmapped. An unclassified access is answered as any other; it is never refused for it.
The ehds-categories entity’s detail entries are ehds-category
(<system>|<code>), ehds-category-basis (<system>|<code>:returned,
written, queried or construction, the basis after the last :),
ehds-category-version (<system>|<version>, once per code system whose
version is known), ehds-no-category, ehds-unclassified, template-id,
archetype-id,
version-uid, unmapped-id, category-map-digest (the SHA-256 of the
map’s canonical text, so a reader knows which map classified the record),
delivered and stored-query. Each origin that contributed what the
access delivered names its categories in its origin entity. When it alone
contributed, they are the access’s own. When several did, each origin’s
are classified from the rows it answered with, before the merge, so its
set can name a category of a row the merge then cut by LIMIT or
DISTINCT, and never misses one it delivered. An endpoint the query never
left the gateway for, such as one whose request waited out its deadline for
a slot of federation.max_in_flight_per_node, is no origin, and a query
that left the gateway for no endpoint writes no record.
How long a record is kept
Regulation (EU) 2025/327 keeps the information on each access “available for at least three years from each date of access” (Art 9(2)), and asks for “different retention periods … that take into account the origins and categories” of the data (Annex II 3.4). The gateway holds no record, and your Audit Record Repository “retains data according to local policies” (IHE RESTful ATNA §3.81.4.1.3), so each record states the period it must be kept for, and your repository’s retention policy applies it.
You declare the periods in [access_log.retention], in whole years: one
for every record, and one per category and per origin (an endpoint of the
registry). A record is kept for the longest period among the default, its
categories and its origins. Its origins are the endpoints its origin
entities name, every endpoint the access was sent to, one that answered
with nothing included. A record the category map could not classify
(ehds-unclassified) is kept for the longest period you declare anywhere,
since nothing says which of its parts it holds. Without the table every
record is kept three years. Taking the longest is FerroFED’s own design: no
specification says how origin and category combine.
The ehds-categories entity carries three detail entries:
detail | Value |
|---|---|
ehds-retention-years | the period, in years |
ehds-retention-ends | the first date, in UTC, on which the record may be deleted: the date of access plus the period, plus one day, so an access on 29 February keeps its full period |
ehds-retention-ground | what called for the period: default, unclassified, category:<system>|<code> or origin:<endpoint> |
Configure your repository to keep each record at least until its
ehds-retention-ends. A repository that cannot read a record’s own
detail keeps every record for the longest period you declare, which meets
every record’s period.
Failing closed
An access whose record cannot be stored is refused: the gateway stores the
record before the answer leaves, and when it cannot, it answers 503 access-unrecorded and none of the data
(Errors). A write cannot be taken back, so the
gateway reserves its record’s place in the spool before it asks any
member: a write whose record has no place is refused with the same 503,
and no member saw it, so you can send it again. Once a member was asked,
its answer stands. Should the reserved record still fail to be stored (the
spool’s disk failed or stalled), the gateway raises the security event
write-unrecorded and the alert FerroFEDWriteUnrecorded with the
request id, and you record the access from the member’s own log. An
answer of a patient-data operation that carries data and no record is
refused the same way, so no path of the gateway can hand out data without
its record.
What stays out of the log
No record content reaches a node, the operator log, a span or a metric
label (§5.4, N33). The log destination writes the record’s pattern,
action, outcome and the count of its entities, never the patient, the
caller, a template or archetype id or the client, so it is accepted for
the access log under the development profile alone: a production gateway
with a registry sends its access records to your Audit Record Repository
with destination = "repository". A failure to store a
record is logged under the gateway’s request id with the error chain, which
names no value.
Reading the log
The records are read at your Audit Record Repository, with ITI-81 Retrieve
ATNA Audit Event, the FHIR search an Audit Consumer runs against the
repository that received them (IHE RESTful ATNA §3.81). The gateway serves
no route of its own for the log and reads no record back. Annex II 3.3
asks for tools to review and analyse the log data “or” the connection of
external software for the same purpose: the repository and any ITI-81
Audit Consumer are that software. Choose a repository that supports the
Retrieve Audit Message Option (ITI TF-1 §9.2.3) at the FHIR base
[audit.repository.endpoint] url names.
Every search names a period with date (§3.81.4.1.2.1), matched against
each record’s recorded:
| Who reads | The search |
|---|---|
| A person, through your Member State’s electronic health data access service (Art 9(2)) | GET [base]/AuditEvent?date=ge2027-01-01&date=le2027-12-31&patient.identifier=<namespace>|<identifier>: the patient a request named, or the identity binding named behind the ehr_id it reached, is the record’s entity:patient, whose what.identifier carries the namespace and the identifier |
| An operator, for one caller | agent.identifier=<iss>|<sub>, the caller’s issuer and subject |
| An operator, for one EHR at one member | entity.identifier=|<ehr_id>, the record’s ehr entity |
| An operator, for one member | entity.identifier=|<endpoint>, the record’s origin entity |
| A person’s population accesses, through the access service | entity.identifier=|<endpoint> for each member that holds an EHR for the person, keeping the records with an ehds-population entity: see A query over many patients’ data |
| An operator, for refused or failed accesses | outcome=http://hl7.org/fhir/audit-event-outcome|4,8,12 |
The repository answers with a FHIR Bundle of type searchset holding
the matching AuditEvents (§3.81.4.2.2.2), which is also the documented
format to export the log in (Annex II 2.6): each record is written as
What a record names lists. The repository returns
only the records “which the requester is authorized to view”
(§3.81.4.1.3), so the access rights of each reader are set there, and it
records every search as an Audit Log Used event of its own
(§3.81.5.1). FerroFED’s tests run each search in the table above, and
the refusal of one with no date, against the records the gateway writes
to its harness repository, which answers ITI-81 and records each search
as that event. Neither ITI-81 nor the gateway gives a person’s own
application a route to the log: the person reads it through the access
service their Member State provides (Art 9(2)).
The patient behind an ehr_id
A routed request addressed by ehr_id, such as a composition read or a
write under an EHR, an EHR create, whose record names the ehr_id the
member’s answer gave the new EHR, and a query scoped to one ehr_id, name
no patient.
Art 9(1) gives the person information on “any access”, so the gateway
asks your identity binding which patient the member holds under that
ehr_id, in each namespace [access_log] patient_namespaces names, and
writes each identifier it finds as an entity:patient, as it writes a
patient the request named. The search by patient.identifier then finds
the access. Under the IHE binding the question is one ITI-83 to the
member’s PIX Manager, the ehr_id in the member’s domain as the source
identifier and each namespace’s assigning authority as a target system
(PIXm 3.1.0 §2:3.83.4.1.2), recorded as every ITI-83 is. It is asked on
behalf of the caller, within federation.per_node_timeout, of the
identity service alone: nothing is sent to a node, and no identifier
reaches a log line (§5.4, N33).
Each ehr entity says how its patient was looked up, in a detail named
patient-lookup:
| Value | Meaning |
|---|---|
request-named | the request named the patient, who is the record’s entity:patient |
found | the identity service named the patient, written as an entity:patient |
not-found | the identity service holds no identifier for the patient in a namespace asked |
unavailable | the identity service failed or did not answer in time; the gateway logs the failure, with no identifier |
not-configured | patient_namespaces is empty |
unsupported | the identity binding cannot name a patient by an ehr_id |
The access is recorded and answered whatever the lookup says. Where the
patient is not named, the ehr entity still carries the ehr_id: search
the record with entity.identifier=|<ehr_id> for each ehr_id the person
holds at each member, which your identity service lists (for PIXm, an
ITI-83 with the person’s identifier as the source and each member’s
ehr_id domain as a target). A record that names more than one patient,
such as one identifier in each of two namespaces, writes each and claims
the plain BALP pattern, whose slices bound no patient.
A query over many patients’ data
A query that names no patient and is scoped to no ehr_id is a
population access. The gateway sends each member the query as the client
wrote it, so its record names whose records it read only where the
delivered rows say so (Art 9(1); GDPR Art 5(1)(c), data minimisation).
When the query’s SELECT projects the ehr_id, as
SELECT e/ehr_id/value, c FROM EHR e CONTAINS COMPOSITION c does (or
e/ehr_id), each delivered row names the EHR it came from. The record
then writes an ehr entity for each ehr_id the delivered rows hold, at
each member whose own answer held it, and names the patient behind each
as for a request addressed by ehr_id (see above). A search by the
person’s identifier finds it. All the identity service’s answers for one
record share one federation.per_node_timeout. A query recombined
at the gateway, such as COUNT, delivers no row of any one EHR.
Otherwise the record names no ehr_id and no patient, and carries one
ehds-population entity:
| Element | Value |
|---|---|
type | 4 (other) |
name | ehds-population |
description | a sentence saying that a query over many patients’ data ran over each origin the record names, and that the record confirms no read of any one person’s record |
detail ehds-population | true |
detail ehds-population-reason | why no ehr_id is named: not-projected (the query projects no ehr_id), over-limit (the rows name more than [access_log] max_named_ehrs), not-delivered (no row was delivered) or unattributed (a delivered ehr_id was in no member’s own answer) |
detail ehds-population-ehrs | how many ehr_ids the rows named, when they were read: always for over-limit |
detail ehds-population-limit | max_named_ehrs |
detail template-id, archetype-id | each template and archetype id the query’s FROM and CONTAINS constrain it to |
The members the query asked are the record’s origin entities, each with
its node and system_id. To show a person the population accesses that
may concern them, the access service asks your identity service which
members hold an EHR for the person, searches the log with
entity.identifier=|<endpoint> for each such member’s endpoint, and keeps
the records that carry the ehds-population entity. Present each as “a
population query over your member ran”, never as a confirmed read of the
person’s record: the record does not say that the query read it.
A population access is not findable by the person when:
- the access service cannot learn which members hold an EHR for the
person, because the identity service holds no
ehr_idof the person at the member or did not answer; - the person’s EHR at the member was created after the query ran, or the member left the registry, and the access service matches by the members of today;
- the query reached no member at all: no data was read, and no record is written.
The gateway never adds the ehr_id to a dispatched query to learn whose
rows it returned: that changes what each member is asked (§7.1) and which
rows DISTINCT keeps and what an aggregate counts (N13, §11.6.3).
Each record carries a security label per category and per origin, on its
ehds-categories and origin entities, so your
repository can limit who reads it by category and origin: see Labelling
the access records and [access_log.labels] there.
[access_log]
[access_log]
# national categories your national law adds (Art 14(1) third subparagraph),
# each <system>|<code>
national_categories = ["https://example.org/fhir/CodeSystem/national-category|nl-example"]
# the namespaces the patient behind an ehr_id is named in (Art 9(1))
patient_namespaces = ["urn:oid:2.999.1"]
# the most ehr_ids a population query's record names; 0 names none
max_named_ehrs = 50
[access_log.templates]
"Example Lab Report.v1" = ["Laboratory-Reports"]
"Example Discharge.v1" = ["Discharge-Reports"]
"Example Admin Note.v1" = "none"
[access_log.archetypes]
"openEHR-EHR-OBSERVATION.laboratory_test_result.v1" = ["Laboratory-Reports"]
[access_log.retention]
years = 5 # every record, at least 3 (Art 9(2)); 3 when unset
[access_log.retention.categories]
"Discharge-Reports" = 20 # an Art 14(1) code, or a national <system>|<code> declared above
[access_log.retention.origins]
"node-a" = 15 # an endpoint id of the registry
[access_log.labels.categories] # see Labelling the access records
"Laboratory-Reports" = [{ system = "https://example.org/fhir/CodeSystem/access-class", code = "lab" }]
[[access_log.emergency_purpose]]
system = "http://terminology.hl7.org/CodeSystem/v3-ActReason"
code = "BTG" # break the glass marks an emergency access (Art 11(5))
patient_namespaces lists the namespaces your Member State’s access
service searches the log by, written as your identity binding names them:
under the IHE binding a namespace that is an absolute URI, or one
[pixm] maps to an assigning authority. An empty namespace is refused
when the configuration loads. With none, a request addressed by ehr_id
names no patient, and its record says not-configured.
max_named_ehrs, 50 by default, is the most ehr_ids the record of a
population query names from its delivered rows. Above it, or at 0, the
record carries the ehds-population entity instead
(A query over many patients’ data).
Each named ehr_id asks your identity service once, so a larger bound
writes larger records and asks more.
Each key is a template id or an archetype id, written exactly as the
archetype_details of your compositions write it. Each value is a list of
categories, at least one, or "none", your statement that the data under
the key belong to no category. A category is a priority code
(Laboratory-Reports), the same as <system>|<code>, or a declared
national category as <system>|<code>. A category that names no
category, such as a code in another letter case or a national code without
its system, an empty list, a national category without a system, with a
system that is no absolute URI, in the priority categories’ system, or
with a code that is no FHIR code, are refused when the configuration
loads. The lower-case spellings of the development builds before the
codes were settled (patient-summary, medical-test-result and the
rest) are refused; the upgrade notes give the new code of each. A change to [access_log] is
applied by a reload. Without a map every access is recorded unclassified,
so author one from the templates your members hold
(GET {base}/v1/definition/template/adl1.4 at each member).
Each period under [access_log.retention] is a whole number of years, at
least 3. A smaller one, a category code that names no category, and an
origin that is no endpoint of the registry are refused when the
configuration loads, naming the key. A change is applied by a reload and
reaches the records written after it.
Each [[access_log.emergency_purpose]] needs a code, and takes a
system. Without a system it matches only a purpose a token declares
with no system. An empty code or system and an unknown key are refused when
the configuration loads (Emergency access).
The Annex II 3.2 checklist
| Item | Requirement | How FerroFED meets it |
|---|---|---|
| 3.2 | a record “on every access event or group of events” | one record per federated query, stored-query execution, routed read and routed write that reached a node, the console’s included; stored before the answer leaves |
| 3.2(a) | the healthcare provider or other individuals who accessed the data | the provider agent and the Application agent of each record |
| 3.2(b) | the specific natural person or persons who accessed the data | the agent:user, from the token the gateway verified: the professional’s name and identifier, the assurance level when one was established, and whether a person or a client acted |
| 3.2(c) | the categories of data accessed | the ehds-categories entity, classified by your [access_log] map, unclassified with its evidence where the map cannot tell |
| 3.2(d) | the time and date of access | recorded |
| 3.2(e) | the origin or origins of the data | one origin entity per endpoint the query was sent to, with its node, its outcome and its categories |
| Art 11(5) | an access to restricted data in the vital interests “logged in a clear and understandable format” | the ehds-emergency-access entity of a record whose token declares a purpose you name: see Emergency access |
| 3.3 | tools to review and analyse the log data, or the connection of external software | the records go to your ATNA Audit Record Repository and are read there with ITI-81 by any Audit Consumer, your Member State’s access service included: see Reading the log |
| 3.4 | retention periods and access rights by origin and category | each record states the period its categories and origins call for, never under three years (Art 9(2)): see How long a record is kept; access rights are set at your repository, by the security labels each record carries for its categories and its origins: see Labelling the access records |
Labelling the access records
Regulation (EU) 2025/327 asks the components that store personal electronic health data to “support different retention periods and access rights that take into account the origins and categories” of the data (Annex II 3.4). The gateway keeps no access record: each goes to your Audit Record Repository (The access log), which returns only the records “which the requester is authorized to view” (IHE RESTful ATNA Rev. 3.6 §3.81.4.1.3), and ATNA asks that “access controls should appropriately restrict access to audit records” (ITI TF-1 §9.5). So the access rights are set at your repository, and the gateway gives each record what the repository needs to set them by: a security label per category of the data the access reached and per origin of the data, set when the record is written and sent with it over ITI-20.
Where a record carries its labels
| Element | Labels |
|---|---|
securityLabel of the ehds-categories entity | the labels of the categories |
securityLabel of each origin entity | the labels of that origin |
Each label is written once, on the entity it belongs to, and the record
writes no meta.security. IHE RESTful ATNA Rev. 3.6 maps each
entity.securityLabel to that entity’s DICOM ParticipantObjectSensitivity
and maps no meta (Table 3.81.4.2.2.1-1), so the entity labels are the ones
that survive in both forms of a record. A repository that filters on the
FHIR _security search parameter, which reads meta.security, finds no
label there: read the entities’ securityLabel instead.
The labels a record carries
| The access | Its labels |
|---|---|
reached data of a category your [access_log] map names | that category’s labels: the ones [access_log.labels.categories] declares for it, or, when it declares none, the category’s own code, for a priority category the code of HL7 Europe’s EEHRxFDocumentPriorityCategoryCS at version 1.0.0-ballot |
reached data of no category (ehds-no-category) | no category label |
reached data the map cannot classify (ehds-unclassified), in whole or in part | the labels of every category the map can name: the six priority categories and each national category you declare |
| asked an origin (an endpoint of the registry), one that answered nothing included | that origin’s labels: the ones [access_log.labels.origins] declares for it, or, when it declares none, the endpoint id as the code in origin_system |
An unclassified access carries every category’s label because nothing says which categories its data hold: a repository that admits a reader to a record only when the reader may read each of its labels shows it only to a reader who may read every category. The same reasoning keeps such a record for the longest period you declare (How long a record is kept).
No adopted act fixes a label vocabulary for a category or an origin, so
the defaults and the rule for an unclassified access are FerroFED’s own
design. The default origin system,
https://ferrofed.eu/fhir/CodeSystem/access-log-origin, is a code system
FerroFED owns whose codes are your registry’s endpoint ids. Its FHIR R4
CodeSystem is served at that URL with content not-present, because
the codes belong to each deployment
(The definitions behind FerroFED’s URLs).
[access_log.labels]
[access_log.labels]
# the code system an origin with no labels of its own is labelled in, its
# endpoint id the code; FerroFED's own system when unset
origin_system = "https://example.org/fhir/CodeSystem/access-log-origin"
[access_log.labels.categories]
# an Art 14(1) code, or a national <system>|<code> declared in [access_log]
"Laboratory-Reports" = [{ system = "https://example.org/fhir/CodeSystem/access-class", code = "lab" }]
[access_log.labels.origins]
# an endpoint id of the registry
"node-a" = [{ system = "https://example.org/fhir/CodeSystem/access-class", code = "hospital-a" }]
A table entry replaces the default label of its category or origin with
the labels it lists, at least one. Each label is a system, an absolute
URI without |, and a code, a FHIR code. These are refused when the
configuration loads, naming the key: an origin_system that is no
absolute URI, a category the map does not name, two keys that name one
category (Laboratory-Reports and its <system>|<code> form), an empty
list, a malformed system or code, an unknown key in a label, and an origin
that is no endpoint of the registry. A change is applied by a reload and
reaches the records written after it.
Configuring your repository
Give each reader of the log the labels they may read, and have the repository admit a reader to a record only when the reader may read every label the record carries. For example:
- an operator who reviews the accesses to one member’s data is granted that member’s origin label and every category label;
- a reader who may see accesses to laboratory reports alone is granted the
Laboratory-Reportslabel and every origin label, and does not see an unclassified record, which also carries the other categories’ labels; - your Member State’s electronic health data access service, which serves each person all accesses to their own data (Art 9(2)), is granted every label.
ATNA suggests the IUA Profile for the authorization: the repository grouped with an IUA Resource Server enforces the policy, and the Audit Consumer grouped with an IUA Authorization Client presents the reader’s authorization (ITI TF-1 §9.5). ITI-81 defines no behaviour for the FHIR search parameters beyond its own (§3.81.4.1.2.2), so whether a reader can also search the log by label is your repository’s choice.
A record carries the labels it was written with. After a change to
[access_log.labels], the records written before it keep their earlier
labels, so review your repository’s grants against both.
Client authentication
Every client authenticates to the gateway (§13.1, N25). A request to the
ITS-REST surface under {base}/v1/, and OPTIONS {base}/, carries an access
token the gateway verifies before it reads anything else of the request. A
request that fails is answered 401, 403 or 503, and no node, no
cross-reference service and no store is asked anything. There is no
unauthenticated mode. This page covers how you configure the issuers your
clients get their tokens from, what each operation requires, the purpose of
use, and the edge mode for a deployment that authenticates at a proxy. What
each node is told about the caller is on
What a node is told about the caller. FerroFED’s answers to the five questions §13.4 asks
every deployment, and the template for yours, are on
The §13.4 deployment decisions.
The health family (GET {base}/health, /health/readiness) and
GET {base}/ stay open. They name no patient and no member, and
GET {base}/ names the product version. Restrict the root at the proxy if
your network should not learn the version
(Hardening). The dependency report,
GET {base}/operator/dependencies, names every member endpoint by its id
with the state the gateway last observed of it, so it sits on the
operator surface, behind the operator scope. A
path outside the base, or under it but outside {base}/v1/ and the routes
above, is a 404.
The token
A client sends Authorization: Bearer <token> (RFC 6750 §2.1), once. The
gateway checks it through ferrohealth-smart, the SMART and OAuth 2.0
resource-server crate every FerroHEALTH product runs on, which checks tokens
and never issues them. The token is a JWT access token, verified as follows:
- the bearer value is a
b64token(RFC 6750 §2.1); - its
issnames an[[auth.issuer]]the gateway trusts, whose key set verifies the signature under the key itskidnames. A token with nokidis verified only when the set holds one candidate key; - its JOSE header names an algorithm the issuer’s
algorithmsadmits, by default RS256, RS384, PS256, PS384, ES256 and ES384. A token signed withnoneor an HMAC algorithm is refused, and so is an HMAC algorithm inalgorithmsat load (RFC 8725 §3.1, §3.2); - under the default
profile = "rfc9068", its header names the typeat+jwt(RFC 9068 §4) and it carries every claim RFC 9068 §2.2 requires:iss,exp,aud,sub,client_id,iatandjti. Asubthat is empty or blank names no caller and is refused; audnames one of the issuer’saudience;exp,nbfandiathold within the issuer’sclock_skew_seconds;- the token names a caller the issuer’s
kindadmits (Who the caller is).
Every token that fails a check gets the same 401 with
WWW-Authenticate: Bearer realm="ferrofed", error="invalid_token", and no
error_description (RFC 9068 §4, RFC 6750 §3.1): a refusal that named the
failed check would teach a forger which claim to fix next. Which check failed
reaches the security log alone, as check on the caller-refused event. A
request with no credential, or with a credential in another scheme, gets
401 with the challenge and no error code (RFC 6750 §3.1). Two
Authorization fields are a 400 (credential-malformed) with
error="invalid_request".
An issuer can be verified by RFC 7662 introspection instead, with
mode = "introspection" and an [auth.issuer.introspection] table. The
gateway reads the issuer’s metadata when a token first needs it, then asks
the issuer’s endpoint about each token, authenticating with its own client
credentials, and admits the token when the answer is active, names one of
the issuer’s audience in aud, and has an exp that has not passed. A
token that is no JWS goes to the one issuer the deployment introspects.
The gateway never forwards the client’s token to a node. Its audience is the gateway, and one token would unlock every member that trusts its issuer (RFC 9700 §2.3). Toward each node the gateway sends that endpoint’s own onward credentials (Onward credentials), and the caller’s identity in a token of its own (below).
Who the caller is
Every [[auth.issuer]] declares its kind, whose tokens it signs, and
ferrohealth-smart classifies each token it verifies under it:
persons: the professionals of your deployment, signing in through your own clients. A token whosesubis itsclient_idis a system acting without a user context; any other token is admitted only through a client the issuer lists infirst_party_clients, or a delegate it lists indelegates, and an RFC 8693actclaim only when its actor is a listed delegate;patients: patients, each token carryingpatient/scopes and nouser/orsystem/scope. An issuer bound to a member with[trust.issuer.patient]is usually one;machines: systems under the client credentials grant alone;federation: another party’s issuer, with its[[auth.federation]]table.
A token that matches no step for its issuer’s kind is refused as an invalid
token. The gateway counts a system caller as no natural person
(Professionals and assurance). The gateway
admits no HTTP Basic caller: a machine, such as a scraper of the admin
listener or a PMIR Registry, authenticates with a client-credentials token
(RFC 6749 §4.4). Any [[auth.basic.users]] entry refuses the start, naming
the user; one that lists a system/ scope is refused by ferrohealth-smart
first (GHSA-jhg2-jwjp-hq74).
Configuration
Two sections configure client authentication. [auth] is the section of
ferrohealth-smart, the same in every FerroHEALTH product: one
[[auth.issuer]] table per issuer, with its kind, its profile, its
audiences, its token rules and its assurance table, and the product scope.
[trust] is FerroFED’s own policy over the callers [auth] verifies: what
each issuer’s tokens are honoured for, the edge mode and the purpose-of-use
rule.
[auth]
product_scope = "__ferrofed.operator"
[[auth.issuer]]
issuer = "https://idp.example.org"
kind = "persons"
audience = ["https://gateway.example.org/fed"]
first_party_clients = ["example-clinical-app"]
[[auth.attributes]]
issuer = "https://idp.example.org"
mapping = "iua"
authorization_details_purposes = true
[[trust.issuer]]
issuer = "https://idp.example.org"
backend_clients = ["example-reporting-service"]
demographic_clients = []
| Key | Default | Meaning |
|---|---|---|
auth.issuer[].issuer | none | The issuer identifier its tokens carry in iss: https, or http to a loopback host (RFC 8414 §2). An issuer URL that carries a user name or password refuses the start (OpenID Connect Core 1.0 §1.2). Each auth.issuer[] key below is the issuer’s own; no [auth] key stands in for one an issuer leaves out. |
auth.issuer[].kind | none | Whose tokens the issuer signs: persons, patients, machines or federation (Who the caller is). Required. |
auth.issuer[].profile | rfc9068 | The preset the issuer’s tokens are read under: rfc9068, a standards-only authorization server; entra, keycloak and okta, each that product’s token shape; nl, the Dutch harmonised authentication of Annex B. Any profile key set on the table, such as token_types, required_claims or scope_claim, is set over the preset. |
auth.issuer[].first_party_clients | [] | The client_ids of your own applications, the clients a persons token of the issuer is admitted through. |
auth.issuer[].delegates | [] | The third-party clients, each a client_id, and the RFC 8693 actors, each { issuer, sub }, a persons token of the issuer is admitted through as a delegated person. |
auth.issuer[].audience | the public URL | The audiences, of which every token of the issuer names one in aud (RFC 9068 §4). Unset, the gateway.public_url as written; an issuer without an audience refuses the start. |
auth.issuer[].algorithms | six | The signature algorithms admitted: RS256, RS384, PS256, PS384, ES256 and ES384. An HMAC algorithm refuses the start. |
auth.issuer[].clock_skew_seconds | 60 | The leeway on exp, nbf and iat, at most 300 (RFC 7519 §4.1.4). |
auth.issuer[].jwks_max_age_seconds | 600 | How long a key set is used before it is read again. |
auth.issuer[].jwks_refresh_interval_seconds | 60 | The least time between two reads of one key set, after an unknown kid or a failed read. |
auth.issuer[].jwks_file | none | A JWK Set file handed over out of band, read in place of the jwks_uri the issuer’s metadata names. A file holding a symmetric key or private key material refuses the start, naming the key’s index and kid and never a value. |
auth.issuer[].timeout_ms | 10000 | How long one call to the authorization server may take: its metadata, its key set, or an introspection. |
auth.issuer[].role_claims | ["roles", "groups", "entitlements"] | The claims ferrohealth-smart reads a token’s roles from, each a dotted path. The gateway grants nothing by these roles: the professional’s roles it conveys come from the IUA subject_role. |
auth.issuer[].mode | jwt | jwt: the token is a JWT checked against the key set. introspection: the token is checked at the issuer’s RFC 7662 endpoint, under [auth.issuer.introspection]. One issuer at most is introspected. |
auth.issuer[].introspection.endpoint | the metadata’s | The introspection endpoint, in place of the one the issuer’s metadata names. |
auth.issuer[].introspection.client | none | The gateway’s client authentication at the endpoint: client_id with client_secret or client_secret_file, or a private_key_jwt key, as an [<lane>.credentials.oauth] table takes them. |
auth.ca_file | none | A PEM file of trust roots added to the platform’s for every issuer’s metadata, key set and introspection, so an issuer behind a private certificate authority is reached without changing the trust of the whole process. A file that cannot be read, or holds no certificate, refuses the start. |
auth.realm | ferrofed | The realm every WWW-Authenticate challenge names. |
auth.product_scope | none | The scope that admits a caller to the read-only operator surface and to the write actions of the admin listener: a string starting with __ or a full URI you control (SMART App Launch 2.2.0, Scopes and Launch Context, Extensibility). Without it, no caller reaches either. |
auth.basic.users[] | none | Refused: the gateway admits no Basic caller. A machine authenticates with a client-credentials token of an [[auth.issuer]], named in metrics.scrape_clients or pmir.feed_clients. |
auth.issuer[].assurance.claim | acr | The claim, a dotted path, that carries the authentication assurance of this issuer’s tokens (The assurance level). |
auth.issuer[].assurance.levels | {} | The level, low, substantial or high, each claim value stands for. |
auth.issuer[].assurance.minimum | none | The least level a patient-data request needs; some value must reach it. |
auth.dpop.external_url | none | The gateway’s base URL as its clients address it; setting [auth.dpop] admits DPoP requests (DPoP), and a proof’s htu is compared with this URL, the request path appended. |
auth.dpop.max_age_seconds, .clock_skew_seconds, .algorithms | 60, 10, the six asymmetric algorithms | How long after its iat a proof is accepted, at most 300; how far an iat may lie ahead, at most 300; and the algorithms a proof may be signed with. |
trust.mode | token | token: the client presents a bearer token. edge: a proxy authenticates the client (The edge mode). |
trust.purpose_of_use.required | true | Whether a token must declare a purpose of use (Purpose of use). |
trust.issuer[].issuer | none | The issuer this entry’s policy is for, which an [[auth.issuer]] table trusts. |
trust.issuer[].backend_clients | [] | The client_ids whose system/aql-* grant is honoured. |
trust.issuer[].demographic_clients | [] | The client_ids admitted to the DEMOGRAPHIC API. |
trust.issuer[].patient.endpoint | none | The registry endpoint id of the one member whose platform issues this issuer’s patient tokens; setting [trust.issuer.patient] is the opt-in that honours its patient/ grants (Patient grants). |
trust.issuer[].patient.ehr_id_system | none | The identifier system under which the cross-reference service knows that member’s ehr_ids. |
auth.issuer[].act_represents_patient | false | On a patients issuer, whether the RFC 8693 act claim of its tokens names a proxy or a legal representative who acts for the patient (Regulation (EU) 2025/327 Art 4(2)), so a document such a token inserts names that representative; a patient token that carries act on an issuer without it is refused as an invalid token (Patient grants). |
trust.issuer[].requester | none | Refused: who asks for the data is read from the nl.professional_number, nl.role, nl.ura and nl.organisation_type fields of the issuer’s [[auth.federation]] table, which the Mitz consent pre-filter asks about. |
trust.issuer[].client_tokens_act_for_professional | none | Refused: a token ferrohealth-smart classifies as a system’s reaches no patient data (Professionals and assurance). |
auth.attributes[].issuer | none | A persons issuer whose tokens state their professional: its name, identifier and roles, the organisation and the purposes of use, read by ferrohealth-smart (What a token states about its professional); or a patients or machines issuer whose tokens state their purposes of use alone. Without a table, a token of the issuer states none of them. |
auth.attributes[].mapping | none | iua: read the IHE IUA extension, extensions.ihe_iua (ITI TF-2 3.71.4.2.2.1.1). |
auth.attributes[].authorization_details_purposes | false | Whether the system|code purpose_of_use of each RFC 9396 authorization_details entry is read as a purpose of use too (Annex B §B.4a.3). |
auth.attributes[].fields | {} | Sources over the mapping’s, one list per field, each { claim = "<dotted path>" } or { constant = "<value>" }. "professional.issuing_authority_name" names the agency that issued the professional’s identifier (Implementing Regulation (EU) 2026/2099 Annex Table 1 issuing_authority_name), which IHE IUA defines no claim for. |
trust.issuer[].professional_issuing_authority | none | Refused since 0.0.20: name the claim as the "professional.issuing_authority_name" source of the issuer’s [[auth.attributes]] table. |
trust.issuer[].assurance | none | Refused: set the issuer’s [auth.issuer.assurance] table instead. |
federation.contact_point[].issuer | none | A national contact point: an [[auth.issuer]] of kind = "federation" whose [[auth.federation]] table sets contact_point = true (National contact points). Every such issuer needs one [[federation.contact_point]], and one naming any other issuer is refused. |
federation.contact_point[].correlation_header | none | The request header the contact point’s connector sends its correlation identifier in, recorded in the access record. A header that carries a credential (Authorization, Proxy-Authorization, Cookie, DPoP) is refused. |
federation.contact_point[].additional_countries | [] | The ISO 3166-1 alpha-2 codes the contact point’s [[auth.federation]] countries may name, in addition to the 27 EU Member States. Use it for a third country whose contact point the Commission connected to MyHealth@EU (Regulation (EU) 2025/327 Art 24(3)). Neither act names the EEA States, so add one here when your deployment serves it. A code that is not two upper-case letters, or that is already a Member State, is refused. |
trust.edge.header | none | The header the edge’s assertion travels in, with trust.mode = "edge". |
[auth] enabled = false is refused: the gateway has no unauthenticated mode.
So are auth.scopeless_subjects, auth.capabilities and
auth.scopes_supported, which the gateway reads nowhere: it serves no SMART
discovery document and grants nothing without a scope. A key [auth] held
for every issuer before ferrohealth-smart 0.9 (audience, algorithms,
clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds,
jwks_file, timeout_ms, mode, role_claims) is refused at the section,
naming the [[auth.issuer]] table it moved to, and so is an issuer written
as [auth] issuer = "…". A [[trust.issuer]] entry names an
[[auth.issuer]]. A gateway that federates and lists no
issuer refuses to start, with exit code 78, because it could admit no one.
Each bad value refuses to start under its key; a value ferrohealth-smart
refuses is named under auth with the crate’s rule.
A reload, on SIGHUP or
POST /admin/reload, applies a changed [auth] and [trust] to every
request that arrives after it, on the client face and the admin listener,
and reads each issuer’s metadata and key set again; a changed audience
of an issuer the gateway already trusts waits for a restart. A reload whose [auth] does not load, or trusts no
issuer of a federating gateway, is refused and the running issuers stay.
The key set
The gateway reads an issuer’s metadata at its RFC 8414 location, with
OpenID Connect Discovery 1.0 as the fallback, and the key set its
jwks_uri names, when a token of that issuer first needs them; with the
issuer’s jwks_file it reads the file and no metadata. The set is read again
once it is older than jwks_max_age_seconds. A token naming a key the set
does not hold makes the gateway read the set once more, so a key the issuer
rotated in is picked up; that read happens at most once per
jwks_refresh_interval_seconds, so tokens naming unknown keys cannot make
the gateway flood the issuer. Metadata or a key set that cannot be read is a
503 (authentication-unavailable), never a pass, and a failed read is not
repeated inside the refresh interval. A key declared for another use or
another algorithm, or a symmetric key, verifies nothing.
Scopes per route
The token’s scope claim is read in the SMART on openEHR grammar (ITS-REST
SMART on openEHR, master08 §Resource Scopes), with the openehr-sdt crate’s
parser. The grammar has three resource families, template-, composition-
and aql-, each with the CRUDS permissions c, r, u, d and s. An
operation needs a granted scope of its family, in the user/ or system/
compartment, whose permissions hold the operation’s and whose pattern covers
the resource. Where the gateway cannot see which resource a request
addresses, only * or ** covers it.
| Route | Requires |
|---|---|
POST and GET {base}/v1/query/aql | aql-* with s |
POST and GET {base}/v1/query/{name}[/{version}] | aql- with s, its pattern covering {name} |
GET {base}/v1/definition/query/{name}[/{version}] | aql- with r, its pattern covering {name} |
PUT {base}/v1/definition/query/{name}[/{version}] | aql- with c, its pattern covering {name} |
GET {base}/v1/definition/template/… (a list) | template-* with r |
GET {base}/v1/definition/template/…/{template_id}… | template- with r, its pattern covering {template_id} |
POST {base}/v1/definition/template/… (an upload) | template-* with c |
A COMPOSITION under {base}/v1/ehr/{ehr_id}/…, its versions and tags | composition-* with the operation’s permission |
The EHR, its EHR_STATUS, DIRECTORY and CONTRIBUTIONs, and GET {base}/v1/ehr?subject_id=… | composition-* with the operation’s permission |
The DEMOGRAPHIC API under {base}/v1/demographic/ | a client listed in demographic_clients, its token no patient grant |
The ADMIN API under {base}/v1/admin/ | refused to every caller (operation-refused) |
GET {base}/operator/incidents, /operator/creating-systems, /operator/stored-queries, /operator/stored-queries/{name}, /operator/dependencies and /operator/deployment | a verified token carrying auth.product_scope, no purpose of use |
A write action on the admin listener, such as POST /admin/stored-queries/{name}/{version}/distribute or POST /admin/ehr-ids/{ehr_id}/clear, and the export POST /admin/identity-state/export | a verified token carrying auth.product_scope, no purpose of use (Metrics) |
OPTIONS {base}/ and OPTIONS on any path under {base}/v1/ | a verified token, no scope, no purpose of use |
A path or method ITS-REST does not define under {base}/v1/ | a verified token, then 501 |
The table above is the gateway’s own; the specification and the SMART on openEHR grammar govern what each scope means, and the following choices are FerroFED’s design:
- The EHR’s other resources have no SMART family. The gateway holds them to
the
composition-family over every template, the broadest grant over an EHR’s content. - The DEMOGRAPHIC API has no SMART family either, so a scope cannot grant it, and the gateway admits only the clients you list.
- A
patient/grant is confined to the patient of the token’s launch context (master07 §Context Selection), which SMART on openEHR gives as anehrIdat one platform. AnehrIdnames no namespace and means nothing outside the CDR that issued it, so a bare match could admit another patient’s EHR at another node (§12.5, §12.5.2). Apatient/grant therefore admits nothing at the gateway, unless you bind its issuer to one member (Patient grants). system/aql-*“would grant access to all registered and ad-hoc AQL queries system-wide” (master08), so it counts only for a client listed inbackend_clients.- A scope the grammar reads as anything but a resource scope,
openidorlaunch/patientfor example, grants nothing.
The node behind the gateway still makes its own access decision (§13.2, N26).
The operator surface
Six read-only routes on the client listener give the operator console and your monitoring what no other surface carries:
| Route | Answers |
|---|---|
GET {base}/operator/incidents | how many integrity incidents of each kind the gateway emitted since it started, and the last 20 of each kind |
GET {base}/operator/creating-systems | the creating_system_id routing table: each member’s own system_id, each [[creating_system]] mapping, and each learned or withdrawn mapping |
GET {base}/operator/stored-queries | every stored-query version the gateway holds, with its AQL and whether it is read-only |
GET {base}/operator/stored-queries/{name} | every version of the one stored query whose qualified name is {name}, such as org.example::patient_compositions, lowest first; a 404 (stored-query-unknown) when the gateway holds none, and a 400 (query-name-invalid) for a name ITS-REST does not admit |
GET {base}/operator/dependencies | the state the gateway last observed of each member endpoint and service (Health probes) |
GET {base}/operator/deployment | how the process is deployed: its profile, the product version and the replica |
The incident report is {"counts": {...}, "recent": [...], "cleared": [...]}: counts names every incident kind with how many the gateway
emitted since it started, and recent lists the kept incidents oldest
first, each with its kind, its RFC 3339 at, its description, and the
creating_system_id, ehr_id, detection, endpoints and nodes it is
about. The gateway keeps the last 20 of each of the five kinds, so the
report is one page of at most 100 incidents and takes no paging parameters.
cleared lists the last 20 collisions you cleared on this replica, oldest
first, each with its at, its ehr_id (a bare UUID, else null) and the
member node ids as claimants; a clear removes no incident
(Clearing a held collision).
The routing table and both stored-query routes answer one page, as
{"items": [...], "offset": 0, "total": 2}, where total counts every row
and items holds at most limit of them from offset. Both query
parameters are optional: offset defaults to 0 and limit to 100, its
most. A limit of 0 or over 100 is a 400 (parameter-invalid). A
stored query is the ITS-REST StoredQuery, name, type, version,
saved and q, with one member beside them, read_only. It is true when
the gateway takes no PUT under that name: the name sits in the gateway’s
own namespace eu.ferrofed.eehrxf, or the registry reads the read-only
definition files (backend = "files"). The embedded and PostgreSQL stores
take new versions, so a deployment’s own query there is false. The bodies are the types of ferrofed_operator_api::operator,
which the operator console reads as well, and the stored query is the
openehr-its StoredQuery, so the gateway and the console share one
definition of each.
The deployment report is one object:
{
"mode": "development",
"product": "FerroFED",
"version": "0.0.11",
"replica": "ferrofed-7d9c4b5f6-x2k8q"
}
mode is the profile the process started with, production or
development, and a reload never changes it
(Configuration). The gateway knows no
other profile, so the report names no other. product and version are
those of GET {base}/.
The report carries no setting value, URL, credential or patient
identifier.
Each gateway process keeps its own incidents, learned routing table and
dependency states, so every answer of the five routes names the replica
that gave it: in the ferrofed-replica response header, on a 400 as
well, and in a replica member of each report, such as "replica": "ferrofed-7d9c4b5f6-x2k8q". The name is [server] replica, or the host
name the process runs with, which Kubernetes sets to the pod’s name, or
unnamed without either (Configuration).
Behind a load balancer each request reaches one replica, so read a report
as that replica’s view; to see every replica, ask each at its own address.
A caller reaches them only with a token whose scope holds
auth.product_scope, as one whole scope token, from any trusted issuer:
[auth]
product_scope = "__ferrofed.operator"
The product scope is a string starting with __ or a full URI you control,
the two forms SMART App Launch 2.2.0 (Scopes and Launch Context,
Extensibility) leaves to a server’s own scopes; another refuses the start. A
token without it is a 403 (scope-insufficient), and so is a token
whose scope only contains it, starts with it or differs from it in case.
A gateway that names no product_scope admits no operator at all. No purpose of use is
asked, because the routes answer no clinical data: routing ids, counts and
stored definitions only. An incident names an ehr_id only when it is a
bare UUID, and a stored definition names no patient, because the gateway
refuses one that does before it is held (§5.4.1, N33). The routes are not
part of the ITS-REST surface. No specification governs them: they are
FerroFED’s own design.
Purpose of use
A token declares why its data is requested (§13.4: a deployment must not
rely on a node inferring it from the query). The gateway reads it from the
IHE IUA extension, extensions.ihe_iua.purpose_of_use, an array of FHIR
Coding (ITI TF-2 3.71.4.2.2.1.1), or from RFC 9396
authorization_details[].purpose_of_use, written system|code (Annex B
§B.4a.3), as the issuer’s [[auth.attributes]] table reads them. A token that declares none is a 403
(purpose-of-use-required) on every route that reaches a node. A deployment
relaxes the rule with trust.purpose_of_use.required = false, and records why
in its §13.4 decisions. The
organisation the caller acts for is read from
extensions.ihe_iua.subject_organization_id. The IUA person_id claim, a
patient identifier, is never read.
What a token states about its professional
An issuer’s [[auth.attributes]] table is where ferrohealth-smart reads
what a persons token states about its professional, the same table in
every FerroHEALTH product. With mapping = "iua" it reads the IHE IUA
subject_name, national_provider_identifier, subject_role,
subject_organization_id and purpose_of_use, and a token whose claim has
another shape (a subject_role or purpose_of_use that is no array of
codings) is refused as an invalid token, naming the field. An issuer with no
table states no professional, organisation or purpose, so with
trust.purpose_of_use.required its callers are refused every route that
reaches a node. The agency that issued the professional’s identifier is the
table’s "professional.issuing_authority_name" source. A machine of the
issuer (a client-credentials token) states its purposes of use alone, and a
patients or machines issuer may have a table that names no field but
purpose_of_use, so a patient and their representative declare theirs
(Patient grants):
[[auth.attributes]]
issuer = "https://idp.example.org"
mapping = "iua"
authorization_details_purposes = true
[auth.attributes.fields]
"professional.issuing_authority_name" = [{ claim = "hp_issuing_authority" }]
Emergency purposes
A caller asserts an access in the vital interests of the patient, which Regulation (EU) 2025/327 Art 11(5) lets reach data the patient restricted, by declaring an emergency purpose of use in its token. The gateway never infers one. It conveys every declared purpose to each node unchanged, and the node decides what to release (§13, N26); the gateway sends the same request it would send without it and never overrides a node’s refusal.
You name the purposes that mark an access as an emergency access in the
access log, [[access_log.emergency_purpose]], each a code and its
system, matched exactly (Emergency access).
The HL7 v3 ActReason codes a deployment maps are BTG, “break the
glass”, whose definition “may include override of subject of care consent
directive restricting access”, and, where your national rules call for it,
ETREAT, “Emergency Treatment”
(https://terminology.hl7.org/CodeSystem-v3-ActReason.html). Ask your
issuers which they put in purpose_of_use, and declare those:
[[access_log.emergency_purpose]]
system = "http://terminology.hl7.org/CodeSystem/v3-ActReason"
code = "BTG"
Professionals and assurance
Regulation (EU) 2025/327 asks an EHR system “designed to be used by health
professionals” to “provide reliable mechanisms for the identification and
authentication of health professionals” (Annex II 3.1). Its Art 12 admits to
a health professional access service only professionals holding electronic
identification means recognised under Regulation (EU) No 910/2014 or
compliant with the Art 36 common specifications. For a cross-border exchange,
Implementing Regulation (EU) 2026/2099 Art 6(3) has the entity a Member State
lists authenticate the professional at assurance level “substantial”, and at
level “high” from 26 March 2032. The gateway verifies the token; your issuer
authenticates the person. Two rules connect the two, and both hold on every
request that reaches patient data: a query, the EHR API and the DEMOGRAPHIC
API. A definition request, OPTIONS and the operator surface reach none, so
neither rule applies to them.
A natural person
A token names no natural person when ferrohealth-smart classifies it as a
system’s under its issuer’s profile, as a token whose sub is its
client_id is (“If known, unique identifier of the user; the client_id
otherwise”, IHE IUA ITI TF-2 3.71.4.2.2.1), or when only system/ scopes
cover the operation, which SMART on openEHR grants “to backend applications
acting without a user context” (master08). Such a token is refused patient
data with 401 natural-person-required, whatever professional it names.
A client application that acts for a professional signs that professional in at its issuer, so the token names the person and the client both: the issuer’s profile classifies it as a delegated person, and it reaches patient data as the person does.
The assurance level
You declare per issuer, in the [auth.issuer.assurance] table of
ferrohealth-smart, which claim carries the assurance of its tokens, which
values stand for which level, and the least level patient data needs:
[[auth.issuer]]
issuer = "https://idp.example.org"
kind = "persons"
# ...
[auth.issuer.assurance]
claim = "acr"
minimum = "substantial"
[auth.issuer.assurance.levels]
"urn:example:loa:substantial" = "substantial"
"urn:example:loa:high" = "high"
The levels are the three of Regulation (EU) No 910/2014 Art 8(2): low,
substantial and high. The claim defaults to acr, which an access token
may carry (RFC 9068 §2.2.1); the gateway reads it from the token, or from
the introspection answer, as one string. No specification the gateway binds
says which acr values stand for which level, so you list the values your
issuer writes. A token that carries no value, a value listed at no level, or
a level below minimum is refused patient data with
401 authentication-assurance-insufficient, its challenge
error="insufficient_user_authentication" (RFC 9470 §3), so a client knows to
authenticate its user again.
An issuer without [auth.issuer.assurance] has no level read or required,
and config check names each such issuer in a note. Set it for every issuer
whose tokens reach patient data. Where the gateway serves a cross-border
exchange, set minimum = "substantial", and "high" from 26 March 2032. A
national contact point’s tokens carry the level it asserts for a
professional another Member State authenticated: the gateway cannot check
that Member State’s means, and Implementing Regulation (EU) 2026/2099
Art 6(2) gives the check to the requesting Member State’s entity. The least
level applies to such an issuer as to any other.
Who acts, the professional’s identification and the level reached travel to each node in the caller’s conveyance (below), and in nothing else the gateway sends. Which claims carry them and the refusal are FerroFED’s own design; the texts above set the obligation and the levels.
National contact points
A national contact point for digital health relays the request of a health
professional of another Member State (Regulation (EU) 2025/327 Art 23).
Implementing Regulation (EU) 2026/2099 gives the identification,
authentication and authorisation of that professional to the entity their
Member State lists (Art 6(1), (2)), and has the contact point of the
professional’s Member State communicate the professional’s and the
provider’s identification data, the attributes of its Annex Tables 1 and 2
(Art 7). You declare the issuer of the contact point’s tokens as a
federation issuer whose [[auth.federation]] table sets
contact_point = true, and name it in a [[federation.contact_point]]:
[[auth.issuer]]
issuer = "https://ncp.example.org"
kind = "federation"
sender_constraint = "required"
[[auth.federation]]
issuer = "https://ncp.example.org"
contact_point = true
mapping = "ehdsi"
countries = ["LU"]
fields."professional.country_code" = [{ constant = "LU" }]
fields."professional.issuing_authority_name" = [{ constant = "Example HP Register" }]
fields."provider.issuing_authority_name" = [{ constant = "Example HCP Register" }]
[[federation.contact_point]]
issuer = "https://ncp.example.org"
correlation_header = "ncp-correlation-id"
A contact point needs FerroFED’s own store, [store]: a
configuration that declares one and sets no [store] refuses to start,
naming federation.contact_point[0] and [store]. The gateway refuses a
contact point with no [[federation.contact_point]], a
[[federation.contact_point]] that names no contact point, a countries
code that is neither a Member State nor one of its additional_countries,
and an [[auth.federation]] table that names no source of
provider.issuing_authority_name.
Who authenticates the professional. The contact point’s side does. The
gateway verifies the contact point’s token, as it verifies every caller’s,
and reads the professional and the provider from it as the contact point
asserts them; it never authenticates the professional, and cannot check the
other Member State’s identification means. Federation Tier §13.4 lets the
source “rely on the requester’s assertion”, and the node is told the
attributes marked as the contact point’s assertion. An assurance level the
token states is read through [auth.issuer.assurance] as for any issuer
(The assurance level).
The token. The contact point’s authorization server writes what it read
from the validated HP Identity Assertion and treatment relationship
confirmation under the token’s ehdsi claim, each attribute at its field
name, and binds the token to the connector’s client certificate
(cnf."x5t#S256", RFC 8705 §3), which the connector presents on its mutual
TLS connection to the gateway. ferrohealth-smart holds the token to the
MyHealth@EU contact-point profile, what a conforming assertion carries
(eHDSI SAML Profile 10.0.0 §2.1, §2.3): the professional’s identifier, the
assertion’s NameID, with its format at assertion.name_id_format, one of
the three §2.1 allows, the one-string name, the role with on-behalf-of for
an ISCO 44 role, the authentication class, the provider’s
organization-id, the facility type, the home community, the point of
care, a purpose of use of TREATMENT or EMERGENCY, and the assertion and
TRC ids and windows. The gateway checks no field itself. The country and
the issuing authorities are no part of the assertion, so you configure them
as constants of the contact point. A token that falls short is refused
403 contact-point-attributes-required on every route, and no node is
asked anything; a token not bound to the connection’s certificate is
refused 401. The contact point’s connector acts for the professional the
token names, which ferrohealth-smart classifies as an external person. The
IUA person_id, a patient identifier, is never read (§5.4.1, N33): the
patient is named in the request, as for every caller.
What a node is told. A node is told the professional by their identifier and by no name or address (GDPR Art 5(1)(c)), and a node that needs a display name shows the identifier. The access record keeps the name the identity assertion states, which Implementing Regulation (EU) 2026/2099 Annex Table 1 makes mandatory (The access log).
Consent exclusions stay hidden. Every request of a contact point is
served with disclose = false, whatever [federation.consent] disclose
says (Withholding consent exclusions): the
provider it relays is a healthcare provider to whom a restriction “shall
not be visible” (Regulation (EU) 2025/327 Art 8, Art 11(5)). The setting can
only tighten: a deployment that withholds exclusions from its own callers
withholds them from the contact point too. The gateway applies no category
rule of the Member State of treatment; release stays with the node, which
receives the foreign professional in the conveyance (N26, N27).
What the record and the node receive. The access record names the
foreign professional by their identifier and the provider, with their
country and issuing authorities, marked as asserted by the contact point
(Audit), and each node
is told them in the national_contact_point claim of the conveyance
(below). A correlation identifier
the connector sends in correlation_header is recorded with the request,
so the record can be joined with the contact point’s own exchange log; it
reaches no node. A value sent twice, empty, longer than 128 bytes or with a
byte outside visible ASCII is refused 400 correlation-invalid.
OPTIONS {base}/ declares every contact point under
federation.national_contact_point: disclose: false, professional: "asserted-by-contact-point", purpose_of_use: "required", the emergency
access limit in effect as emergency with its threshold and window,
and per issuer the claim each field is read from, by its field name, and
the correlation header. The eHDSI profile defines no token carrier for its
attributes, so the claim names, the member and the header are FerroFED’s
own design.
DPoP
A client may bind its token to a key and prove it holds the key on every
request with a DPoP proof (RFC 9449), presenting the token in the DPoP
scheme. With [auth.dpop] the gateway admits such a request: it checks the
proof against the request’s method and URL (§4.3) and the token’s
cnf.jkt against the proof’s key (§6), and spends the proof’s jti so a
second use is refused (§11.1). Without the table no DPoP request is
admitted. A national contact point’s tokens must be sender-constrained, by
DPoP or by the client certificate of the connection (RFC 8705 §3).
[auth.dpop]
external_url = "https://gateway.example.org"
RFC 9449 §11.1 notes that single use “may not always be feasible in
practice, e.g., when multiple servers behind a single endpoint have no
shared state”. With FerroFED’s own store set, every replica
records each accepted proof in ferrofed.dpop_proof, by the SHA-256 of its
jti, in the one statement that also checks it, so of two replicas sent one
proof at once only one admits it. With the store down, a DPoP request is
refused 503, never checked in memory instead. A gateway with no store
records proofs in its own memory, which holds within one replica alone, so
run several replicas with DPoP only beside the store. A contact point
needs the store in any case. A proof that does not hold, or was sent before,
is refused 401 with the challenge DPoP error="invalid_dpop_proof", and a
token that does not hold in the DPoP scheme with DPoP error="invalid_token" (§7.1). The store and the single-statement check are
FerroFED’s own design.
The edge mode
A deployment that authenticates its clients at a proxy configures the edge
mode explicitly; it is never a default. The proxy authenticates the client by
whatever means it has, then signs an assertion of who it authenticated, an
RFC 9068 access token whose aud is this gateway, and sends it in the header
trust.edge.header. The gateway verifies the assertion against the edge’s key
set exactly as it verifies a token, reads the caller from it, and logs the
security event edge-identity-asserted with the edge’s issuer and two
references, subject_ref and client_ref. Each reference is an
HMAC-SHA256 of the subject or the client under a key the gateway draws at
start: one caller’s events carry the same references for the life of the
process, and the log names no caller. A bearer token in Authorization
admits no one in this mode.
[auth]
[[auth.issuer]]
issuer = "https://edge.example.org"
kind = "persons"
audience = ["https://gateway.example.org/fed"]
first_party_clients = ["example-clinical-app"]
[trust]
mode = "edge"
[trust.edge]
header = "ferrofed-edge-assertion"
The edge mode takes a signed assertion, never a header trusted for the address it came from. A forwarded header “cannot be relied upon to be correct, as it may be modified … by every node on the way to the server”, and trusting proxies by address leaves it open to anyone “with access to the network” between them (RFC 7239 §8.1). A signature holds wherever the request travelled.
Failure statuses
| Status | Code | When |
|---|---|---|
400 | credential-malformed | two Authorization fields, or one that is no credential; the challenge is error="invalid_request" (RFC 6750 §3.1) |
401 | unauthenticated | no token, or one the gateway does not accept; the challenge carries error="invalid_token" for a token that does not hold and no error code for a missing one, and never names the check that failed; in the DPoP scheme, error="invalid_dpop_proof" for a proof that does not hold or was sent before (RFC 9449 §7.1) |
403 | scope-insufficient | no granted scope covers the operation, or the client is not admitted to the DEMOGRAPHIC API |
403 | purpose-of-use-required | the token declares no purpose of use |
401 | natural-person-required | the request reaches patient data and the token names no natural person, and its issuer does not declare client tokens acting for the professional the token names |
401 | authentication-assurance-insufficient | the request reaches patient data and the token states no assurance at the least level its issuer requires |
403 | contact-point-attributes-required | a national contact point’s token falls short of the MyHealth@EU contact-point profile (National contact points) |
400 | correlation-invalid | a national contact point’s correlation header is sent twice, empty, longer than 128 bytes, or not visible ASCII |
403 | operation-refused | the ADMIN API, refused to every caller |
403 | patient-context-missing | only a bound issuer’s patient/ scope covers the operation, and the token carries no ehrId |
403 | patient-confinement | the request reaches beyond the patient a patient/ grant is confined to, or a patient grant addresses the DEMOGRAPHIC API |
424 | patient-context-unavailable | the patient of a patient/ grant cannot be resolved at every member |
503 | authentication-unavailable | the issuer’s metadata, key set or introspection endpoint cannot be had |
A 403 names no scope, in the challenge or the body (RFC 6750 §3.1 makes
the scope attribute optional). Every refusal is logged under the
ferrofed::security target as caller-refused, with its reason, the check
that refused a credential and the gateway’s request id, never the
token (Errors and status codes).
The quickstart issuer
The compose quickstart trusts one development issuer, its issuer service,
NAV’s mock-oauth2-server at http://localhost:8190/quickstart. Its client
credentials grant for quickstart-client returns a person’s token valid
for an hour, the user quickstart-user, every scope in the user/
compartment and the purpose of use TREAT. The gateway reads an issuer’s
metadata and accepts plain http only on loopback, so it runs in the
issuer’s network namespace. It signs for anyone who asks and is not for
anything real.
TOKEN="$(curl -s http://127.0.0.1:8190/quickstart/token -d grant_type=client_credentials \
-d client_id=quickstart-client -d client_secret=any | jq -r .access_token)"
curl -s http://127.0.0.1:8080/v1/query/aql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"q": "…"}'
What a node is told about the caller
The header each node receives with the caller’s identity, its claims, and how a node verifies it are on What a node is told about the caller.
Patient grants
A patient-facing app holds a token whose patient/ scopes are confined to
one patient, named by the token’s ehrId claim (SMART on openEHR master04
§Capabilities, master07 §Context Selection). That ehrId is an ehr_id at
the platform that issued the token, and at no other member. You opt in per
issuer by naming that platform’s member and the identifier system under
which your cross-reference service knows its ehr_ids:
[[auth.issuer]]
issuer = "https://patient-portal.example.org"
kind = "patients"
audience = ["https://gateway.example.org/fed"]
[[auth.attributes]]
issuer = "https://patient-portal.example.org"
mapping = "iua"
authorization_details_purposes = true
[[trust.issuer]]
issuer = "https://patient-portal.example.org"
[trust.issuer.patient]
endpoint = "node-a-pub"
ehr_id_system = "urn:oid:2.999.9.1"
The issuer is a patients issuer: ferrohealth-smart admits its token only
when the token carries patient/ scopes and no user/ or system/ scope,
so a professional of the same platform signs in through an issuer of its
own (Who the caller is). Its
[[auth.attributes]] table reads the purpose of use each token declares,
the patient’s and a representative’s alike; it names no field but
purpose_of_use, since a patients token states no professional. Without
the table, every caller of the issuer is refused
purpose-of-use-required.
config check and the start refuse a binding whose endpoint the registry
lacks, a binding without a registry, and a binding without a cross-reference
service ([pixm]), each naming its key. Without the section, a
patient/ grant of that issuer admits nothing, as above.
With the opt-in, a patient/ scope of that issuer counts on an EHR’s data
alone: the composition- family, and an aql- search. When only
patient/ scopes cover an operation, the gateway confines the request to
the token’s patient:
- It reads the
ehrIdclaim from the access token, or from the introspection answer. A token without one, or with one that is noHIER_OBJECT_ID, is403(patient-context-missing). - It resolves the pair (
ehr_id_system,ehrId) through the cross-reference service at every member (§5.2), giving the patient’s own{node, ehr_id}pairs. The bound member keeps the token’sehrId. A cross-reference that cannot answer, or that places the patient under anotherehr_idat the bound member, is424(patient-context-unavailable). - It admits the request only when every
{node, ehr_id}pair it would send to is one of those. Everything else is403(patient-confinement) with nothing sent: a query for another patient, a query that names no patient, anehr_idthat is not the patient’s at the member it would go to, a read by subject of another patient, the creation of an EHR and a definition request. Anehr_idthe patient’s own pairs do not place is never looked up in the gateway’s index or probed for at the members, and a refused read by subject leaves no resolution binding for the caller and no entry in the index. - A query must read the patient’s EHR alone: every class in its
FROMis contained, conjunctively, under the oneEHRit is scoped to (FROM EHR e CONTAINS COMPOSITION c CONTAINS OBSERVATION o). A class beside thatEHRunderANDorOR, a secondEHR, or anEHRunderNOT CONTAINSwould let a node answer other patients’ rows, so such a query is403(patient-confinement) before anything is looked up (§7.1). - A query or a read by subject that names a patient is first resolved
at the bound member alone. When the patient there is not the token’s
own
ehrId, the request is403(patient-confinement) before any localizer (XCPD or the NVI), consent pre-filter or other member is asked about it, so no ITI-55 exchange or audit event is made for that patient. A patient summary on the FHIR face is held the same way before the PDQm Supplier is asked for its header, so no ITI-78 exchange is made for another patient either.
An electronic health data access service (Regulation (EU) 2025/327 Art 3(1)
and Art 4) reads a person’s own patient summary with such a token: the
scope patient/aql-eu.ferrofed.eehrxf::*.s covers the summary’s section
queries and nothing else, and the token names the person as its sub.
The summary comes from every member the cross-reference places the person
at, and the access record names the person as the caller and as the data
subject (The patient summary over FHIR).
A person inserts information in their own EHR (Art 5) with such a token
too. A document the FHIR face receives under a token whose sub names the
person, with a patient/ scope that creates compositions
(patient/composition-*.c), is written into the person’s own EHR at the
receiving member, every entry naming the record’s subject as its provider,
and its access record says the person inserted it (Inserted by the
person).
A proxy or a legal representative (Art 4(2)) who inserts for the person is
told apart only when the token says so. RFC 8693 §4.1 names any delegate
in the act claim, a client service as well as a person, so set
act_represents_patient = true on the [[auth.issuer]] of kind = "patients" only for an issuer whose patient tokens name the
representative there; on any other issuer, a patient token that carries
act is refused as an invalid token. Every entry of
a document such a token inserts then names the representative, a
PARTY_IDENTIFIED holding the act sub, and its access record says a
representative inserted it (Inserted by the
person). An
issuer that declares nothing cannot say a representative acts, so what its
tokens insert is marked as the person’s own.
Over ITS-REST the gateway routes a person’s write as it routes any
caller’s. Under a patient grant whose scopes cover a composition update or
delete, it sends the request to the member that owns the composition,
confined to the person’s own EHR as above, with the body unchanged (§5.4).
It does not mark such a write as the person’s, and it cannot tell a
person’s entry from a professional’s: only the member, which records the
committer of each version and is told in the caller token that a person
acts (acting is person), can stop a person changing an entry a health
professional wrote, as Art 5 asks.
A token whose resource scopes are all patient/ never reaches the
DEMOGRAPHIC API, whether or not its issuer is bound and even when its client
is listed in demographic_clients: a patient scope reaches its patient’s
own EHR alone (master08 §Resource Scopes). The gate refuses it 403
(patient-confinement) before anything is resolved.
The gateway never compares the bare ehrId with an ehr_id at another
member, because one ehr_id can name another patient’s EHR there (§12.5.2).
Each node is told the patient’s own ehr_id at that node in the ehrId
claim of the caller’s token, with only the patient/ scopes that cover the
operation in scope, so the node can enforce the grant as well (N26;
below). The outbound gate holds
that claim to the ehr_id the request to that node is composed for: the
one its query is scoped to, or the one its path names. A request whose
token would tell the node another ehr_id, or that names none, is never
sent (§5.4.1, N33). No specification defines a patient grant across nodes,
so this opt-in is FerroFED’s own design.
The residual risk is the cross-reference service. The confinement is only
as correct as its link between the ehrId at the bound member and the
patient’s ehr_id at every other member: a wrong link admits the wrong
EHR at that member. Bind an issuer only to the member whose platform issued
its tokens, and only when your cross-reference service holds that member’s
ehr_ids as identifiers under ehr_id_system. Record the choice in your
§13.4 decisions.
The §13.4 deployment decisions
The specification fixes no authentication solution. It fixes five questions a deployment must answer and record instead (§13.4, N25, CP-39). CP-39 is an operator point: the specification scores it against your deployment, not against the gateway. This page gives FerroFED’s own answer to each question, as the gateway behaves with its default configuration, names the configuration that changes each answer, and ends with a template for the parts only your deployment can answer. Copy the template, fill it in, and publish it with your deployment.
The answers rest on three pieces of the gateway:
client authentication, OAuth 2.0 from the gateway to
each node (Configuration), and the
openEHR-federation-client token that tells each node who asks
(What a node is told about the caller).
1. Which identity is verified across the trust boundary
There are two boundaries, and a different identity crosses each.
From the caller to the gateway. The gateway verifies an RFC 9068 access
token: its issuer is on your trust list, its signature verifies against that
issuer’s key set (or the issuer’s RFC 7662 introspection endpoint calls it
active), and its aud names the gateway. What is verified is the issuer’s
statement: the caller’s sub, the client_id, and the organisation the IHE
IUA subject_organization_id names. The gateway checks the issuer’s
signature over that organisation identifier. It does not look it up in an
organisation register: whether the issuer may speak for that organisation is
the trust you place in the issuer when you list it.
From the gateway to each node. The gateway authenticates as itself. With
an oauth section for the endpoint, it signs an RFC 7523 client assertion
naming the client_id the node’s authorization server registered for it,
and the node’s authorization server verifies it against the gateway’s
published JWK Set and its own client registry. With client_auth = "tls_client_auth" or "self_signed_tls_client_auth", the gateway’s TLS
client certificate is that identity instead: the node’s authorization
server matches the certificate to the client it registered for client_id
(RFC 8705 §2). Without a grant, it sends the user and
password configured for that endpoint, or, with TLS material alone, only
presents its certificate. Every request also carries the
openEHR-federation-client token, signed with the [signing] key, whose
iss the node can check against that same client_id.
What changes the answer: [auth] issuers and [[trust.issuer]] (the
trust list, by key set or introspection, with each issuer’s kind and
audience), trust.mode,
[credentials."<endpoint id>"] and its oauth section, and [signing].
2. Who authenticates the end user, and where that trust stops
The requesting organisation does. Its authorization server, or the proxy in the edge mode, authenticates its own clinicians and systems and issues the token. The gateway never authenticates an end user and never re-authenticates one: it verifies the token the organisation’s authorization server signed, and that is where its check stops.
The gateway holds the token to who acts and how they authenticated. A
request that reaches patient data needs a natural person behind the token,
or a client its issuer declares as acting for the professional the token
names, and, where you declare it per issuer, the least assurance level the
token’s acr (or the claim you name) must state (Regulation (EU) 2025/327
Annex II 3.1). The issuer’s authentication sets that level; the gateway
reads it and refuses a token below it
(Professionals and assurance).
A foreign professional is authenticated by their own Member State. A request a national contact point relays comes from a health professional of another Member State. The entity that Member State lists identifies, authenticates and authorises the professional (Implementing Regulation (EU) 2026/2099 Art 6(1), (2)), and its contact point communicates the professional’s and the provider’s identification data (Art 7). The gateway verifies the contact point’s token, never the professional: it requires every attribute of the 2026/2099 Annex Tables 1 and 2 and a purpose of use, and passes them to the node and the access record marked as asserted by the contact point. Trust in the professional stops at the contact point’s assertion (National contact points).
The node relies on the gateway’s statement. It does not re-authenticate
the caller either. It reads who asks from the openEHR-federation-client
token: the sub and iss_upstream the gateway verified, and verified_by,
which says edge when a proxy asserted the identity rather than an issuer’s
token proving it. The caller’s own token never reaches a node.
What changes the answer: trust.mode = "edge" moves end-user
authentication to your proxy, whose signed assertion the gateway then
verifies; [[auth.issuer]] decides whose authentication you accept, and
a federation issuer whose [[auth.federation]] table sets
contact_point = true relays a professional another Member State
authenticated.
3. Purpose of use
The purpose travels in the caller’s token, never in the query: the IHE IUA
extensions.ihe_iua.purpose_of_use claim, HL7 v3 PurposeOfUse codings, or
RFC 9396 authorization_details[].purpose_of_use. It is required by
default: a token that declares none is a 403 (purpose-of-use-required)
on every route that reaches a node (Purpose of use).
The gateway relays every purpose the token declares to each node in the
purpose_of_use claim of openEHR-federation-client, so the node makes its
release and consent decision against it (N26, N27).
An oauth section under security_profile = "fapi2" states a purpose to
that node’s authorization server as
well: the purpose_of_use, and for the query use case the
subject_organisation_type, of its configured authorization_details
(Annex B §B.4a.3). They are configured per endpoint and are the same for
every request to it, a declaration of the gateway’s organisation for the
token, never derived from the caller. The caller’s own purpose still
reaches the node in openEHR-federation-client
(The FAPI 2.0 grant).
What changes the answer: trust.purpose_of_use.required = false admits a
token with no purpose. A deployment that sets it records why here, because
its nodes then decide without one.
4. What the token is bound to
The specification asks two separate questions here.
Bearer or sender-constrained. Bearer by default at both hops, and
sender-constrained toward a node where you configure it. The caller’s token
is a bearer token whose audience is the gateway, and it reaches no node, so
it cannot be replayed at one; under token exchange it reaches the node’s
authorization server as the subject of the exchange. The token the gateway
obtains from a node’s authorization server is a bearer token, kept until 30
seconds before it expires and audience-restricted when the oauth section
names a resource (RFC 8707) or an audience, unless the section names a
dpop_key_file: the token is then bound to that key with DPoP (RFC 9449),
and a node can refuse it from anyone who does not hold the key
(Tokens bound to a key). The
openEHR-federation-client token lives 60 seconds, names one node as its
aud, and carries a fresh jti, so a node that records jti values can
refuse a replay within that window. An oauth section with
certificate_bound = true takes only tokens bound
to the gateway’s TLS client certificate (RFC 8705 §3), sends them only over
connections that present it, and refuses one whose stated binding names
another certificate
(Mutual TLS to a node).
A nuts section binds every token with DPoP, as the Dutch
binding’s Nuts track requires
(The Nuts grant), and so
does an oauth section under the FAPI 2.0 profile, with DPoP or with the
certificate, since the FAPI
2.0 Security Profile issues only sender-constrained tokens
(The FAPI 2.0 grant).
Transport identity. Toward the gateway, a transport identity is never
read as an organisation’s identity. TLS protects the connection: outside the
development profile, a credential or a patient identifier is sent only over
https. A client certificate, where your proxy asks for one, identifies a
connection and not an organisation; the organisation is the one the
verified token names. Toward a node, the gateway’s own certificate is read
as its organisation’s identity only where you declare it, with
client_auth = "tls_client_auth" or "self_signed_tls_client_auth" in that
node’s grant: the node’s authorization server then authenticates the
gateway as the client registered for that certificate (RFC 8705 §2).
Otherwise a certificate the gateway presents to a node, and the XCPD
binding’s mutual TLS to a responding gateway, authenticate the connection
alone. Record here, per node, which of the two you chose.
What changes the answer: the resource, audience, client_auth,
dpop_key_file, certificate_bound and security_profile keys of an
oauth section, tls.client_identity_file in a node’s section, and your reverse
proxy’s TLS settings.
5. What the technique does not cover
Addressed technically, by the gateway:
- no directly identifying patient identifier reaches a node, in the query, the path, a header or the conveyed caller claims (§5.4.1, N33);
- the caller’s token is never forwarded, and no node is sent a request without its configured onward credential and the caller’s identity;
- every caller is verified before anything is read, and every failure to verify fails closed;
- the gateway’s keys rotate with an overlap window, and each node verifies against the published key set;
- consent is never inferred from localization: each node makes its own consent decision, whatever a pre-filter answered (§13.2, N27);
- a
patient/grant admits nothing unless you bind its issuer to one member (Patient grants). Bound, the grant reaches only the patient’s own{node, ehr_id}pairs, as your cross-reference service links them to the token’sehrId: a wrong link there admits the wrong EHR at that member, so record which issuers you bind and why you trust that link. A deployment that instead issuesuser/orsystem/scopes to patient users widens what those users can reach to everything those scopes cover; record that choice here.
Addressed by agreement, outside the gateway:
- admission: who may join, and on what evidence (Admitting a node; §12b);
- the trust list of caller issuers, and what each issuer may assert;
- the authentic register of organisation identifiers, and which issuer may speak for which organisation;
- audit and supervision while a member participates;
- logging and liability after the fact. The gateway logs each request by its own request id, route and status, never a body or a header value (Request ids); each node audits who asked from the conveyed token (N24).
Bearer-token replay (§13.4 asks a deployment crossing organisational boundaries to consider it): the caller’s token cannot be replayed at a node, because it never leaves the gateway; a stolen onward token or conveyed token can be replayed at its own node until it expires. Record whether that is acceptable in your setting.
What changes the answer: a consent pre-filter
(Consent) adds a check before dispatch, never in
place of the node’s own; consent_refusal_codes in the registry decides
which node refusals are reported consent-denied;
[federation.consent] disclose = false keeps a member the pre-filter
excludes out of what a client sees, for Regulation (EU) 2025/327 Art 8, and
OPTIONS {base}/ declares it
(Withholding consent exclusions).
No configuration turns off the identifier gate, caller verification, or the
rule that the caller’s token stays at the gateway.
Consent exclusions in the answer
The specification has a member a consent pre-filter excludes reported
consent-denied (N27a), which tells the requesting clinician that the
patient restricted access at that member. Regulation (EU) 2025/327 Art 8
says that fact “shall not be visible to healthcare providers”. The
specification does not list this among the §13.4 questions, but a
deployment under that regulation has to settle it and record the answer
with the others.
FerroFED’s default is the specification’s: consent-denied.
With [federation.consent] disclose = false, an excluded member is
reported as one the cross-reference does not know the patient at
(not-resolved), meta.federation.complete stays false, a read by subject
only an excluded member could serve answers 404 subject-unavailable, and
OPTIONS {base}/ declares federation.consent.disclose: false. The
pre-filter metrics still count every exclusion for the operator.
Every request a national contact point relays is served with
disclose = false whatever the setting: the healthcare provider it relays
is one to whom the restriction must not be visible either (Art 8, Art
11(5)), and OPTIONS {base}/ declares it under
federation.national_contact_point.
An emergency purpose and the pre-filter. Regulation (EU) 2025/327
leaves to the Member State whether the vital interests of the data subject
override a restriction or an opt-out (Art 8, Art 10(2), Art 11(5)). The
gateway never sets a consent decision aside on its own judgement.
[federation.consent] emergency = "apply", the default, keeps the
pre-filter for an emergency request too; "pass-to-node" asks every member
it denies all the same, so the node decides, and records each such member
(Consent). Record which you chose, and the legal
basis for "pass-to-node".
The operator’s template
FerroFED answers the questions above for the gateway. These parts only your
deployment can answer. Copy this, replace every …, and publish it with
your deployment’s documentation.
§13.4 deployment decisions: <federation id>, <date>, <who decided>
1. Identity across the trust boundary
Authentic source of organisation identity: … (for example the URA
register of CIBG in the Netherlands, Annex B §B.4a)
Issuers on the trust list, and the organisations each may assert: …
Each node's authorization server, and the client_id it registered for
the gateway: …
2. End-user authentication
Who authenticates the clinicians of each calling organisation: …
The assurance level each issuer's tokens state, and the least level
required for patient data ([auth.issuer.assurance]): …
National contact points ([[federation.contact_point]]), the entity of
each Member State that authenticates its professionals (2026/2099
Art 6(1)), and the country and issuing authorities configured for each: …
Token mode or edge mode, and for the edge mode which proxy: …
How each node uses the conveyed caller in its release decision: …
3. Purpose of use
trust.purpose_of_use.required: true | false, and if false, why: …
The purpose-of-use codes callers send, and their legal basis: …
4. Token binding
Bearer at both hops: accepted | not accepted, and why: …
Transport identity read as organisation identity: never (FerroFED's
answer); TLS terminated at: …
5. Risk carried by agreement
Admission requirements and the evidence each member gives: …
Audit and supervision while a member participates: …
Logging, retention and liability: …
Bearer-token replay: acceptable | mitigated by …
Consent exclusions in the answer
Consent pre-filter: none | nl-gf-mitz, and its service: …
federation.consent.disclose: true | false, and the legal ground
(for example Regulation (EU) 2025/327 Art 8): …
What a node is told about the caller
The gateway authenticates to each node as itself, with that endpoint’s own
credentials (OAuth 2.0 to a node),
and tells the node who asks in a header of its own (§13.1, N24, N25, §12.4).
Where a node’s authorization server exchanges tokens (RFC 8693), the gateway
also asks it for a token per caller, with the caller’s verified token as the
subject and only the caller’s scopes that cover the operation; the caller’s
token reaches that authorization server alone, never the node, and a caller
the edge mode asserted has no token to exchange, so that node is
node-error with nothing sent
(A token per caller).
Every request it sends a node carries openEHR-federation-client: the
federated query, every routed read and write, a definition request, the
ask-all probe and the read of an EHR by subject. The value is a compact JWS
the gateway signs for that one node with the current key of [signing],
the key whose public half it publishes at {base}/.well-known/jwks.json and
declares as federation.auth.jwks_uri in OPTIONS {base}/. Its JOSE header
names alg, ES256 for a P-256 key and ES384 for a P-384 key, the
key’s kid and typ openehr-federation-client+jwt. Its claims:
| Claim | Value |
|---|---|
iss | the gateway: the client_id of the endpoint’s oauth grant, the iss of its client assertions there, or else federation.id |
aud | the node’s endpoint id in the registry |
iat, exp | seconds since the epoch; exp is 60 seconds after iat |
jti | a fresh version 4 UUID per token |
sub | the caller’s sub, as the gate verified it |
iss_upstream | the issuer that vouched for the caller |
verified_by | signature, introspection, or edge for an identity the edge asserted |
subject_organization_id | the caller’s organisation, when its token names one (IHE IUA) |
purpose_of_use | each purpose of use the token declares, as {"system", "code"} (IHE IUA, HL7 v3 PurposeOfUse) |
scope | the caller’s scopes as granted; under a patient grant, only the patient/ scopes that cover the operation |
ehrId | under a patient grant only: the patient’s own ehr_id at this node (SMART on openEHR master04 §Capabilities) |
acting | person when the caller’s sub names a natural person, client when it names a client application (Professionals and assurance) |
subject_name | the professional’s name, when the caller’s token states extensions.ihe_iua.subject_name (IHE IUA) |
national_provider_identifier | the professional’s identifier from their national authority, when the caller’s token states it (IHE IUA) |
national_provider_identifier_authority | the name of the agency that issued that identifier, when the caller’s issuer’s [[auth.attributes]] table names its "professional.issuing_authority_name" source and the token states it (Implementing Regulation (EU) 2026/2099 Annex Table 1 issuing_authority_name) |
subject_role | the professional’s roles, each as {"system", "code"}, when the caller’s token states extensions.ihe_iua.subject_role (IHE IUA) |
assurance_level | low, substantial or high, the level the caller’s authentication reached, when its issuer declares [auth.issuer.assurance] and the token states a value it maps |
national_contact_point | for a caller a national contact point vouched for: health_professional, the rows of Implementing Regulation (EU) 2026/2099 Annex Table 1 the eHDSI assertion carries under their data identifiers, with on_behalf_of when stated, healthcare_provider, the provider’s identifier and issuing authority from Table 2, and asserted_by, the contact point’s issuer, which marks them as its assertion; never a name or an address |
With subject_organization_id, the provider’s identifier, the professional
claims let a node record the professional and the provider of every
registration or update it makes on the caller’s behalf, which Regulation (EU)
2025/327 Art 13(4) asks an electronic health record to identify. Name the
"professional.issuing_authority_name" source in the [[auth.attributes]]
table of an issuer whose tokens state the agency that issued the identifier.
The token never carries the caller’s own token, its client_id, or a
patient identifier: an IUA person_id is never read (N33). The outbound
gate reads every caller claim as it reads the rest of a request, and a
request whose caller claims would carry the identifier its query was
resolved on is refused with nothing sent, logged as a security event and
answered 500 (§5.4.1). Under client credentials the node’s grant to the
gateway is a system/ scope, so the caller’s narrower scopes travel in
scope for the node to apply (N26).
A request that reaches the dispatcher without a verified caller is the
gateway’s own failure: it is answered 500 and no node is sent anything.
The gateway’s own requests for its operator, the
admission check and the redistribution of a held stored
query on the admin listener, carry the header with the gateway as sub and
no caller claim.
[signing] is therefore required whenever a registry is configured, by
registry.document or by [registry.mcsd]: a federating gateway without a
signing key refuses to start, naming the key
(Signing keys). The
specification leaves end-user conveyance open (§13.1), so the header and its
claims are FerroFED’s own design.
Verifying it at the node
A node that admits FerroFED reads the header before it decides what to release, and audits who asked:
- At admission, record the gateway’s
jwks_uri(itsOPTIONS {base}/declares it asfederation.auth.jwks_uri) and theissit will see: theclient_idthe node’s authorization server registered for the gateway, or the federation id where the gateway sends no OAuth 2.0 grant. - Read exactly one
openEHR-federation-clientvalue; refuse a request with none or with more than one. - Check the JOSE header:
typisopenehr-federation-client+jwtandalgis thealgthe gateway’s JWK Set publishes for the key the header names,ES256orES384. Refuse any other algorithm,noneincluded (RFC 8725 §3.1). - Verify the signature with the key of the gateway’s JWK Set whose
kidthe header names. Fetch the set again when thekidis unknown: during a rotation the set publishes the current and the previous key. - Check
issagainst step 1,audagainst the node’s own endpoint id, andexp, allowing a few seconds of clock skew (RFC 7519 §4.1.4). - Where the gateway authenticates with an OAuth 2.0 grant, check that
issequals theclient_idof the access token on the same request. - Apply
scopeandpurpose_of_useto what you release, confine apatient/scope to the EHR theehrIdclaim names, and recordsub,iss_upstream,verified_byandsubject_organization_idin your audit trail, withnational_contact_pointwhere a contact point relays the request: its professional and provider are the contact point’s assertion, whichasserted_bynames. Consent stays your own check (§13.2, N27).
Onward credentials
How the gateway authenticates to each node it sends a request to (§13.1,
N25). Each endpoint’s [credentials."<id>"] section names one scheme. Its
nested [credentials."<id>".credentials] table is ferrohealth-smart’s
credentials table: a user and a password
(Configuration), or the oauth grant below.
Beside that table the section takes the nuts grant, the node’s tls and
onward_organisations. A grant obtains a token at the node’s authorization
server before the request:
credentials.oauth: the client-credentials grant offerrohealth-smart, the table every FerroHEALTH product reads for an upstream it calls as itself. It binds its tokens to a key or to the TLS client certificate where you ask for it, holds to the FAPI 2.0 Security Profile withsecurity_profile = "fapi2", and takes an optionalonwardtable beside it that exchanges each caller’s token for one of the node’s own (The onward lane of a node);nuts: the Nuts track of the Dutch Generic Functions (Annex B §B.4).
Until 0.0.19, user, password, password_file, oauth and onward sat
in the section itself; this release refuses each there, naming its key in
the nested table (see the upgrade notes).
A section names one of them; two in one section refuse the configuration.
The oauth2 and fapi2 tables of earlier releases, and the section’s
mtls_alias_hosts key, are refused at load, naming the key of the oauth
table that replaces each (see the upgrade notes). A node’s section may also
name the TLS client certificate the gateway presents to that node and to
its authorization server
(Mutual TLS to a node). No log line,
error or rendering carries a credential, an assertion, a key, a proof or a
token.
OAuth 2.0 to a node
An oauth section makes the gateway authenticate to that node as itself
(§13.1, N25). Before a request, it asks the node’s token endpoint for an
access token with the client-credentials grant (RFC 6749 §4.4), and sends
the token as Authorization: Bearer (RFC 6750 §2.1):
[credentials."cdr-c".credentials.oauth]
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token" # or issuer, read from its RFC 8414 metadata
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt" # or tls_client_auth, self_signed_tls_client_auth
private_key_file = "/run/secrets/cdr-c-client.pem"
# assertion_alg = "ES256" # by the key type when unset
scope = "system/aql-*.s system/composition-*.cru"
# resource = "https://cdr-c.example.org/openehr" # RFC 8707
# audience = "cdr-c"
client_auth = "private_key_jwt", the default mechanism of §13.1, signs a JWT client assertion (RFC 7523 §2.2) with the key inprivate_key_file; the gateway publishes its public half in its JWK Set (Signing keys and the JWK Set), under the RFC 7638 thumbprint as itskidunlesskidnames another. The assertion namesclient_idas itsissandsub, carries a freshjtiand lives 60 seconds.tls_client_authandself_signed_tls_client_authauthenticate by the TLS client certificate instead (Mutual TLS to a node).- A node’s grant takes no client secret:
client_secret_basic,client_secret_post,client_secretandclient_secret_filerefuse the configuration, naming the key. The gateway authenticates to a node by its own key or certificate (§13.1, N25); a client secret is for an identity service’s grant (Identity resolution).client_authhas no default a node takes, so set it. scopeis space-separated SMART on openEHR scopes, each a resource scope of thesystemcompartment (system/aql-*.s,system/composition-*.cru); anything else is refused at load. The token request carries it as written (RFC 6749 §3.3).token_endpointis anhttpsURL with no user name, password or fragment (RFC 6749 §3.2; RFC 9110 §4.2.4);httpis accepted only underprofile = "development"(What must travel encrypted). Withissuerin its place, the token endpoint is read from the issuer’s RFC 8414 metadata, andissueris held to the same rule.assertion_audienceistoken_endpoint, the default, orissuer. RFC 7523 §3 admits either as the assertion’saud. An authorization server under the FAPI 2.0 Security Profile accepts only its issuer identifier, as one string (§5.3.2.1).issuerneeds the table’sissuerset, which then replacestoken_endpoint: the two exclude each other.refresh_margin_ms, 30 seconds by default, is how long before the token expires a new one is fetched;timeout_msbounds each call to the authorization server.
ferrohealth-smart caches the token until refresh_margin_ms before the
end of the lifetime its expires_in states, or for half that lifetime when
it is shorter than twice the margin, with one token request per endpoint at
a time. A token with no stated lifetime is reused until a node refuses it.
A node that answers 401 drops the cached token, and the next request
obtains a new one. When no token can be obtained, that node is reported
node-error and is sent nothing: the gateway never dispatches
unauthenticated. The answer says only
no onward credential could be obtained, so nothing was sent, followed by
the token endpoint’s RFC 6749 §5.2 error code when it refused with a
registered one. The full account, the token endpoint’s description
included, goes to the log at warn with the endpoint and the request id.
The admission check authenticates the same way.
The caller’s own Authorization header never reaches a node.
A token per caller: token exchange
An onward table beside the node’s oauth table gives each verified
caller a token of its own at that node, where the node’s authorization
server supports RFC 8693. Set actor_assertion = true to name the gateway
as the actor, as the oauth2 token exchange of earlier releases always
did:
[credentials."cdr-c".credentials.oauth]
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token"
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt"
private_key_file = "/run/secrets/cdr-c-client.pem"
scope = "system/aql-*.s" # the gateway's own requests
[credentials."cdr-c".credentials.onward]
issuer = "https://auth.cdr-c.example.org" # the issuer of the exchanged tokens
kind = "persons"
profile = "rfc9068"
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token"
audiences = ["https://cdr-c.example.org/openehr"]
indicators = ["https://cdr-c.example.org/openehr"] # sent as resource (RFC 8707)
actor_assertion = true
[credentials."cdr-c".credentials.onward.keys]
max_onward_lifetime = "1h"
For a request on behalf of a caller, the gateway sends the token endpoint
the caller’s verified access token as subject_token, an assertion of its
own as actor_token, with its own jti, and its client authentication as
above. The actor assertion is signed with the oauth table’s
private_key_file whatever client_auth says, so a grant that
authenticates by its certificate still names itself as the actor. The
exchange names the node with each of indicators.
- Where the
onwardtable sets noscope, the exchange asks for the caller’s granted scopes that cover the operation, and never the rest (N26). A request that no granted scope of the caller covers, such as one with no SMART on openEHR family or a demographic request, is never exchanged: the node isnode-errorand is sent nothing. Withoutscopethe authorization server would choose the scope itself (RFC 8693 §2.1). Ascopeset on theonwardtable, such as the node scope an Entra issuer needs, is asked for as written instead. - The answer must issue an access token or a JWT (
issued_token_type, RFC 8693 §2.2.1) thatferrohealth-smart’s onward check accepts: a JWT namingissueras itsiss, anaudinsideaudiences, a lifetime withinmax_onward_lifetime, the gateway as itsactor client, and the caller’s person. An opaque exchanged token, or an answer that carries a refresh token, fails the node with nothing sent. - A token bound with
DPoPreaches the node under theDPoPscheme, its requests proven with theoauthtable’sdpop_key_file(Tokens bound to a key). - The exchanged token is cached per caller and target until shortly before
it or the caller’s token expires, at most 4096 per endpoint, and dropped
when the node answers
401, so the next request exchanges again. - Only a caller verified by its token’s signature or by introspection has a
token to exchange. A caller the edge mode asserted has none, so that node
is
node-errorand is sent nothing. - The gateway keeps the caller’s token only while some node has an
onwardtable, and never writes it to a log, a span, a metric or theopenEHR-federation-clienttoken. - A caller’s token whose text or claims carry the patient identifier the
query was resolved on is not sent to the token endpoint, and that node is
node-error(§5.4.1, N33). - The gateway’s own requests, the admission check and the redistribution of
a held stored query, have no caller: they use the
oauthtable’s client-credentials token, with itsscope.
The caller’s token reaches the node’s authorization server, which must
trust the caller’s issuer, and never the node itself. Which callers an
onward table carries, and the other profiles it runs, follow.
The onward lane of a node
An onward table beside a node’s oauth table makes every request a
verified caller originates reach that node under a token of the node’s own
issuer, obtained for that caller. It is the [<lane>.credentials.onward]
table of ferrohealth-smart, and the oauth table authenticates the gateway
at the onward issuer:
[credentials."cdr-f".credentials.oauth]
issuer = "https://as.cdr-f.example.org"
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt"
private_key_file = "/run/secrets/cdr-f-client.pem"
scope = "system/aql-*.s" # the gateway's own requests alone
[credentials."cdr-f".credentials.onward]
issuer = "https://as.cdr-f.example.org"
kind = "persons" # persons, federation or patients
profile = "keycloak" # rfc9068, entra or keycloak: picks the grant
audiences = ["cdr-f"] # what an issued token's aud may name
indicators = ["cdr-f"] # sent as resource or audience, by the profile
# scope = "..." # the Entra profile names the node here
# correlation_claim = "txn" # a chained federation route
[credentials."cdr-f".credentials.onward.keys]
max_onward_lifetime = "1h" # required
# The Keycloak profile only: the issuer's service accounts, whose sub is a
# user's; the profile refuses the table at load until you list them.
app_only = [{ subjects = ["service-account-cdr-f-batch"] }]
ferrohealth-smart runs the grant the profile picks: RFC 8693 token
exchange with the caller’s token as subject_token (with audience under
the Keycloak profile), or the RFC 7523 JWT bearer grant with the on-behalf-of
parameters under the Entra profile. It checks every issued token before use
(its issuer, its aud inside audiences, its lifetime against
max_onward_lifetime, the gateway as its client or act, and the caller’s
person), and caches it until shortly before it or the caller’s token
expires. A federation issuer adds a [credentials."<id>".credentials.onward.federation]
table whose exchanges_from lists the caller issuers it is trusted to map
and whose person_mapping names the subject-token claim the issued token’s
professional.hp_identifier must carry.
Which caller the lane carries depends on kind:
kind | Carried | Refused |
|---|---|---|
persons | a person of the node’s organisation, acting alone, through a client or for another | everyone else |
federation | a professional of another domestic party, and another domestic party’s organisation when the section opts in | everyone else |
patients | the patient, on a token naming the patient context | everyone else |
- A caller the lane does not carry, a system included, fails that node as
node-error, and nothing is sent to the token endpoint or the node. - A refused exchange fails that node as
node-error, naming the token endpoint’s error code. The gateway’s own token is never sent in its place, and a401from the node is never retried under it. - The gateway’s own requests, which no caller originates, use the
oauthtable’s token. - A caller the edge mode asserted, or a Basic caller, has no token to
exchange, so that node is
node-errorand is sent nothing. - A request with no named person, from another domestic organisation, is
carried only where the node’s agreement declares it: set
onward_organisations = truein the node’s[credentials."<id>"]section, beside anonwardtable of kindfederation. The issuer exchanges the organisation’s token for one naming the organisation alone, and the node is reached read-only: a request outside the ITS-RESTquerygroup and outsideGET,HEADandOPTIONSis refused403 operation-refusedbefore any token is asked for. Configuration refuses the switch without anonwardtable or on any otherkind. A national contact point is never carried as an organisation: its token always names a professional. - Under a
chainedagreement, setcorrelation_claimto the claim that holds the requester’s log correlation id. A caller whose verified claims do not carry it is refused before the token endpoint is asked. For an opaque token those are the claims its issuer’s introspection answer returned. The issued token must carry the same value, or that node isnode-error. - With the Keycloak profile, the caller’s token must name the gateway’s
client in
aud: give the inbound client scope an audience mapper that adds it, and list the gateway among the node’s delegates. Check this before the lane carries traffic.
Who commits through the lane
A node reached through an onward lane authenticates the onward token, so it
derives the committer of every commit from that token, and takes, where
ITS-REST requires a committer, one placeholder naming the token’s iss and
sub, which it recognises and discards (FerroEHR’s committer rules):
{"_type": "PARTY_IDENTIFIED", "identifiers": [{"_type": "DV_IDENTIFIER", "issuer": "<token iss>", "id": "<token sub>"}]}
- A
CONTRIBUTIONnames the placeholder of the onward token the lane obtained for the caller in itsauditand in every version’scommit_audit(ITS-REST 1.1.0UpdateAudit,UpdateVersion). The gateway writes these members alone and keeps the rest of the body as sent. - An attestation (
UpdateAttestation) takes the same placeholder for a person exchanged at apersonsissuer. Through afederationor apatientslane aCONTRIBUTIONthat carries an attestation is refused403 operation-refusedbefore any token is asked for, because the node refuses every attestation by an external person. - A header commit (
PUTorPOSTof a composition, anEHR_STATUSor a directory) is forwarded as the client sent it. The gateway adds no committer to itsopenehr-audit-details, and the node derives it.
The configuration refuses an onward table without its oauth table,
beside a user and password or another grant, on a service’s credentials,
and when ferrohealth-smart refuses it: no issuer, kind or audiences,
no max_onward_lifetime, a federation issuer without exchanges_from or
person_mapping, or a target its grant cannot ask for.
A FerroEHR node
FerroEHR from release 5.7.0 binds every person who signs in to a
demographic PERSON, and writes the committer of a person’s commit
itself, so it admits the gateway’s writes for a caller only under a token
that names that caller. FerroFED reaches such a node under the caller from
v0.0.20, the first FerroFED release that does, through an onward lane of
kind persons on each of the node’s endpoints, and a deployment adds the node’s persons issuers, the step
FerroEHR’s party binding page holds back for a gateway, once its gateway
runs that release with those lanes. The node’s side of the lane:
- the onward issuer is an
[[auth.issuer]]of kindpersonswhoseaudienceis the node’s own and is one of the lane’saudiences; - the gateway’s client is one of the issuer’s
delegates:{ client = "<client_id>" }where the issued token names the gateway as its client, or{ sub = "<client_id>" }where it names the gateway inact; - each person the lane carries binds to a
PERSONunder the issuer’s[[auth.party]]table, provisioned through SCIM or created at the first request (FerroEHR’s just-in-time creation).
The quickstart runs four such nodes, and the end-to-end lane holds that a person’s query reaches one under the exchanged token and fails there under the gateway’s own (Container).
Tokens bound to a key: DPoP
dpop_key_file in a node’s oauth section binds that node’s tokens to a
key of the gateway’s (RFC 9449), for a deployment that requires
sender-constrained tokens, the tokens its onward table exchanges included. The file holds a P-256 key, which signs ES256, or a P-384 key, which
signs ES384, in PKCS#8 PEM:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cdr-c-dpop.pem
Every request to that node’s URL, and to its token endpoint, then carries a
DPoP proof signed with the key: it names the request’s method and URL
without query and fragment, a fresh jti and the time, and, on a request
that carries the token, the token’s SHA-256 in ath. The token is sent
under the DPoP scheme. The token endpoint must answer token_type
DPoP; a bearer token is refused and the node is node-error. A token
endpoint that answers 400 use_dpop_nonce, or a node that answers 401
with a DPoP challenge naming use_dpop_nonce, is sent the request once
more with the nonce it gave, and every later proof to that server carries
the latest nonce it sent (RFC 9449 §8, §9). Each send to the token endpoint
has timeout_ms of its own.
The token endpoint’s nonce and the node’s are kept apart even when both
live on one host. A token request sent once more carries a newly signed
client assertion, and under token exchange a new actor token, so the
token endpoint never sees a jti twice. A node whose deadline passes
before its second send is time-out, and it counts as a node that was
asked.
The key is read at start and on each reload; a key that is no P-256 or
P-384 key refuses the configuration, naming the oauth section, and a
section with both dpop_key_file and certificate_bound = true refuses it
too: a token is bound one way.
Mutual TLS to a node (RFC 8705)
A node’s [credentials."<id>".tls] table names the TLS material the
gateway reaches that node with, by the keys the identity services take:
| Key | What it is |
|---|---|
client_identity_file | The gateway’s client certificate chain and its private key, PEM, the end-entity certificate first. client_identity takes the same inline, as a secret. |
trust_roots_file | A PEM bundle of trust roots, beside the platform’s, the node and its authorization server are trusted by. |
min_version | The oldest TLS version the gateway speaks to the node and its authorization server: "1.3" unless lowered to "1.2". |
The node and its authorization server are reached over one transport built
with that material, so every request to either presents the same
certificate. A node presented a certificate is reached over https alone,
under every profile: an http node URL refuses to start, and an http
token endpoint or issuer refuses the configuration. A section may name the
TLS material alone, with no scheme, when the node authenticates the gateway
by the certificate and asks for no Authorization header.
With that material, the section’s oauth table can authenticate by the
certificate in place of an assertion, and bind its tokens to it. The grant
presents the certificate the tls table names, read once: the oauth
table names no certificate of its own.
[credentials."cdr-f".tls]
client_identity_file = "/run/secrets/cdr-f-client.pem"
trust_roots_file = "/etc/ferrofed/cdr-f-roots.pem"
[credentials."cdr-f".credentials.oauth]
client_auth = "tls_client_auth" # or self_signed_tls_client_auth
token_endpoint = "https://auth.cdr-f.example.org/oauth2/token"
client_id = "ferrofed-gateway"
scope = "system/aql-*.s"
certificate_bound = true
client_auth = "tls_client_auth"authenticates the gateway by the certificate, which the authorization server validates against its PKI and matches to the subject it registered forclient_id(RFC 8705 §2.1);"self_signed_tls_client_auth"has it matched to the certificate it registered instead (§2.2). The token request then carriesclient_idand no client assertion. Anonwardtable withactor_assertion = truestill names the gateway as the actor, with an assertion of theoauthtable’sprivate_key_file(RFC 8693 §2.1).certificate_bound = truetakes only tokens bound to the certificate (§3). The token is sent underBearer(RFC 6750), and only over the endpoint’s transport, which presents the certificate it is bound to. Where the token states its binding, as acnfmember of the token response or thecnfclaim of a JWT access token, itsx5t#S256must be the SHA-256 thumbprint of thetlstable’s certificate (§3.1, §3.2); a token bound to another certificate is never cached or sent, and the node isnode-errorwith nothing sent. An opaque token states no binding, and the node’s authorization server and the node check it.- Either key without
tls.client_identity_filerefuses the configuration, and so does an identity file without a certificate and its key, namingcredentials."<id>".tls.client_identity.client_certificate_file,client_key_fileandca_filein theoauthtable of a section that names TLS material refuse the configuration, naming the key: one identity comes from one place.
The client identity is a secret no rendering shows; the settings and the
start-up log name the endpoints that present a certificate, never the
certificate or its key. A service’s own credentials section, under
[pixm.manager.endpoint] or [pdqm.endpoint] for example, takes no TLS
material: name it in the endpoint table’s tls.
The Nuts grant (Annex B §B.4)
A nuts section makes the gateway authenticate to that node on the Nuts
track of the Dutch Generic Functions, the regional realisation of §13.3
that Annex B §B.4 describes. The gateway is the holder: it presents its own
Verifiable Credentials, signed as a presentation with its did:web key,
and the node’s authorization server answers with a token bound to the
gateway’s DPoP key. The wire is Nuts RFC021, the VP Token Grant Type:
- The gateway reads the authorization server’s metadata at the RFC 8414
well-known URL of
authorization_server. The metadata must name that issuer exactly, atoken_endpoint, apresentation_definition_endpoint(RFC021 §5) andvp_formatsadmittingjwt_vpwith the holder key’s algorithm (RFC021 §3.1); when it listsdpop_signing_alg_values_supported, theDPoPkey’s algorithm must be among them. The token and definition endpoints must be on the issuer’s origin (its scheme, host and port), so the credentials go nowhere else, and an answer whose objects repeat a name is refused. Writeauthorization_serverin its canonical form, a lower-case host and no default port, since the metadata must name it as the same text. - It reads the Presentation Definition for
scopeand maps each[[credential]]to the input descriptor it names. A credential for a descriptor the definition lacks, a descriptor left unanswered when the definition has no submission requirements, or a submission requirement the credentials do not meet stops the request before any credential is sent. The authorization server evaluates each descriptor’s constraints against the credentials it receives (RFC021 §4.1); the gateway does not. - It signs a JWT Verifiable Presentation of every credential (VC Data
Model 1.1 §6.3.1):
issandsubthe gateway’sdid,kidthe DID URLkid,audthe issuer,nbfnow andexpfive seconds later, and a freshnonceandjti(RFC021 §4.2). A credential whoseexphas passed is never sent. - It posts
grant_type=vp_token-bearerwith the presentation asassertion, the Presentation Submission, thescopeand, when set,client_id, with aDPoPproof ofdpop_key_file’s key. A demanded nonce is answered once, with a new presentation, since RFC021 §4.4 refuses a presentation nonce seen before. - It takes the token only when its
token_typeisDPoP. The token is kept until 30 seconds before it expires and dropped when the node answers401, and every request to the node carries it under theDPoPscheme with a proof of the same key (Tokens bound to a key).
| Key | What it is |
|---|---|
authorization_server | The issuer identifier of the node’s authorization server (RFC 8414 §2). https outside the development profile. |
scope | The scope the authorization server maps to its Presentation Definition, space-delimited RFC 6749 §3.3 scope tokens. |
client_id | Optional; sent when the authorization server identifies its clients by one (RFC 6749 §3.2.1). |
did | The gateway’s did:web identifier, the holder of the credentials. |
kid | The DID URL of the holder’s key, <did>#<fragment>. |
key_file | The holder’s key, a P-256 (ES256) or P-384 (ES384) private key in PKCS#8 PEM. |
dpop_key_file | The key the tokens are bound to, as in an oauth section. Required: GF-Authentication sender-constrains every token (GFI-005). |
[[credential]] | One per credential: input_descriptor, the descriptor it answers, and file, the JWT-encoded credential, issued to did. |
The gateway serves its DID document, so the authorization server can
resolve the presentation’s kid and verify it (RFC021 §4.2; GFI-001). The
document sits where the did:web method resolves the DID: the path is
/.well-known/did.json for a DID with no path, and
/<segment>/…/did.json for one with path segments, so
did:web:gateway.example.org:nuts is served at /nuts/did.json. It is
served at that path whatever the base URL, with no client authentication,
like the JWK Set, as application/did+ld+json. Route
https://<host><path> of the DID’s host to the gateway. The document is
built from the holder keys the nuts sections of that did name and
nothing else: one JsonWebKey2020 verification method per key, its id
the kid, its publicKeyJwk the public half of key_file’s key,
referenced from authentication and assertionMethod. A key change in
key_file changes the served document on the next reload or restart, with
no file to edit.
Two nuts sections may share a did. If they name one kid, they must
hold one key. A DID whose document path would sit under {base}/v1/, or
two DIDs whose documents share one path, refuse the configuration, since
one gateway serves one document per path. A deployment whose DID names
another host still has to serve that host’s path from the gateway, through
its reverse proxy. The credentials are issued to the
gateway by their authoritative sources ahead of time (GFI-002); the gateway
reads them from their files at start and on each reload, and checks only
that each is a JWT credential, with a vc claim, whose sub is did.
No log line, error or rendering carries a credential, the presentation, a
key, a proof or the token: a refusal names the authorization server’s
error code and at most 256 characters of its description. An oauth
section and a nuts section for the same endpoint refuse the configuration.
The NVI’s Nuts grant
The same table under [nl_gf.nvi.nuts] authenticates the
gateway to the NVI, the Localization Service of
Dutch localization, as a data
user on GF-Authentication (the IG’s Localization page, GFI-004, GFI-005):
[nl_gf.nvi.nuts]
authorization_server = "https://nuts.example.org/oauth2/nvi"
scope = "nl-gf-localization"
did = "did:web:gateway.example.org"
kid = "did:web:gateway.example.org#key-1"
key_file = "/run/secrets/nuts-holder.pem"
dpop_key_file = "/run/secrets/nvi-dpop.pem"
[[nl_gf.nvi.nuts.credential]]
input_descriptor = "organization_credential"
file = "/run/secrets/organization.jwt"
The token is obtained and cached as a node’s is, each token request bounded
by the localization’s timeout_ms. Every search carries it under the DPoP
scheme with a proof of dpop_key_file’s key over the search URL without its
query, so the pseudonym never reaches a proof or the authorization server
(RFC 9449 §4.2, §7.1). The grant rides on the outbound client of
[nl_gf.nvi.endpoint] as its authorization, so that table names no
credentials beside it. A 401 from the NVI drops the token and the search
is sent once more with a fresh one; a nonce it demands is kept and the search
sent once more with it (RFC 9449 §9). A second 401 fails the search. A grant the authorization server
refuses leaves the localization unavailable, so the query fails closed
(§14.1). The token goes to the NVI alone, never to a node, a log line or an
error. authorization_server must be https outside development, or boot
is refused, naming its key. The NVI’s credentials take no oauth grant:
the IG defines none for the Localization Service.
The FAPI 2.0 grant (Annex B §B.4a)
security_profile = "fapi2" holds a node’s oauth grant to the FAPI 2.0
Security Profile, for a node whose authorization server follows it, as the
BgZ/eOverdracht track of the Dutch binding does (Annex B §B.4a, a VWS memo
at concept v0.9). The track uses OAuth 2.0 client credentials with
private_key_jwt client authentication, sender-constrained tokens, and the
healthcare attributes in an RFC 9396 authorization_details object of type
nl-gis-v1 (§B.4a.2, §B.4a.3):
[credentials."cdr-e".credentials.oauth]
issuer = "https://as.cdr-e.example.org"
security_profile = "fapi2"
client_id = "urn:oid:2.16.528.1.1007.3.3.<URA>"
client_auth = "private_key_jwt"
assertion_audience = "issuer"
private_key_file = "/run/secrets/fapi2-client.pem"
dpop_key_file = "/run/secrets/cdr-e-dpop.pem"
scope = "system/aql-*.s" # optional when authorization_details is set
authorization_details = '''[{"type": "nl-gis-v1",
"purpose_of_use": "http://terminology.hl7.org/CodeSystem/v3-ActReason|TREAT",
"locations": ["https://cdr-e.example.org/openehr"],
"locations_organization_id": "urn:oid:2.16.528.1.1007.3.3.<URA of the node>"}]'''
A token per caller adds an onward table beside it, as
A token per caller shows, with the
issuer as its issuer and no token_endpoint, so the token endpoint is
discovered the same way.
- At the first request to the node,
ferrohealth-smartreads the authorization server’s metadata fromissuer, once, and keeps it; a read that fails is tried again on the next request. The metadata must beapplication/json, name that issuer exactly, and name atoken_endpointon the issuer’s origin; an answer whose objects repeat a name, or that is longer than 1 MiB, is refused. It must also list:private_key_jwtintoken_endpoint_auth_methods_supported, and the key’s algorithm intoken_endpoint_auth_signing_alg_values_supported(RFC 8414 §2: an omitted method list meansclient_secret_basicalone);client_credentialsingrant_types_supported, and token exchange for anonwardtable (an omitted list meansauthorization_codeandimplicitalone);- every
typeofauthorization_detailsinauthorization_details_types_supported(RFC 9396 §10); - the
DPoPkey’s algorithm indpop_signing_alg_values_supported, when the list is there (RFC 9449 §5.1).
- It asks the token endpoint for a token with the client-credentials
grant, or the
onwardtable exchanges each verified caller’s token. The client assertion is signed withprivate_key_file’s key, and names the issuer, as one string, as itsaud(FAPI 2.0 §5.3.3.1); the actor token of an exchange is signed the same way. The request carriesscopewhen set andauthorization_detailsas written. - It takes the token only when its
token_typeisDPoPand, underauthorization_details, when the answer states the details it granted (RFC 9396 §7). A refusal withinvalid_authorization_detailsis reported with that code. Every request to the node carries the token under theDPoPscheme with a proof ofdpop_key_file’s key (Tokens bound to a key).
The keys every grant takes, held to the profile:
client_authisprivate_key_jwt, ortls_client_authorself_signed_tls_client_authby the node section’s certificate (Mutual TLS to a node); underprivate_key_jwt,assertion_audience = "issuer"is required.- Every key signs PS256, ES256 or EdDSA, the algorithms §5.4.1 admits, so a
P-384 key cannot sign here: a P-256 key, an RSA key with
assertion_alg = "PS256", or an Ed25519 key. - Every token is sender-constrained (§5.3.2.1):
dpop_key_file, orcertificate_bound = truewith the node section’s certificate. scopeandauthorization_detailsare each optional, and one of them is required, so the server never chooses what a token may do (§5.3.3.1: least privilege).
The profile admits mutual TLS for both choices (§5.3.2.1). With
tls_client_auth or self_signed_tls_client_auth the metadata must list
that method in place of private_key_jwt, and the token request carries
client_id and no assertion. With certificate_bound = true the metadata
must state tls_client_certificate_bound_access_tokens as true (RFC 8705
§3.3), and the token is taken as Bearer and held to the certificate. A
grant that uses mutual TLS sends its token requests to the
token_endpoint of mtls_endpoint_aliases where the metadata names one
(RFC 8705 §5), held to the issuer’s origin like the other endpoint.
RFC 8705 §5 lets a server put that alias on another host, as its own
example does with mtls.example.com. The gateway sends there only when you
name the host in the oauth table:
[credentials."cdr-e"]
tls = { client_identity_file = "/run/secrets/cdr-e-client.pem" }
[credentials."cdr-e".credentials.oauth]
issuer = "https://as.cdr-e.example.org"
security_profile = "fapi2"
client_auth = "tls_client_auth"
certificate_bound = true
mtls_alias_hosts = ["mtls.cdr-e.example.org"] # or "mtls.cdr-e.example.org:8443"
# ... as above
Each entry is a host name, or a host name and a port, in lower case and
without a scheme or a path; port 443 is left out. An alias on a named
host is taken over https alone, without userinfo or a fragment. An alias
on any other host is refused before a token request is sent, as the
top-level token_endpoint on another origin always is. The key is refused
for a grant without security_profile = "fapi2", and for one that does not
use mutual TLS, since no other grant reads mtls_endpoint_aliases. Which
hosts to trust is FerroFED’s own design: no specification names them.
The gateway publishes the public half of private_key_file’s key in its
JWK Set, beside the [signing] keys, and the authorization server
verifies the assertion against it (§5.4.2; Annex B §B.4a.2). To make the
two keys:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out fapi2-client.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cdr-e-dpop.pem
To rotate the client key, make a new P-256 key, set it as
private_key_file, move the old one to previous_private_key_file, and
reload or restart. The new key signs every assertion from then on, and the
JWK Set publishes both keys, the new one first. An authorization server
that still holds the JWK Set from before the rotation does not know the
new key, so it refuses the new assertions until it fetches the set again;
the set it fetches then still holds the old key, so an assertion the old
key signed before the rotation verifies too. Once every authorization
server that verifies the grant has fetched the new set, remove
previous_private_key_file, or set previous_key_overlap_ms to have the
old key withdrawn on its own. next_private_key_file publishes a key
ahead of the rotation that never signs. A previous key the profile does
not admit, or that is the current key, refuses the configuration.
private_key_file = "/run/secrets/fapi2-client-2026-10.pem"
previous_private_key_file = "/run/secrets/fapi2-client.pem"
What the gateway does not do on this track:
- An authorization-endpoint flow. The authorization code grant needs a
user agent to redirect, with pushed authorization requests (RFC 9126) and
PKCE, which FAPI 2.0 requires of those flows (§5.3.2.2, §5.3.3.2). A
server-to-server gateway has no user agent, and the
oauthtable runs the client-credentials grant alone. The client-credentials grant is admitted under the profile’s general requirements (§5.3.2.1 Note 2). - Purpose of use per caller.
purpose_of_useandsubject_organisation_typetravel as the configuredauthorization_detailsof the endpoint, the same for every request, as a declaration of the gateway’s organisation (§B.4a.3). Record that answer to §13.4 for the deployment (The §13.4 deployment decisions). - Verifying the access token. The issuing organisation signs the access token (§B.4a.2); the node verifies it, not the gateway.
Signing keys and the JWK Set
[signing] holds the gateway’s signing keys: P-256 or P-384 private keys
in PKCS#8 PEM, each read from a file. The curve decides the algorithm: a
P-256 key signs ES256 and a P-384 key ES384 (RFC 7518 §3.4), and the JWK
Set publishes each key with its alg. It is required whenever a registry
is configured, by registry.document or by [registry.mcsd]: every
request to a node carries the caller’s identity in an
openEHR-federation-client token signed with the current key
(What a node is told about the caller;
§13.1, N24), and a federating gateway without the key refuses to start,
naming it. A grant’s client assertions are signed with the oauth table’s
own private_key_file, never with [signing]. A key on another curve
refuses the configuration, naming the key. To make a key:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out ferrofed-signing-key.pem
Choose P-256 when a node holds to the FAPI 2.0 Security Profile, which admits PS256, ES256 and EdDSA for a JWT and not ES384 (§5.4.1), so such a node may refuse an ES384 token. The one key then signs ES256 for every node:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ferrofed-signing-key.pem
The gateway serves its public keys as a JWK Set (RFC 7517) at
GET {base}/.well-known/jwks.json, with no client authentication, and
declares jwks_uri as federation.auth.jwks_uri in OPTIONS {base}/
(§13.1, N30). Point jwks_uri at that route on the gateway’s public
address, or at wherever your deployment publishes the keys. Each key’s kid
is its RFC 7638 thumbprint, so the same key always has the same kid. The
set also publishes the private_key_file key of every oauth grant, with
its previous and next keys, after the [signing] keys; a reload that
changes one publishes the new key.
Rotating the signing key
[signing] holds up to three keys, and the JWK Set publishes each with its
own kid:
| Key | Signs | Published |
|---|---|---|
key_file | every token | always |
previous_key_file | never | for rotation_overlap from the start of the process |
next_key_file | never | always |
A node verifies a token by its kid (RFC 7515 §4.1.4) against the JWK Set
it fetched (RFC 7517 §5), and may hold that set for its cache time. A key
must therefore be in the set a node holds before any gateway signs with it,
and stay there until every token it signed has expired. The gateway gives
a token its [signing] keys sign at most 300 seconds; the
openEHR-federation-client token lives 60.
The procedure assumes that no node caches the JWK Set longer than
node_jwks_cache (3600 s by default). Set it to the longest cache time of
your nodes and their authorization servers; config check refuses a
rotation_overlap shorter than 300 seconds plus node_jwks_cache.
assertion_lifetime_s, which earlier releases took here, is refused at
load: no [signing] key signs a client assertion any more. The three steps hold for one gateway and for any
number of replicas behind one address:
- Publish the new key. Make the new key, copy it to every replica, set
it as
next_key_file, and restart the replicas one by one. Each restarted replica publishes the current key and the new one, and still signs with the current key. When the last replica runs the new configuration, waitnode_jwks_cachemore, so every node’s cached set came from a replica that publishes the new key. - Sign with it. Set the new key as
key_file, move the old one toprevious_key_file, removenext_key_file, and restart the replicas one by one. A restarted replica signs with the new key. During the restart every replica publishes both keys: a restarted one as current and previous, one not yet restarted as current and next. A replica publishes the previous key forrotation_overlapfrom its own start, so finish this rolling restart withinrotation_overlapminus the longest token lifetime: 3600 s with the defaults. Raiserotation_overlapbefore this step for a slower rollout. - Retire the old key. Once
rotation_overlaphas passed since the last replica restarted, no replica publishes the old key and no token it signed is still valid. Removeprevious_key_fileat your next restart.
The new key signs with its own algorithm, so a rotation can also move the
gateway from ES384 to ES256 or back. A next key on another curve than the
current key refuses the configuration, naming signing.next_key_file,
unless next_key_algorithm names the algorithm you mean it for:
[signing]
key_file = "/run/secrets/ferrofed-signing-key.pem" # P-384, signs ES384
next_key_file = "/run/secrets/ferrofed-signing-key-next.pem" # P-256
next_key_algorithm = "ES256" # the move to ES256 is intended
A next key that is already the current or the previous key is refused as
well. A change to [signing] takes effect only on a restart; a reload
reports it.
Queries and API areas
The settings a federated query runs under (completeness, timeouts, paging and aggregates), the stored-query registry, the DEMOGRAPHIC area and the template upload fan-out.
Completeness
A federated query is all-or-nothing by default (N37). If a node that was
asked does not answer, the query fails: 504 when the node timed out or was
unreachable, and 424 when it answered with an error. When both happen, the
answer is 504. A failing answer returns no rows, and its meta.federation
names every node with its status and complete: false. A member that does not
know the patient (not-resolved) clears complete and never fails the
query, and so does a consent-denied member, whether a consent pre-filter
dropped it or its node refused with a code the registry lists
(Consent). A member the cross-reference could not answer for fails
the query 424 (Identity resolution). A member that was never
in scope (excluded, not-localized) leaves complete alone. When
every endpoint is excluded, for example because every one is suspended, no
member is in scope and the request cannot be resolved to any destination: the
gateway answers 404 and asks no node (§11.2, §11.3).
A client can opt into best-effort for one request by sending
openEHR-federation-completeness: partial. The gateway then answers 200 with
the rows of the nodes that did answer, still names every other node with its
status, and sets complete: false. Sending all asks for the default
explicitly. Any other value, or the header given twice, is refused with a
400. Best-effort is offered by default, and you can withdraw it:
[federation]
best_effort = false # a request asking for partial is then refused with a 400
The gateway never quietly serves an all-or-nothing answer to a request that
asked for partial. The setting is named in the startup log line.
Timeouts
A federated query runs under two budgets (§11.5, N38): each node’s request may
take per_node_timeout, and the whole fan-out, resolution included, may
take overall_timeout. A node past either is abandoned and reported
time-out, so under all-or-nothing the query fails 504 with
meta.federation naming every node.
[server]
request_timeout = "30s" # must exceed overall_timeout by more than 1000
[federation]
per_node_timeout = "10s" # one node's request
overall_timeout = "25s" # the whole fan-out
A client can shorten the budget for one request with Prefer: wait=<seconds>
(RFC 7240 §4.3). A shorter wait replaces the overall budget, and the answer
names it in Preference-Applied; a longer one leaves the configured budget
in force. Only the first wait counts, a malformed one is ignored and never
refused, and wait=0 asks no node and reports each one time-out. The
budget in force is the one meta.federation.timeout reports.
The server’s own request_timeout answers 408 with an empty body, which
would drop that envelope. A gateway that federates therefore refuses to boot,
and config check refuses the file, unless server.request_timeout is
greater than federation.overall_timeout plus one second, the time the
gateway keeps for combining the answers. The refusal names both keys. The
defaults leave four seconds to spare. The one second is FerroFED’s own choice:
§11.5 promises an answer “within its declared overall budget, plus combining
time” and does not size the combining time.
Paging with OFFSET
A node’s rows k to k + n are not the federation’s rows k to k + n, so
the gateway never sends OFFSET to a node (§11.6.2, N39). By default it
computes the page exactly: for ORDER BY … LIMIT n OFFSET k it asks each node
for its first k + n rows with no OFFSET, merges them in the federation
order, and returns rows k to k + n. A node that returns its k + n rows
out of that order is reported node-error, as for LIMIT n.
The ITS-REST offset and fetch members select a window over the rows your
AQL selects: ITS-REST defines offset as “the row number in result-set to
start result-set from”. The window starts at the AQL OFFSET plus offset,
and holds the smaller of fetch and the rows the AQL LIMIT leaves after
offset. LIMIT 10 OFFSET 5 with offset 2 and fetch 3 returns rows 7 to
9. The window is then paged as the AQL clauses are, so the bound below holds
its skip plus its count.
The page is computed only where k + n is bounded. The gateway answers 400
for a page whose k + n is past max_offset_window (the message names the
bound), for an OFFSET with no LIMIT, and for an OFFSET with no
ORDER BY, which has no order to page through. You can lower or raise the
bound, or refuse every OFFSET past zero:
[federation]
offset_strategy = "bounded" # the default; "reject" answers every OFFSET > 0 with a 400
max_offset_window = 1000 # rows asked of one node for a page, k + n; 0 is refused
The strategy and the bound are named in the startup log line.
The rows read of one node
ITS-REST leaves the rows a node returns without fetch to the node, so a
node’s own default page could look like its whole answer. The gateway sends
every node an explicit fetch: the LIMIT its query carries, k + n for a
page, and for a query with no LIMIT the ceiling max_node_rows plus one. A
node that returns more rows than the ceiling may have more still, so the
gateway reports it node-error and never merges its rows: the query fails
424 under the all-or-nothing default, and under partial the answer is
incomplete and names the node (§11.4, §11.5). The page a node is asked for
is one row more than the ceiling, so the ceiling stays below the largest page
every member serves: 9999 by default, under the 10 000 rows FerroEHR serves
a page by default. A node that refuses the page size answers 400, which the
gateway reports as node-error naming the node’s own message. Raise the
ceiling for a federation whose nodes serve larger pages and whose unbounded
queries match more rows, or add a LIMIT:
[federation]
max_node_rows = 9999 # rows read of one node for a query with no LIMIT; 0 is refused
OPTIONS {base}/ declares it as paging.max_node_rows, and the startup log
line names it.
Aggregates across nodes
Each node answers an aggregate with its own value, so one row per node is
never the federation’s answer (§11.6.3, N14). By default the gateway
recombines the aggregate exactly: it sends the aggregate to every node, scoped
to that node’s ehr_id, and answers one row in your query’s columns.
COUNT(*)andCOUNT(path)are the sum of the node counts.SUMis the sum of the node sums, orNULLwhen no node holds a value.MINandMAXare the least or greatest node value, over numbers and complete date-times.AVGis asked of each node as theSUMand theCOUNTof its path, and answered as their quotient, orNULLwhen no node counts a value. The type of the input decides the type of the answer (AQL 1.1.0 §3.9.1.5), and it reaches the gateway as the type of the node sums. Over integers,AVGis an integer: the one nearest the exact quotient of the federation’s sum and count, with a tie going to the even integer, so5 / 2is2and7 / 2is4. The gateway rounds once, after it adds every node’s sum and count, and never rounds a node’s own mean. Over reals,AVGis the decimal quotient written as the nearest JSON number, so12.5 / 3is4.166666666666667. AQL states no rounding rule, so the rounding is FerroFED’s own.
Integers add exactly, and reals add in decimal arithmetic, so 0.1 + 0.2 is
0.3. A recombination over some of the nodes would be a wrong value, so:
- a node that answers a value the recombination cannot use exactly (a string
for
MIN, a real forCOUNT, more or less than one row) is reportednode-error, and the query fails424; - a node that does not answer fails the query
504, as for any query; - a request with
openEHR-federation-completeness: partialis refused400with the codepartial-aggregate.
COUNT(DISTINCT …), SELECT DISTINCT, and a column that is not an
aggregate beside the aggregates are refused 400
(indecomposable-aggregate), and the message suggests the two alternatives:
direct the query to one node, or select the rows and aggregate them in your
application. A query directed to one endpoint is sent to it unchanged.
You can narrow the functions the gateway recombines, or turn recombination
off, which refuses every undirected aggregate with undirected-aggregate:
[federation]
decomposable_aggregates = ["COUNT", "SUM", "MIN", "MAX", "AVG"] # the default; [] declares none
The list is named in the startup log line.
Stored queries
The gateway can hold stored queries itself, the federated stored-query
registry of §12.7 (N44). Setting [stored_queries] offers the registry, over
one of three backends that backend names:
backend | Where the definitions live | For |
|---|---|---|
redb, the default | the embedded store file path names | one gateway process |
postgres | FerroFED’s own PostgreSQL database, which [store] configures | several replicas behind one address |
files | read-only, one file per definition under the directory path names | instances that share definitions with no database |
What holds for every backend:
- A stored version must outlive the process, because clients invoke it by
name after a restart, and a second
PUTrefused before a restart is refused after it too (§12.7, N44). - The store holds each definition’s name, version, the instant it was stored
and its AQL, and nothing else. A definition names its patient through a
$parameter, never a literal, and the values a client binds when it invokes a query are never written, so no patient identifier reaches the store (§5.4.1, N33). - The registry needs the federation that runs its queries, so
[stored_queries]withoutregistry.documentrefuses the configuration, namingregistry.document. A key the chosen backend does not read, such aspathbesidebackend = "postgres", refuses it too, naming the key. OPTIONS {base}/declaresdefinition.stored_query_registry: truewhile the registry is offered, whatever the backend, andfalsewithout it; without it a stored-query definition request goes to the one node the targeting headers name, bothPUTs included, andGETandPOST /v1/query/{name}answer501.- The startup banner and the startup log line name the backend, and never
its path or connection string. Changing
[stored_queries]needs a restart.
One gateway: redb
[stored_queries]
path = "/var/lib/ferrofed/stored-queries.redb"
- The file is an embedded
redbstore, created at boot when it does not exist. Put it on a persistent volume. - One gateway process opens the file at a time. A second process pointed at
the same file refuses to start, so run one gateway per file. Several
replicas need the
postgresbackend. - The file records its schema version in a table of its own,
ferrofed_schema. At start an older file is migrated forward and keeps its definitions, and a file a newer FerroFED migrated refuses the start, naming both versions (Rollback).
Several replicas: postgres
[stored_queries]
backend = "postgres"
[store]
url_file = "/run/secrets/ferrofed/store-url"
- The definitions live in FerroFED’s own store, the one PostgreSQL database
[store]configures.backend = "postgres"without[store]refuses the configuration, namingstored_queries.backendand[store]. The connection string, its TLS, the migrations and the readiness indicator are the store’s (FerroFED’s own store). [stored_queries]takes no connection string.urlandurl_filethere refuse the configuration, naming the key and[store](Moving from[stored_queries] url).- The table is
ferrofed.stored_query_definition, the store’s first migration, so a database an earlier release’spostgresbackend wrote is taken over with every definition it holds. - Every replica shares the table, whose primary key is the name and the
version. Two replicas that store the same new version at once store exactly
one: one
PUTanswers200and the other409(stored-query-held), and both replicas then hold the winner’s text. A read or an invocation reads the table again before it answers, so a version one replica stored is served by every other. A database that fails a request answers it500and names nothing in the body; the log names the failure. config checkchecks the store’s connection string, and does not connect.
Read-only: files
[stored_queries]
backend = "files"
path = "/etc/ferrofed/stored-queries"
- Each definition is one file,
{path}/{qualified_query_name}/{version}.aql, mirroring its ITS-REST path, holding the AQL as UTF-8 text:/etc/ferrofed/stored-queries/org.example::patient_compositions/1.0.0.aql. A name and a version each have one spelling, so the layout holds one file per name and version. - The gateway loads every file at start and admits each as a
PUTwould: a definition that is not a federated query, or that names its patient by a literal, is refused. It holds the canonical print, and reports the file’s modification time as the instant it was stored. - Anything else in the directory refuses the start: a file at the top level,
a directory that is no qualified query name, a file that is not
major.minor.patch.aql, a nested directory, text that is not UTF-8, or an unreadable entry. The refusal names the file and never quotes its content.config checkloads the directory the same way. - A definition in the namespace
eu.ferrofed.eehrxf, which holds the gateway’s own read-only queries, refuses the start andconfig check, as a row there in aredbor PostgreSQL store refuses the start (the gateway’s own queries). - A
PUTanswers405(stored-query-read-only) withAllow: GET, OPTIONS, andOPTIONSon a definition path lists noPUT. To add or change a definition, add its file and restart. Distributing definitions needs aPUT, sofederation.fan_out_stored_queriesbeside this backend refuses the configuration.
The client contract says how a client stores and invokes a query.
Distributing stored queries to the members
The registry runs its own copy of every definition, so no member needs one. A deployment that wants each definition at the members too, for a node that runs it by name locally or for an audit at the point of execution, can have the registry distribute it (§12.7):
[federation]
fan_out_stored_queries = true # off by default
[stored_queries]
path = "/var/lib/ferrofed/stored-queries.redb"
- Distribution is a facility of the registry. Setting
fan_out_stored_querieswithout[stored_queries], or beside the read-onlyfilesbackend, refuses the configuration, atconfig checkand at start. - Only a stored-query
PUTthat asks for it is distributed, withopenEHR-federation-endpoint: *(every active member) or headers that name members. The registry stores the definition first; each named member is then sent the registry’s copy independently, within the request’s budget, and nothing is rolled back. APUTnaming no member stores at the registry alone. With the setting off, a stored-queryPUTor versionGETthat carries a targeting header is refused400(stored-query-fan-out-unsupported), so a client that asked for distribution never mistakes a plain store for it. - A definition that carries a
FROM ENDPOINTorORGANISATIONdirective is refused for distribution, because no node can run it. Store it without the header, and it runs federated. - A
GETof a stored version that names members reports, per member, whether its copy matches the registry’s. An invocation always runs the registry’s copy, never a member’s. OPTIONS {base}/declares the setting asdefinition.stored_query_fan_out,trueonly while the registry is offered. The setting is named in the startup log line, and changing it needs a restart.
The client contract gives the answers.
The DEMOGRAPHIC area
The gateway never federates the openEHR DEMOGRAPHIC API (§7a.1, N32): the
patient’s identity is resolved through the identity binding, never through a
node’s demographic store. By default every request under /v1/demographic/
answers 501 and no node is asked.
A deployment that keeps its demographics in one member may declare that member’s endpoint as the one that serves the area:
[federation]
demographic_endpoint = "hospital-a.demographic"
- The setting is the explicit choice of §12.6: one destination you fix and
OPTIONS {base}/declares (§7a.1, §12.6, N32). Every DEMOGRAPHIC operation ITS-REST 1.1.0 defines goes to that endpoint alone, whether or notopenEHR-federation-endpointnames it, through the same single-node path as a definition request: the query string and the declared headers are held to what the operation declares, the body travels byte-identical, and the node’s answer comes back as the node sent it, withopenEHR-federation-endpointandopenEHR-federation-system-idnaming the endpoint (§7a.3, N31). Nothing is probed, fanned out or sent to another member. - A targeting header may name the declared endpoint and no other. A header
naming another endpoint is a
400(targeting-conflict), several endpoints anendpoint-several, and*or an unknown id anendpoint-unknown. - The value must be an endpoint id of the registry. Any other value refuses
the configuration, naming
federation.demographic_endpoint, and so does setting it withoutregistry.document. A suspended endpoint answersno-destination. OPTIONS {base}/declaresits_rest.demographicasunsupported: 501without the setting, and asrouted-single-nodenaming the declared endpoint, with it. The setting is named in the startup log line.
Fan-out template upload
A template lives at the node it was uploaded to, so a deployment whose clients commit to any member needs the same template at every member (§12.6). By default the gateway routes every template upload to the one endpoint the request names, and the deployment keeps templates consistent another way: distributing them out of band, or through a shared template repository (§12.6, N43). The gateway can instead fan an upload out:
[federation]
fan_out_template_upload = true # off by default
- Only an ADL 1.4 or ADL 2 template upload fans out, and only when the
request asks for it, with
openEHR-federation-endpoint: *(every active member) or headers that select several endpoints. A plain upload still names its one node, and every other definition request still routes to one node. - Each member is sent the upload independently, within the request’s
per_node_timeoutandoverall_timeout. A member that accepts keeps the template: the gateway rolls nothing back. - The answer names each member’s outcome, and a partial success answers
207, never200(client contract). OPTIONS {base}/declares the setting asdefinition.fan_out_template_upload, andits_rest.definitionsays that an upload naming*or several endpoints fans out. The setting is named in the startup log line, and changing it needs a restart.
Configuration revisions
With config.ui = "managed", the gateway keeps its configuration as a
history of revisions in a store, and you save, compare, apply and roll back
revisions on the admin listener. With config.ui = "off", the default, none
of this exists: the gateway is configured by its file and the FERROFED__
environment alone, opens no store, and serves no revision route. No
specification governs this page: our own design.
What is built is the revision store and its API on the admin listener (#576), the members and stored queries the operator console manages, with their owners (#577, Console-managed resources), and the onward credentials and binding sections it manages, with write-only secrets, a confirmation after a change applies and a second operator’s approval before it applies (#578, Credentials and binding sections). Any revision exports as TOML, and TOML imports as a dry-run revision (#579, Export and import).
Turning it on
[metrics]
listen = "127.0.0.1:9464" # the admin listener, which serves the revisions
[config]
ui = "managed" # "off", the default, changes nothing
backend = "redb" # or "postgres", for replicas that share a store
path = "/var/lib/ferrofed/config-revisions.redb" # redb: the store file
# url_file = "/run/secrets/ferrofed/config-revisions-url" # postgres: the connection string, a secret
| Key | Default | What it sets |
|---|---|---|
config.ui | off | managed keeps the configuration as revisions on the admin listener |
config.backend | redb | redb, an embedded file one gateway process opens at a time, or postgres, one database every replica shares; it is never FerroFED’s own store of [store], which holds no configuration |
config.path | none | the redb store file, created when it does not exist |
config.url, config.url_file | none | the PostgreSQL connection string of the postgres backend, a URL or libpq key/value pairs; a secret, never logged, and its sslmode must be require when it carries a password over a network outside the development profile |
Under off, GET /admin/config answers 404 configuration-unmanaged, so a
client tells an unmanaged gateway from a wrong admin URL. Under off, any other key of [config] refuses to start, so a store you
meant to use is never silently ignored. Under managed, [metrics] listen
must be set, since the revision API is served on the admin listener.
What a revision holds
A revision is the whole configuration less its bootstrap core: the file and the environment as the gateway reads them, written as TOML text. The bootstrap core comes from the file alone and is never part of a revision:
| Bootstrap core | Why it stays in the file |
|---|---|
profile | it decides the transport policy every outbound URL is held to |
[server] | the client listener: its address, TLS, base path and limits |
[metrics] | the admin listener and its scrape authentication |
[auth] | client authentication, the operator scope among it |
[trust] | each issuer’s policy, the edge mode and the purposes of use client authentication holds a token to |
[config] | the revision store itself |
So a revision can neither lock you out of the admin listener nor lower the security floor the file sets. A revision that sets a key of the core is refused.
A revision never holds a secret value. Under managed, every secret
outside the core is written as its _file reference, such as
password_file, and the gateway refuses to start, and refuses a
revision, that writes one inline (password, client_identity,
client_secret, assertion, store.url, or a URL that carries a
password). The refusal names the key, never the value. A secret inside the
core, such as a signing key’s file path, may stay inline, since the core is never stored.
Comments are not kept: a revision is stored as TOML writes it.
Each revision records its number, the revision it was based on, where it
came from (file, operator, rollback or import), who made it (the file, or the
operator’s issuer and subject as their token names them), when, and why.
The file is a revision too
At start, and at each SIGHUP reload, the gateway reads its file and
environment and checks them as config check does. When the part a
revision holds differs from the newest revision the file gave, it is saved
as a new revision by the file and runs. When it does not, the revision
marked running keeps running, with the file’s bootstrap core beside it,
so a revision you applied survives a restart until you change the file.
Nothing ever writes back to the file.
Every revision is checked as config check checks the file
A revision is put beside the file’s bootstrap core, read as the
configuration, resolved, and checked as ferrofed config check checks a
file, the transport policy and the registry document included. One the
check refuses is refused with 422 configuration-invalid, and the message
carries the same error config check prints. Nothing is stored, and the
running configuration stays.
The routes
Every route is on the admin listener and is admitted only for a caller
whose token carries auth.product_scope, as the other admin
actions are (Who the admin listener serves).
Each write is recorded under ferrofed::security as an admin action, and
with the revision number as config-revision-saved,
config-revision-applied, config-rolled-back or
config-revision-imported.
| Route | Answer |
|---|---|
GET /admin/config | the newest revision’s number as head, and the running revision’s mark as current: its number, the one before it, whether it is applied or pending-restart with the keys a restart applies, who applied it and when, and the confirmation it awaits (awaiting) or received (confirmed); what the newest revision takes when it does not run, as review (Commit-confirm, Second-operator approval); and this replica’s own running, in_step and refused (Several replicas) |
GET /admin/config/revisions?offset=&limit= | one page of the history, newest first, at most 100 |
GET /admin/config/revisions/{id} | one revision |
GET /admin/config/revisions/{id}/diff/{other} | each dotted key that differs from id to other: added, removed or changed, with both values |
POST /admin/config/revisions | saves {"base": <newest>, "reason": "…", "toml": "…"}, 201 with the revision |
POST /admin/config/revisions/{id}/apply | applies the newest revision, with no body |
POST /admin/config/rollback | {"current": <running>, "reason": "…"}: saves the content of the revision that ran before the running one as a new revision, and applies it |
GET /admin/config/revisions/{id}/export, POST /admin/config/import | a revision as TOML, and TOML saved as a dry-run revision (Export and import) |
A save names the revision it is based on, which must be the newest (null
for none), and an apply names the newest revision. When another operator,
or another replica on a shared store, saved or applied first, the request
is refused with 409 revision-stale and nothing changes, so two operators
never overwrite each other. Read the revisions again and base the change
on the newest one.
An apply goes through the same path as a SIGHUP reload: the sections a
reload applies take effect at once, and a changed setting that takes a
restart is listed in needs_restart, marked pending-restart, and takes
effect at the next start. When the reload refuses the revision, the
running configuration and the running mark stay. A rollback is one step:
it restores the revision that ran before the running one, so a second
rollback returns to where you were. With no earlier revision it is refused
with 409 rollback-unavailable.
The other refusals are 400 body-invalid for a body that does not read,
whose parser message is never echoed, and 404 revision-unknown
(Gateway codes).
The running configuration
GET /admin/config/running answers the deployment-wide settings the
console’s configuration tabs show, under off as well as managed. It is a
read, admitted for auth.product_scope like the other reads. Each setting
carries:
| Member | What it says |
|---|---|
key | the dotted key, such as federation.per_node_timeout |
section | the tab it belongs to: federation, fan-out, consent, stored-queries, access-log, fhir or authentication |
value | the value this replica runs with; absent for a secret, and for a setting nothing sets that has no default |
source | where the value comes from: file, environment (a FERROFED__ override), revision (the revision this replica runs, under managed) or default |
default | the value the gateway takes when nothing sets it, when it has one |
restart | true when a change takes effect only at the next start of the replica |
next | for such a setting, the value the next start takes when it differs from the running one |
secret | for a secret, set and the file it is read from as reference; never its value |
editable | true when PUT /admin/config/settings may change it: under managed, a setting of one value that is no secret |
The authentication section lists who may call, which only the file sets
and no revision holds: auth.product_scope, each [[auth.issuer]] by its
issuer, kind, jwks_file and audience (a change waits for the next
start), each [[trust.issuer]] by its issuer, and each
national contact point’s
[[federation.contact_point]] by its issuer, correlation_header and
additional_countries. An entry is keyed by its position, such as
federation.contact_point[0].correlation_header, as a reload names it. None of them is editable, and a Basic user’s password hash
is not listed.
PUT /admin/config/settings takes {"base": <newest>, "reason": "…", "settings": {"federation.per_node_timeout": "3s"}} and saves a
revision on base with each setting changed, 201 with the revision. Each
value is read as an environment override is: TOML syntax, such as 3000 or
true, or the string it is. The revision is checked as config check
checks the file (422 configuration-invalid with its errors) and then
applied as any revision. A key that is not an editable setting is refused
400 body-invalid, naming the key and never the value. A list or a table,
such as access_log.templates, is changed by an
import.
Console-managed resources
Each member of the federation, with its organisation and its endpoints, and
each stored-query version has one owner, which GET /admin/config/resources
names:
| Owner | Resource | Changed by |
|---|---|---|
file | a member the registry document provides; a stored query of the read-only definition files | the file alone, read-only in the console |
directory | a member the care services directory of [registry.mcsd] provides | the directory alone, read-only in the console |
console | a member a revision holds under [[registry.members]] | the console, through a revision |
gateway | a stored query of the reserved namespace eu.ferrofed.eehrxf | nobody |
store | a stored-query version in the stored-query store | nobody: a held version is immutable (§12.7, N44) |
The console’s Members tab lists each member with its owner and state, and a console member is added on a form of its own (the console’s screens show every tab).
The Members tab in the dark theme
The member form in the dark theme
A member is owned whole. A console member is held in the revisions as one
[[registry.members]] entry and joined to the registry document’s members,
so a revision stays the whole configuration and nothing is written back to
the file. The configuration file and the environment never set
registry.members: a file that does refuses to start. Console members join
a registry document; with a care services directory, which is the whole
registry, they are refused.
[[registry.members]]
organisation = { id = "org-c", name = "Hospital C" }
node = { id = "node-c", system_id = "cdr-c.example.org", product = "FerroEHR", version = "4.3.3" }
endpoints = [
{ id = "node-c-pub", url = "https://cdr-c.example.org/openehr", connection_type = "openehr-rest-query" },
]
| Route | Answer |
|---|---|
GET /admin/config/resources | running and head, stored_query_registry (absent, read-only or writable), every member with its node, organisation, system_id, endpoints, owner, and for a console member its state and the member itself, and every stored-query version with its owner |
PUT /admin/config/members/{node_id} | {"base": <newest>, "reason": "…", "member": {…}}: saves a revision with the member in place of the console member of that node_id, or beside the others, 201 with the revision |
POST /admin/config/members/{node_id}/remove | {"base": <newest>, "reason": "…"}: saves a revision without that console member, 201 with the revision |
PUT /admin/config/stored-queries/{name}/{version} | {"aql": "…"}: stores the version in the stored-query store at once, 201 with it as an ITS-REST StoredQuery |
A member save or removal is not applied: preview the revision with
GET /admin/config/revisions/{running}/diff/{new}, then apply it, and roll
it back in one step like any revision. A console member’s state is
running, pending (saved, not applied), removing (the newest revision
removes it), or shadowed.
A change that names a member the file or the directory provides, by its
node_id, its organisation id, its system_id, an endpoint_id or an
endpoint’s base URL, is refused with 409 resource-file-owned, and the
removal of a node_id no console member has with 404 resource-unknown.
A stored-query version is admitted as a PUT to the ITS-REST definition
API is: a held version is 409 stored-query-held, the reserved namespace
409 stored-query-reserved, and the read-only definition files
409 resource-file-owned. A version is never edited or removed, so a
change is a new version.
At a SIGHUP reload whose file changed, the file’s new revision carries
the console members over from the running revision, so a console member
survives the reload. When the file later provides a member that shares an
identity with a console member, the file’s member runs, and the console’s
copy stays in the revision, reported as shadowed and logged by node_id;
remove it, or give it identities of its own. When the console members are
refused beside the file’s configuration, as when the file drops the
registry document they join, the file’s revision is recorded without them,
the refusal is logged by key, and the revision before it still holds them.
The operator console’s Configuration page shows all of this through the
first replica it is configured with: the running and the newest revision
with the newest one’s changes, the members and stored queries with their
owners, and the history, in tabs of their own. A console member is saved or
removed, the newest revision applied, the running one rolled back, and a
stored-query version stored, each removal, apply, roll back and store
confirmed in a step of its own
(The operator console). Its member
form writes the openehr-rest-query and hl7-fhir-rest endpoints, each a
row of its own fields.
Credentials and binding sections
Each onward credential and each binding section is a resource owned whole, as a member is:
| Resource | Table | config.approval kinds |
|---|---|---|
| the onward credentials of one endpoint | [credentials."<endpoint id>"] | onward-credentials |
| the PIXm resolver | [pixm] | identity-services |
| the PDQm demographics step | [pdqm] | identity-services |
| the XCPD localizer | [xcpd] | identity-services, localization |
| the Dutch Generic Functions, the NVI localizer and the Mitz pre-filter | [nl_gf] | identity-services, localization, consent |
| the localization failure policy | [federation.localization] | localization |
| consent disclosure | [federation.consent] | consent |
The Credentials and bindings tab in the dark theme
The binding sections a build lacks, by its binding-ihe and binding-nl
features, are not offered. A resource the configuration file sets is the
file’s: GET /admin/config/resources names its owner as file, and a
change to it is refused with 409 resource-file-owned. Every other one a
revision sets is the console’s, and the revision records it in its
console list. Client authentication ([auth]), the transport policy the
profile sets and the rest of the bootstrap core stay in the file alone,
so the console cannot lock you out or lower the security floor.
| Route | Answer |
|---|---|
PUT /admin/config/credentials/{endpoint_id} | {"base": <newest>, "reason": "…", "settings": "…", "secrets": [{"key": "…", "value" | "file": "…" | "clear": true}]}: saves a revision with the endpoint’s credentials as given, 201 with the revision |
PUT /admin/config/bindings/{section} | the same body, for pixm, pdqm, xcpd, nl_gf, localization or consent |
POST /admin/config/credentials/{endpoint_id}/remove, POST /admin/config/bindings/{section}/remove | {"base": <newest>, "reason": "…"}: saves a revision without it, 201 with the revision |
settings holds the resource’s keys less its secrets, as the table’s own
keys or as one TOML inline table: { oauth = { token_endpoint = "https://as.example.org/token", client_id = "ferrofed", client_auth = "private_key_jwt", private_key_file = "/run/secrets/ferrofed-client.pem", scope = "system/aql-*.s" } }.
Settings that name a secret or its _file reference are refused with
400 body-invalid, naming the key. GET /admin/config/resources lists
each credential and binding section under sections, with its owner,
its state for a console one (running, pending or removing), its
settings as one inline table for a console one, and its secrets, each
a key and the reference it is read from; and the binding sections this
gateway is built with under bindings.
At a SIGHUP reload whose file changed, the file’s revision carries the
console’s credentials and binding sections over from the running revision.
A credential or a section the file now sets is the file’s from then on:
its console copy is left out of the file’s revision, and the gateway logs
its table.
A reload applies the credentials, the binding sections whose reload
applies ([pixm], [pdqm], [xcpd], [nl_gf]), the consent reporting
of [federation.consent], and the localization policy of
[federation.localization] while node_selection and the budgets are
those the process started with; a change to XCPD’s audit takes a restart,
as before.
Secrets are write-only
A revision never holds a secret value, only the _file reference to the
file it is read from. A secret change names the secret’s dotted key inside
the resource, such as password or oauth.client_secret, and one of:
file: the absolute path of a file the gateway reads, which the revision references as it is;value: the secret itself, which the gateway writes to a new file inconfig.secrets_dir, readable and writable by the gateway’s user alone (mode0600), and references. Each value gets a file of its own, never one that exists, so an earlier revision that references the previous file still reads it after a rollback. The gateway never logs the value, never echoes it, and never returns it in a revision, a diff, an export or an error;clear: the secret and its reference are removed.
A secret the change does not name keeps its reference. Without
config.secrets_dir, a value is refused with 409 secret-store-unavailable
and nothing is saved; a reference still works. A save the gateway refuses
removes the files it wrote for it. The files of earlier values stay in the
directory for the revisions that reference them; remove one once no
revision you may roll back to references it.
[config]
ui = "managed"
path = "/var/lib/ferrofed/config-revisions.redb"
secrets_dir = "/var/lib/ferrofed/secrets" # an existing directory, on a volume every replica shares
Commit-confirm
An operator’s apply of a revision that changes an onward credential or a
binding section runs it awaiting a confirmation. GET /admin/config
shows the deadline and the resources it changed under current.awaiting.
Confirm it within config.confirm, 120000 ms by default, once the
gateway works with it:
| Route | Answer |
|---|---|
POST /admin/config/revisions/{id}/confirm | with no body: the mark with the confirmation, its operator and time under current.confirmed |
Unless an operator confirms it by the deadline, the gateway rolls it back:
it saves the revision that ran before as a new revision, made by the
gateway with the reason revision N was not confirmed by its deadline,
applies it, and records config-unconfirmed-rolled-back under
ferrofed::security. Each replica reads the deadline from the store’s
running mark with every config.poll read, so the first replica past
the deadline rolls it back and the others follow the rollback as any
applied revision. A gateway that starts with a revision past its deadline
rolls it back before it runs, and one that starts within the deadline keeps
waiting for the confirmation. A SIGHUP whose file changed while a
revision awaits its confirmation rolls that revision back first.
While a revision awaits its confirmation, another apply is refused with
409 confirmation-pending; confirm it or roll it back first. A
confirmation of a revision that does not await one, or past its deadline,
is refused with 409 confirmation-unavailable, and one from a caller with
no verified token, such as a loopback peer under the development profile,
with 403 operator-unverified. A rollback awaits no confirmation and takes
no approval: it restores what ran before. GET /admin/config shows what
the newest revision takes under review: the resources whose change awaits
a confirmation once it applies (confirms).
Second-operator approval
config.approval names the resources a revision changes only once an
operator other than its author approved it: a kind of the table above, or
one resource (credentials.<endpoint id>, pixm, pdqm, xcpd, nl_gf,
federation.localization, federation.consent).
[config]
approval = ["onward-credentials", "consent"]
| Route | Answer |
|---|---|
POST /admin/config/revisions/{id}/approve | with no body: what the revision takes, its approval resources and the approvals it holds, each with its operator and time, and whether it is approved |
An apply of a revision that changes such a resource is refused with
409 approval-required until an operator with another subject, at the
same or another accepted issuer, approved it. Its author’s approval is
refused with 403 approval-own-revision, the approval of a revision that
needs none with 409 approval-not-required, and one from a caller with no
verified token with 403 operator-unverified. The revision records its
author and the store records each approval with its operator, and both
are logged under ferrofed::security (config-revision-approved).
GET /admin/config shows the newest revision’s review, with its
author, its approval resources, its approvals and whether it is
approved.
| Key | Default | What it sets |
|---|---|---|
config.confirm | 120000 | how long an applied change to a credential or a binding section awaits its confirmation, from 1000 to 3600000 ms |
config.approval | none | the resource kinds and resources a revision changes only with a second operator’s approval |
config.secrets_dir | none | the existing directory a secret value is written to; without it the console sets a secret by a reference alone |
Each is read only under managed, refused under off, and takes a
restart to change.
Export and import
Any revision exports as TOML, and TOML imports as a dry-run revision, so a configuration moves between the console and version control (#579).
| Route | Answer |
|---|---|
GET /admin/config/revisions/{id}/export | the revision as TOML, Content-Type: application/toml, as the attachment ferrofed-revision-{id}.toml with Cache-Control: no-store |
POST /admin/config/import?base=<newest>&reason=<text> | saves the TOML body as a revision on base, 201 with {"revision": …, "diff": …}: the revision, and each key it changes against the running revision, as the diff route writes them |
The Import and export tab in the dark theme
The export is the revision’s TOML text under a comment header that names
the revision, who made it, when, and where it came from. It holds the whole
configuration a revision holds, the console’s members, credentials and
binding sections beside the file’s, and every secret as its _file
reference. A secret the console wrote to config.secrets_dir is exported as
the path of the file it wrote. The bootstrap core is not part of a revision,
so it is not exported, and the header says so: keep the core in your file.
An import takes a body of Content-Type: application/toml (or text/plain)
of at most 1048576 bytes, and is checked as every save is. It is recorded
with the origin import, by the operator who sent it, for the reason the
query gives, and it is never applied by the import itself. Read the diff in
the answer, then apply it with POST /admin/config/revisions/{id}/apply,
which takes the approval and the confirmation it needs as any revision
does. An import is refused, and nothing is saved, when:
| The import | Refusal |
|---|---|
| sets a key of the bootstrap core | 422 configuration-invalid, naming the key |
| holds a secret inline | 422 configuration-invalid, naming the key, never the value |
| sets a credential or a binding section the file provides otherwise than the file does, or leaves it out | 409 resource-file-owned |
holds a console member that shares an identity with a member the registry document or a directory provides, and that base does not hold as it is | 409 resource-file-owned |
| is based on a revision other than the newest | 409 revision-stale |
| is sent as another media type | 415 media-type-unsupported |
| names no reason, or is larger than 1048576 bytes | 400 body-invalid |
Each import is recorded under ferrofed::security as
config-revision-imported with its revision number. An export of a
revision imports as the same configuration: its revision holds the same
TOML, and its diff against the revision exported is empty.
On the Configuration page each revision of the history offers its download (Export revision N as TOML), which the console’s server reads from the first replica with your access token and answers as an attachment. The form Import a configuration as TOML takes the TOML pasted into it, saves it as a dry-run revision, and shows what it changes; apply it with the form Apply revision.
File-first and console-first
Two ways of working use the same revisions:
- File-first: the configuration lives in version control and reaches
the gateway as its file. Each
SIGHUPreload or start records the file as a revision by the file, and its credentials and binding sections are the file’s, read-only in the console. Use the console to read the history and the diffs, and to roll back. - Console-first: the file holds the bootstrap core and little else, and operators change the configuration on the Configuration page or the admin routes. Export a revision to keep it in version control or review it as a pull request.
To move from console-first to file-first, export the running revision,
copy its tables into your file beside the bootstrap core, and reload: the
file then sets those credentials and binding sections and owns them.
Console members are the exception: the file never sets
registry.members, so move each one into the registry document. Secrets
stay as the _file references the export names; keep those files in
place.
To move a credential or a binding section from file-first to console-first, export the running revision, remove the section from the file and reload, then import the export: the file no longer provides the section, so the import sets it as the console’s, and you apply it as any revision. A configuration kept in version control comes in the same way, as an import you preview and apply, as long as it leaves every resource the file provides as the file sets it.
Several replicas
With backend = "postgres", the replicas share the history and the
running mark, and a save or an apply on one replica is checked against
what every other replica wrote.
Each replica reads the running mark every config.poll, 5000 ms by
default. When the mark names a revision the replica does not run, because
another replica applied or rolled back a revision, or recorded its changed
file at a SIGHUP, the replica applies that revision through the same
reload path an apply takes. Within one poll of an apply, every replica
runs the revision. The watch reads the mark with an ordinary query; it does
not use PostgreSQL LISTEN/NOTIFY.
A replica can refuse a revision another replica applied, for example when
a file the revision names, such as the registry document, is missing or
invalid on that replica. The replica then logs the refusal, counts it in
ferrofed_config_revision_follows_total{result="refused"}, and keeps its
running configuration. It does not try the same revision again. Fix the
cause, then send it SIGHUP or apply a newer revision.
GET /admin/config on each replica says where that replica stands:
| Member | Meaning |
|---|---|
running | the revision this replica runs, or null before the first one |
in_step | whether running is the revision the store marks current |
refused | the revision this replica refused, while it does not run it |
ferrofed_config_revision_in_step is 1 while the replica runs the
current revision and 0 while it does not, and the alert
FerroFEDConfigurationDrift fires after 10 minutes at 0
(Metrics).
| Key | Default | What it sets |
|---|---|---|
config.poll | 5000 | how often each replica reads the running mark, from 100 to 600000 ms; read only under managed, and a change takes a restart |
Under config.ui = "off" nothing is read: no store, no watch.
With several replicas, config.secrets_dir must be one volume every
replica mounts at the same path, since a revision names the file a secret
value was written to and every replica reads it when it follows the
revision. A replica that cannot read a file a revision references refuses
that revision, keeps its running configuration and reports it as
refused, as above.
FerroFED’s own store
[store] connects FerroFED to one PostgreSQL database of its own. The
store holds the state that must both survive a restart and be one on every
replica. No specification governs the store: it is FerroFED’s own design.
When you need it
You need [store] in two cases:
- an issuer is declared a national contact point
(
[[federation.contact_point]], Client authentication); - the stored-query registry uses
backend = "postgres"(Stored queries).
A configuration in either case that sets no [store] refuses to start, and
config check refuses it too, naming the key that needs the store and
[store]:
federation.contact_point[0] needs FerroFED's own PostgreSQL store, and the configuration sets no [store]; add [store] with url or url_file
In every other case FerroFED runs with no PostgreSQL database. The embedded
redb file of the default stored-query backend and the read-only files
backend are unchanged.
The database and its role
Give FerroFED a database and a login role of its own. Never point [store]
at a member CDR’s database or role, and never at a schema inside a FerroEHR
cluster’s database, so a gateway credential never reaches clinical data.
The tables live in the schema ferrofed. The role needs CREATE on the
database to make the schema, or a ferrofed schema made for it on which it
holds CREATE and USAGE. The store is tested against PostgreSQL 18.6.
What it holds
The store holds only state that must outlive the process and be shared by every replica:
| Table | What a row holds | When it goes |
|---|---|---|
ferrofed.stored_query_definition | a stored-query definition of the postgres backend: name, version, the instant it was stored, its parameterised AQL (§12.7, N44) | never: a stored version is immutable |
ferrofed.emergency_access | the emergency accesses granted to one health professional a national contact point relays, keyed by a SHA-256 digest of the professional’s identity: the instants granted inside the window, and the end of that window (The emergency access limit) | at expires_at, the end of the window of the last access granted, by the sweep |
ferrofed.dpop_proof | one DPoP proof the gateway accepted, by the SHA-256 of its jti alone, never the value, so a replay to any replica is refused (RFC 9449 §11.1; DPoP) | at expires_at, the end of the window the proof is accepted in, by the sweep |
ferrofed.contact_point_dispensation | who dispensed one dispensation a national contact point wrote, keyed by the SHA-256 of the gateway’s id of it: the identity assertion’s organization-id and the facility identifier the dispensation names on performer.actor, so its discard is held to that dispenser on every replica (Dispensations and their discard) | at expires_at, 24 hours after the dispensation, by the sweep; no discard is admitted after it |
ferrofed.store_migration | one migration applied, and when | never |
These stay out of the store: caches (revocation and OCSP answers, issuer
keys), audit records (the audit spool), configuration (the revisions of
[config] keep their own store) and clinical data. A table that a later
release adds meets the same rule.
The keys
[store]
url_file = "/run/secrets/ferrofed/store-url" # or url = "...": the connection string, a secret
ca_file = "/etc/ferrofed/store-ca.pem" # optional: the CA the database's certificate is verified by
max_connections = 4 # 1 to 64
timeout = "10s" # one connection, or one statement
sweep_interval = "1min" # how often expired rows are deleted
| Key | Default | What it sets |
|---|---|---|
url, url_file | none, one is required | the connection string, a URL (postgres://ferrofed@db.example.org:5432/ferrofed?sslmode=require) or libpq key/value pairs; a secret, read at boot and trimmed when it comes from the file, never both |
ca_file | the platform’s trusted roots | a PEM file of the CA certificates the database’s certificate is verified by, in place of the platform’s roots |
max_connections | 4 | how many connections the gateway opens, 1 to 64 |
timeout_ms | 10000 | how long one connection, or one statement, may take; zero is refused |
sweep_interval | 1min | how often the rows past their window are deleted; zero is refused |
Give the credentials with url_file: write the whole connection string,
password included, to a secret file only the gateway’s user reads, and
keep url for a connection string that carries no password.
A key the table does not have refuses the configuration, naming it. A
connection string that does not parse is refused naming its key, never
quoting the string. The startup banner, the log and ferrofed report never
show the string. Changing [store] needs a restart.
TLS is required
The store is reached over TLS under every profile, the development profile
included. The connection string must set sslmode=require; a string that
leaves sslmode unset or sets disable or prefer is refused by
config check, the start and a reload, naming store.url or
store.url_file:
store.url does not require TLS: FerroFED's store is reached over TLS under every profile; set sslmode=require
TLS uses rustls and always verifies the server’s certificate, against
ca_file when it is set and the platform’s roots otherwise. A database that
listens on a Unix socket alone cannot serve TLS, so give it a TCP listener.
Migrations
The migrations are part of the binary. At start, and each time a connection
opens, the gateway takes a PostgreSQL advisory lock in one transaction,
creates the schema ferrofed when it is absent, and applies each migration
ferrofed.store_migration does not record, in order. Two replicas that
boot together over one empty database apply each migration once: the second
waits for the lock and then finds every migration recorded. A database that
records a migration this release does not have was migrated by a newer
FerroFED: the start is refused, naming both numbers, and the database is
left as it was (Rollback). Migrations only move forward, and
a released migration never changes.
Readiness and failure
The store has its own readiness indicator, store, which is down while the
database does not answer a statement within the readiness check’s two
seconds (Health probes). A down indicator takes the replica out
of rotation.
Every probe asks the database, but the log tells you only what changed. The
first probe that finds the store down logs the warning the store does not answer with the cause. While it stays down, the store still does not answer repeats at most once every five minutes, with down_for_s. The
first probe that finds it answering again logs the store answers again, with
how long it was down. The five minutes are FerroFED’s own choice.
A database that cannot be reached at start, or within timeout_ms, refuses
the start. Once running, a request that needs the store while the database
does not answer is refused, and is never answered from memory: a stored
query read or PUT answers 500, names nothing in the body, and the log
names the failure.
Expired rows
A table whose rows expire carries the end of each row’s window in a column
expires_at. Every sweep_interval, the gateway deletes each row past
its window from every such table of the schema ferrofed, so the store
keeps a row no longer than its rule needs. The stored-query definitions have
no window and are never swept.
Moving from [stored_queries] url
The postgres backend of the stored-query registry used to read its own
connection string, [stored_queries] url or url_file. Those keys are
gone since 0.0.17, and a configuration that still sets either refuses to
start with an unknown key. Move the connection string to [store], point it at
the database the backend already uses, and set sslmode=require:
[stored_queries]
backend = "postgres"
[store]
url_file = "/run/secrets/ferrofed/store-url"
ca_file = "/etc/ferrofed/store-ca.pem"
The table of definitions is the store’s first migration, so the gateway
takes over the existing table with every definition it holds, and a version
it held is refused a second PUT as before. Upgrading lists
the step.
Admitting a node
A federation admits a node before the node serves any request, and it does not admit a node that fails its admission conditions (§12b.1, N42a). The conditions cover at least the identifier-integrity rules of §12b.2. Admission is your act as the federation operator. FerroFED does not decide it, and the specification scores it against you, not against the gateway (CP-33a).
The specification asks you to verify the conditions by test and not by attestation alone. FerroFED helps in two ways:
- the registry refuses, at load, a second member with a
system_idan existing member already has, compared without regard to ASCII case; ferrofed admission checkexercises one configured member against each condition a test can reach and writes a report you keep as evidence.
Running the check
Add the candidate node to the registry document first, with its node, its
system_id and its endpoint. Keep the endpoint at status = "suspended"
while you check it: the gateway sends a suspended endpoint no query and
routes no request to it, and the check still reaches it.
ferrofed admission check --endpoint node-c-pub --config /etc/ferrofed/ferrofed.toml
ferrofed admission check --endpoint node-c-pub --count 5
--endpoint names the endpoint id from the registry. --count is the number
of test EHRs the check creates, from 2 to 50, and 3 when you leave it out.
The check uses the same configuration as serve: the endpoint’s onward
credentials, the per-node timeout, and the configured cross-reference.
The check writes to the node. It creates the test EHRs through
POST {base}/v1/ehr, and the node keeps them. Each EHR’s EHR_STATUS names
a synthetic subject: the namespace urn:oid:2.999.1.0, inside the example
arc ITU-T X.660 reserves, and a value ffd-admission- followed by 32 random
hexadecimal digits, minted fresh for each run. No real identifier is used.
The report states in its first lines that it created test EHRs. Remove them
under the node’s own procedure if your governance requires it.
A member whose governance forbids test data in its production CDR can be checked without a write, below (A run without writes).
The subjects travel only in the EHR_STATUS body and to your
cross-reference. Every request passes the same outbound gate as a query, with
the run’s subjects withheld from the path, the query string and the headers
(§5.4.1,
N33).
The report prints the endpoint id, the node id, system_ids and the
ehr_ids the node created. It never prints a subject or a node’s error body,
which could echo one.
Each condition gets one verdict:
| Verdict | Meaning |
|---|---|
pass | the evidence shows the node meets the condition |
fail | the evidence shows it does not, or the check could not reach the node |
cannot-check | the check cannot decide the condition; the report says why |
The command exits 0 when no condition failed, 1 when one did, 2 for an
endpoint the registry does not hold, and 78 for a configuration that does
not load or has no registry document. A node the check cannot reach fails
every condition it exercises, with the cause: a check that reached nothing
passes nothing.
A run without writes
Many CDR operators forbid test data in production, and the check above leaves its test EHRs behind. Three paths remain, and you can combine them:
-
A staging copy. Run the full check against a staging instance of the member that runs the same product, version and configuration, and stamps the same kind of
ehr_ids. Add it to a registry document of its own with its ownsystem_id, because the registry refuses two members with one. -
A run without writes against the production CDR:
ferrofed admission check --endpoint node-c-pub --read-only ferrofed admission check --endpoint node-c-pub --read-only --count 20 -
The member’s own procedures, for whatever neither run reaches.
With --read-only the check creates nothing. It sends the node one AQL
query, through the same node client and outbound gate as every other
request, for the ehr_id and system_id of up to --count EHRs the node
already holds:
SELECT e/ehr_id/value, e/system_id/value FROM EHR e
It reads no subject and no clinical content. The node must allow the
gateway’s credential to run that query across EHRs; a node that refuses it
fails both conditions the run exercises, with the status it answered. The
report says in its first lines that the run made no write, lists the
ehr_ids it read, and ends with the conditions the run left unproven.
| Condition | What a run without writes does |
|---|---|
ehr_id generation | each ehr_id read is judged as above, and no two may be equal. A node that returns no EHR is cannot-check. |
| No reuse | cannot-check, as on every run |
No adoption of foreign ehr_ids | cannot-check, as on every run |
system_id uniqueness | each EHR must report the system_id the registry records for the node, or one a [[creating_system]] entry routes to it. One the registry records for another member fails. One the registry routes nowhere is cannot-check: the EHR may have been created on another system, and a read cannot show what the node stamps into new EHRs. |
ehr_id exchange | cannot-check: the run creates no subject, and it reads no subject of an existing EHR, so it has no patient whose ehr_id it knows |
§12b.1 asks you to verify the conditions by test. A run without writes
proves less than a full run: its ehr_ids are the ones the node issued in
the past, and it never exercises the exchange of §5.5. Prove what it leaves
unproven with a full run against a staging copy, or with a test patient the
member’s environment registers through its own procedure.
The conditions
ehr_id generation
The rule: ehr_ids are version-4 UUIDs, or come from another scheme with
equivalent collision resistance and no coordination requirement. A node that
issues sequential, short or deployment-local ehr_ids is not admitted
without remediation.
What the check does: it reads the ehr_id of each test EHR from the
node’s ETag (or else its Location), reads the EHR back through
GET {base}/v1/ehr/{ehr_id}, and compares.
- A version-4 UUID in its hyphenated form passes.
- A UUID of another version is
cannot-check. §12b.2 accepts another scheme only on equivalent collision resistance, and that judgement is yours. - A value that is not a UUID fails, and so does an
ehr_idthe node issued for two subjects, or an EHR the node reads back under anotherehr_id.
What it proves: the ehr_ids the node issues now, for new EHRs created
through ITS-REST, have the right form and are distinct. It does not prove the
generator’s randomness: a few samples cannot.
No reuse
The rule: a node never re-issues or reuses an ehr_id, including across
restores, migrations and test-data resets.
What the check does: it reports cannot-check. Reuse happens across a
restore, a migration or a reset, and a check run before or after one sees no
trace of the ehr_ids the node issued on the other side of it. The ehr_ids
of one run are compared with each other under ehr_id generation. Your
evidence is the node’s documented ehr_id generation and its restore and
reset procedures.
No adoption of foreign ehr_ids
The rule: a node that ingests EHRs from elsewhere issues a fresh local
ehr_id, or is registered as a holder of the origin node’s ehr_id space.
It never silently adopts a foreign ehr_id as its own.
What the check does: it reports cannot-check. Adoption happens on an
import, and the check performs none. Whether an import issues a fresh
ehr_id is the node’s import procedure. A registration as holder of another
node’s ehr_id space is your registry decision.
system_id uniqueness
The rule: a node’s openEHR system_id is unique across the federation
and is not shared with another node. A shared one makes creating_system_id
routing ambiguous
(§12.2),
so a follow-up write could reach the wrong CDR.
What the check does: the registry already holds every member’s
system_id unique, because its load refuses a duplicate. The check then
reads the system_id the node itself reports in each EHR it created, the
EHR.system_id of the RM. It passes when that is the system_id the
registry records for the node. It fails when the node reports another
member’s system_id, or one the registry does not record for it.
What it proves: the registry’s uniqueness covers the system_id the node
really writes into its data. It does not prove that no system outside the
federation uses the same system_id.
ehr_id exchange
The rule: the node’s environment can resolve a patient identifier to the
node’s local ehr_id
(§5.5).
Who provides it, the node, its organisation’s MPI or a regional service, does
not matter.
What the check does: for each test EHR it asks the configured
cross-reference ([pixm]) which ehr_id the synthetic subject has
at the node.
- It passes when the answer is the
ehr_idthe node created. - It fails when the answer is another
ehr_id, when no cross-reference is configured, or when the node created no EHR. - It is
cannot-checkwhen the cross-reference does not know the subject, or does not answer for it.
The gateway writes to no cross-reference: a PIX Manager learns patients from
its own identity sources. The round trip therefore passes only where the node’s environment
registers a new EHR’s subject with the cross-reference by itself. Where it
does not, which is common, the report says cannot-check, and you verify the
exchange with a test patient your environment registers.
What the check cannot prove
Two conditions are outside the reach of any test run from outside the node:
reuse across restores, migrations and resets, and adoption of foreign
ehr_ids on import. Both depend on what the node does at a moment the check
does not see. The report marks both cannot-check on every run. For those,
the evidence is the node operator’s documented procedures, and the decision
is yours.
The gateway also keeps a backstop at request time. An ehr_id that two
members claim raises an integrity incident and is never served
(§12.5.2).
That alarm surfaces a collision admission missed. It does not replace
admission.
Exporting the identity state
When you replace FerroFED with another product, the routing state the gateway learned while it served goes with you. Regulation (EU) 2025/327 Annex II 2.6 says the harmonised components of an EHR system “shall not include features that prohibit, restrict or place an undue burden on authorised exporting of personal electronic health data for the reasons of replacing the EHR system by another product”. The access records are exported from the Audit Record Repository (The audit trail). This page covers the rest: the state the gateway learned and holds in memory.
What is exported and what is not
The export holds the creating_system_id mappings the gateway learned from
answers. A member’s CDR can hold versions another system created, and the
gateway learns which member to send a follow-up read of such a version to
(§12.2, N21). The mapping is lost when the process stops, and a replacement
that has it routes those follow-ups from its first request.
The export leaves out:
| State | Why it is left out |
|---|---|
| The resolution bindings of each caller | They are scoped to one client session and expire after their time to live. A replacement asks the identifier cross-reference (the PIX Manager or the NVI) itself, which is the source of truth. A binding is pseudonymous personal data, and the export carries none. |
The ehr_id index | It holds one entry per patient’s EHR and is a hint: the next read that names no node asks every member and finds the owner again (§12.5.1). |
| The membership | The registry document is the record, and you already hold it. |
| The stored queries | Their backend holds them, and GET {base}/operator/stored-queries reads them (Client authentication). |
The export therefore carries system_ids, endpoint_ids and node_ids
only, and no value a patient is known by: no patient identifier, no
ehr_id, no demographic (§5.4.1, N33).
Running the export
Send the request to the admin listener
with an access token that carries auth.product_scope:
POST /admin/identity-state/export
Authorization: Bearer <an access token with the operator scope>
| Answer | When |
|---|---|
200 | The export, as JSON, with Cache-Control: no-store. |
400 (body-invalid) | The request carries a body. The export takes none. |
401, 403 | The token is missing, does not verify, or lacks the operator scope. |
501 | The gateway runs without a registry document, so it learned nothing. |
The gateway records the export like every admin action: under
ferrofed::security, once when it begins and once when it ends, with your
issuer and subject, the route and the status (Metrics). It
also logs how many mappings the export held, never a value.
Each replica learns its own mappings and shares them with no other replica
(Running several replicas). Export every replica,
one request to the admin listener of each, and keep every file. The files
of two replicas can disagree, because each saw different answers. Each
file names its replica in replica, so give each replica a name you can
tell apart ([server] replica, or the pod name Kubernetes gives it).
The format
{
"format_version": 1,
"federation_id": "example-federation",
"exported_at": "2026-10-15T09:30:00Z",
"replica": "ferrofed-7d9c4b5f6-x2k8q",
"learned_creating_systems": [
{
"creating_system_id": "legacy-x.example.org",
"status": "learned",
"node": "node-a",
"endpoint": "node-a-pub"
},
{
"creating_system_id": "legacy-y.example.org",
"status": "withdrawn",
"node": null,
"endpoint": null
}
],
"not_exported": [
"resolution-bindings",
"ehr-id-index",
"development-cross-reference",
"membership",
"stored-queries"
]
}
| Field | Meaning |
|---|---|
format_version | The version of this format, 1. A change that removes a field or changes what a field means raises it. A reader refuses a version it does not know. |
federation_id | The [federation] id the gateway declares in OPTIONS {base}/ (§7a.2, N30). |
exported_at | When the process took the export, as an RFC 3339 timestamp in UTC. |
replica | The name of the process whose state the export holds: [server] replica, or the host name the process runs with, which Kubernetes sets to the pod’s name, or unnamed without either (Configuration). Each file names the replica it came from. |
learned_creating_systems | Every mapping the process learned, ordered by creating_system_id. It is empty when the process learned none. |
learned_creating_systems[].creating_system_id | The creating_system_id of the versions, as their version uid spells it. |
learned_creating_systems[].status | learned: the process saw versions of this creating_system_id at one member only and routes follow-up reads there. withdrawn: an integrity incident withdrew the mapping, because the versions were seen at two members or the registry document names another member, and the process routes nothing by it (Integrity incidents). |
learned_creating_systems[].node | The node_id of the member, from the registry document the process runs on. null for a withdrawn mapping. |
learned_creating_systems[].endpoint | The endpoint_id the versions were seen at. null for a withdrawn mapping. |
not_exported | The state the process holds that the export leaves out, as the table above lists it, so a reader of the file alone knows it is not there. |
The node_id and endpoint_id values are those of your registry
document, so keep the document of the same date with the export. A
member’s own system_id and the document’s [[creating_system]] mappings
are routes too; they are in the document, and the export does not repeat
them.
A mapping learned from answers shows where versions are held, never which CDR controls them: the gateway routes reads by it and never a write (§10.3, §12a.1, N23). A replacement that imports the file should read it the same way.
Scoring a deployment
ferrofed conformance run drives the Connectathon test tracks of the
Federation Tier with AQL
(§16.3)
against your own deployment: its registry, its nodes and its
cross-reference. It plays an unmodified openEHR client over ITS-REST and
writes the same per-track and per-point report the FerroFED test harness
writes from its own run
(§16.4).
Use it to score an installation before a Connectathon, after an upgrade, or
when you admit a member.
The run drives tracks 1 to 7, 9 and 11. Track 8 is provisional and deferred.
Track 10 is judged on what reached each node, which only node-side wire
capture shows, so the run reports it not-run.
Before you run it
The run needs four things beside the configuration file.
-
A synthetic patient your cross-reference knows. Choose an identifier in the example arc
urn:oid:2.999that ITU-T X.660 reserves for examples, such as namespaceurn:oid:2.999.1.1and valueffd-test-0038, and register it in your cross-reference at the members you want scored, each with anehr_idno node holds yet. With a PIX Manager it is aPatientyour identity source feeds. The run refuses a namespace outside the arc, and a value other than ASCII letters, digits,.,_and-. -
A caller token. A file holding a bearer access token your gateway’s
[auth]accepts, with the scopes to query, read and write EHRs and compositions, and to store and run stored queries. The run sends it on every request but the one that checks an unauthenticated caller is refused. -
The seed files. The reference implementation’s synthetic demo data: the
International Patient Summaryoperational template and the two compositionscomposition-12345-hospital.jsonandcomposition-12345-clinic.json, whose only party isPARTY_SELF. Every release attaches them asferrofed-conformance-seed-data.json, one file that also carries their Apache-2.0 licence and notice, with its SHA-256 inferrofed-conformance-seed-data.json.sha256sum:base=https://github.com/FerroHEALTH/FerroFED/releases/latest/download for f in ferrofed-conformance-seed-data.json ferrofed-conformance-seed-data.json.sha256sum; do curl -LO "$base/$f" done sha256sum -c ferrofed-conformance-seed-data.json.sha256sum…/releases/download/vX.Y.Z/downloads the file of one version. Pass the file to--seed-data. In a checkout, you can pass the directory FerroFED vendors them in,docs/specs/federation-ref/docker/demo-data/, instead. Either way the run checks each file’s SHA-256 and refuses any other content. -
At least two members holding the patient for the scenarios that compare answers across nodes. With one, those scenarios are
not-runwith the reason.
Running it
ferrofed conformance run --config /etc/ferrofed/ferrofed.toml \
--allow-writes \
--patient-namespace urn:oid:2.999.1.1 --patient-value ffd-test-0038 \
--token-file /run/secrets/conformance-token \
--seed-data ./ferrofed-conformance-seed-data.json \
--out ./conformance-report
Without --gateway, the run starts the gateway in-process from the
configuration, as serve builds it, on a loopback port of its own, and
stops it at the end. The report is then of exactly the configuration you
gave, and nothing else need be running. With --gateway <url>, the run
drives the gateway already serving at that base URL, and reads the
configuration for the registry, the node clients it seeds through and the
cross-reference. Give it the same configuration that gateway serves.
What it sends in the clear
The run carries your caller token and synthetic patient data, so it holds every connection it makes to a stricter rule than the gateway’s own:
- the
--gatewayURL and the URL of every member endpoint in the registry arehttps, or plainhttpto a loopback host (127.0.0.1,::1,localhost) under the development profile alone; - plain
httpto any other host is refused under every profile, and anyhttpoutside the development profile, before the run sends anything; - the token endpoints and the other credential sites of the configuration
are held to the rule
serveapplies (Configuration).
A refusal exits 78 and names the URL’s key, never the URL or a value. A
loopback http connection the development profile admits is printed as a
warning and listed under Unencrypted connections in report.md. The
in-process gateway is the run’s own listener on 127.0.0.1, inside the
same process.
The run follows no redirect, to the gateway or to a node: a 3xx is the
answer it reads, so the token never travels to an origin you did not give
it. The token is read from --token-file into a secret type, and no report
file, log line or error message carries it. Each node receives the onward
credentials the configuration names for it, never your token.
--node-profile also checks every active member against the node
obligations of
§16.2,
through the member’s node client with its onward credentials, as the
gateway reaches it, and records the findings as the node profile:
| Check | Point | What the run does |
|---|---|---|
invocable on ehr_id alone | CP-27 | reads the patient’s EHR it seeded at the member, its EHR_STATUS and its compositions by the ehr_id alone |
| subject never required | CP-27 | creates an EHR with no EHR_STATUS and reads and queries it by its ehr_id |
| node errors passed through | CP-18 | asks for an EHR the node does not hold and sends an unparsable query, and reads the status the node answers |
| access decided at the node | CP-18 | records not-observable: the refusal under test is the node’s own policy, which a run cannot arrange |
| consent before release | CP-19 | records not-observable: ITS-REST defines no consent resource, so a run cannot arrange a consent refusal |
| the identifier-integrity conditions of §12b.2 | CP-33a, and CP-27 for the ehr_id exchange | runs the admission check (Admitting a node) |
A status ITS-REST does not document for an operation is recorded as the
node’s answer, and a member the checks cannot reach is recorded
not-observable with the cause, never a pass.
The command exits 0 when no scenario failed, 1 when one did or the run
could not reach its report, 2 when a safety rule refused it, and 78 for a
configuration that does not load or has no registry, or a connection that
would carry the token or the synthetic data in the clear.
What it writes
The run writes to your nodes, and removes nothing it writes. It refuses
to start without --allow-writes. Against a configuration whose profile is
not development, it also needs
--i-understand-this-writes-synthetic-data-to-the-nodes, because such nodes
may hold real records.
Through each node’s own ITS-REST API, with the endpoint’s onward credentials and the gateway’s outbound gate, it writes:
| What | Where |
|---|---|
the patient’s EHR, PUT {base}/v1/ehr/{ehr_id} with an EHR_STATUS naming the synthetic patient | every member the cross-reference names |
| the vendored template, unless the node holds it | every member holding the patient |
| one vendored composition in the patient’s EHR | every member holding the patient |
three EHRs with no subject, for the routing scenarios that need an ehr_id the gateway has not seen, one holding the vendored composition | the first two members holding the patient |
Through the gateway, the scenarios write:
| What | Where |
|---|---|
| an EHR with no subject, created at the member the request names | a member holding the patient |
the vendored composition with the synthetic patient’s identifier added to its composer as a DV_IDENTIFIER | the patient’s EHR at the first member |
two stored queries, under org.example.conformance:: with a name unique to the run, the patient bound only as $patient | the gateway’s stored-query registry, when it declares one |
With --node-profile, the node profile also creates one EHR with no subject
on every active member, and the admission check three test EHRs, each for a
synthetic subject of its own.
The run reads no EHR it did not create. When a node already holds an
ehr_id the cross-reference names for the patient, the node answers the
create 409, and the run seeds the patient nowhere and reports the
patient’s scenarios not-run. Register a fresh patient for every run: the
run never writes into an EHR it did not create, so a second run with the
same patient seeds nothing.
The patient’s identifier travels in the queries the run sends the gateway,
in the EHR_STATUS body of its own creates and in the composition it
commits, and to your cross-reference. The report never prints it.
Reading the report
The run writes four files to --out.
report.tsvandreport.mdhold one row per track and per conformance point of §17, with the columnskind,id,title,actor,status,result,passed,failed,not_run,issueandreasonthe harness report has.statusis FerroFED’s own:covered,deferred,node-profileoroperator, from the conformance matrix the binary was built with.node-profile.tsvholds the node profile findings, one row per finding and point.written.tsvlists every write the run made, by endpoint.
A row’s result reads:
| Result | Meaning |
|---|---|
pass | every scenario that scores the row ran against your deployment and passed |
fail | a scenario that scores it failed; the reason says which check |
not-run | a scenario that scores it did not run, or none does; the reason says why |
deferred | FerroFED defers the point by a recorded decision |
node-pass, node-fail, node-not-observable, unchecked | a Node point, scored against the member and never the gateway (§16.2) |
operator-pass, operator-fail, operator-not-observable, not-applicable | an Operator point, scored against you |
A pass from the run is narrower than a pass from the harness. Many
scenarios have a part a live deployment cannot provide for: a fault injected
at a node (a refusal, an outage, a delay), node-side wire capture (what
reached each node: the ehr_id scope, no subject, no directive), or a
gateway configured for the one scenario (a consent pre-filter that denies, a
different aggregate declaration). The run reports each such part as its own
scenario, not-run with that reason, and every row it scores reads
not-run too, however much else passed. The section Scenarios that did not
pass in report.md lists each one. The harness scores those parts against
its own FerroEHR nodes.
Some scenarios also need something of the deployment: two members holding the
patient, a member where the patient does not resolve, or a stored-query
registry declared in OPTIONS {base}/. Without it the scenario is not-run,
naming what was missing.
The run reads each answer with the typed result envelope and does not validate it against the published JSON Schemas; the harness does.
Health probes
The gateway answers the health routes every FerroHEALTH product serves, from
the family’s ferrohealth-server, under its
base path, and the binary carries a
healthcheck command for a runtime that cannot send an HTTP request itself.
An operator reads the state of every member and service from the
dependency report. No specification governs
health probes: our own design.
The routes
| Route | Answers | Use it as |
|---|---|---|
GET {base}/health/liveness | 200 while the process serves; it checks nothing else | liveness |
GET {base}/health/readiness | 200 while the gateway serves and its own subsystems are up; 503 before boot completes and from the moment SIGTERM or SIGINT arrives | readiness, startup, the image HEALTHCHECK |
GET {base}/health | the status of readiness with the state alone, {"state":"up"} | a monitor that needs no detail |
All three answer without a token and name no member. Readiness names each indicator and its state and never its detail, since anyone who reaches the port reads it. The body reads:
{
"state": "up",
"phase": "serving",
"indicators": [
{ "name": "configuration", "state": "up" },
{ "name": "registry", "state": "up" }
]
}
Readiness reports the gateway’s own subsystems by name: the configuration,
the registry and the outbound clients when a registry is configured, the
stored-query store when one is, and store, FerroFED’s own PostgreSQL store,
when [store] is set. store alone is checked on every probe: it is down
while the database does not answer, which takes the replica out of rotation
(FerroFED’s own store). Its body names the phase of the process,
booting, serving or draining. On SIGTERM readiness turns 503 at
once, while the listener still accepts, so a load balancer stops sending
requests before the gateway stops taking them
(Stopping without dropping a request).
No member node and no identity source gates readiness. A node outage is
reported per query in meta.federation (§11), and a gateway that went unready
with one node would turn one CDR outage into a total outage. Their state is
in the dependency report instead.
The dependency report
GET {base}/operator/dependencies answers 200 with the state the gateway
last observed of each member endpoint, of the resolver, of the consent
pre-filter, of the localizer, of the demographics step, of the mCSD
directory, of the audit repository and of the PMIR Patient Identity
Registry. Use it for monitoring, never as a probe. It names every member
and which of them are down, so it sits on the
operator surface: only a caller
whose token carries auth.product_scope reads it, a request
without a token is a 401, and one without the scope is a 403. Give your
monitoring a token with the operator scope, as the operator console has
one. A report reads:
{
"endpoints": { "node-a-query": "up", "node-b-query": "down" },
"resolver": "up",
"consent": "down",
"localizer": "up",
"replica": "ferrofed-0"
}
replica names the gateway process that observed the states, since each
replica observes its own (The replica name).
Each state is the one the last request the gateway made for a client observed, and it reports the member’s reachability and health, never whether that request was valid:
| State | The last request |
|---|---|
up | got an answer below 500, a refusal such as 400, 401, 404 or 409 included |
failing | got a 5xx answer |
down | got no answer: the member could not be reached, or did not answer in time |
unknown | none has reached the member since the registry was loaded or reloaded |
The state reads the node’s own HTTP status, whatever the call’s §11.1 record
in meta.federation says: a query member that answered 400 is
node-error there and up here. The gateway sends no request of its own to
find out, and a request that never left the gateway changes nothing. Every
call that sends a request to a member updates it: a federated query, a
request routed to one node, the ask-all probe, a fan-out template upload, and
a stored-query distribution, repair or drift check. A drift check that finds
a member’s copy different or missing records the member up, because it
answered. A resolution updates resolver, which is absent when no resolver
is configured. A call to the consent pre-filter updates consent by the same
rule: a decision, or an answer below 500, is up, a 5xx is failing, and
no answer is down. It is absent when no pre-filter is configured
(Consent). A call to the localizer updates localizer
by the same rule: a candidate set, or an answer that no member holds the
patient, is up, a failure answered below 500 is up, a 5xx is
failing, an XCPD exchange whose audit message could not be recorded is
failing, and no answer, a silent localizer past its budget included, is
down. It is absent when no localizer is configured
(Node selection). A call to the PDQm
Supplier updates demographics by the same rule: an answer, no match or an
ambiguous one included, is up, a 5xx is failing, an exchange whose
audit record could not be stored is failing, and no answer is down. It
is absent when [pdqm] is not configured
(Demographics first). A refresh of the mCSD
directory the registry is read from updates directory: an answer the
gateway accepts is up, an answer whose change the gateway refuses is
degraded, an HTTP error (a 4xx included), an answer that breaks ITI-90
or ITI-91 or one past a cap is failing, and no answer before the deadline
is down. While the directory is degraded, directory_fault names the
class of the refusal: registry-invalid when the changed registry breaks a
rule of its own, and configuration-mismatch when it is sound and the rest
of the configuration does not fit its members. The gateway then serves the
registry it last accepted, and the directory stays degraded until a later
refresh is accepted. A directory that answers 401 or 403 is failing
with directory_fault = "refused-credentials": check the credentials of
[registry.mcsd]. directory_fault is absent in every other state, and it
names a class only, never a member, an endpoint or a URL. Both are absent when the
registry is a document
(The registry). The
audit repository shows as
audit_repository, read from its spool at each request: up when the last
delivery succeeded and nothing waits, degraded while the gateway retries a
failed delivery, while audit messages wait in the spool and while any sits in
its quarantine, and unknown before the first message. It is absent when the audit messages go elsewhere. The
FHIR Feed repository of [audit] shows as audit_feed in the same states,
absent unless destination = "repository" (The audit trail). The
PMIR Patient Identity Registry shows as identity_registry, from the
identity feed’s last check: up while the gateway holds a subscription in
requested or active, failing after a refusal, an answer that breaks
ITI-94, or a create the gateway cannot manage, and down when the Registry
did not answer. identity_registry_fault names why it is not up:
unreachable, refused, malformed, unmanageable, or audit-failed
when an exchange’s audit record could not be stored. Both are absent
without [pmir]
(The identity feed). The
body names endpoint ids and states only, never a URL, a credential or a
body.
Stopping without dropping a request
On SIGTERM or SIGINT the gateway stops in four steps. No specification
governs this: our own design.
- Readiness answers
503at once, with the phasedraining, and the listener keeps accepting forserver.drain_delay. A request that arrives in this window is served like any other. - The listener closes. Each connection finishes the request it is serving and is closed, and no new connection is accepted.
- The requests in flight get
server.shutdown_timeoutto finish. A connection still open after that is dropped. - The bindings get
gateway.bindings_drain_timeout(5 seconds by default) to stop their processes, such as deleting the PMIR subscription (The identity feed). A process still stopping after that is abandoned, and the gateway logs a warning that what it holds at a remote service may be left there.
The delay exists because a load balancer does not stop routing the moment a
process is asked to stop. A balancer that polls readiness needs up to one
polling period, plus its failure threshold, to see the 503. In Kubernetes,
removing a terminating pod from a Service’s endpoints runs alongside the
SIGTERM, not before it
(Pod termination).
Set the delay to the longest of those times. It is 0 by default, which
closes the listener at once: right for a single process with nothing in
front, and the reason a gateway behind a balancer sets it.
server.shutdown_timeout defaults to server.request_timeout, and
config check, serve and a reload refuse a value shorter than that,
naming both keys. A request accepted just before the listener closes may
run for the whole request timeout, so a shorter drain would cut it, and a
federated query that runs to its overall budget would be lost on every
rolling restart.
The runtime’s grace period must outlast every step. Docker sends SIGKILL
after stop_grace_period, and the kubelet after
terminationGracePeriodSeconds, counted from the moment the pod is deleted.
Keep each above drain_delay plus shutdown_timeout plus
bindings_drain_timeout, with room for the last metrics and spans to be
pushed. The drain is at least the request timeout, so the grace period is
derived as delay + request timeout + bindings’ budget + room. The shipped
examples keep 5 seconds of room:
| Example | drain_delay | shutdown_timeout | bindings_drain_timeout | Arithmetic | Grace period |
|---|---|---|---|---|---|
deploy/kubernetes/base/ | 5000 | 30000 | 5000 | 5 (preStop sleep) + 5 + 30 + 5 + 5 | terminationGracePeriodSeconds: 50 |
deploy/compose/ | 0 | 30000 | 5000 | 0 + 30 + 5 + 5 | stop_grace_period: 40s |
the quickstart compose.yaml | 0 | 30000 | 5000 | 0 + 30 + 5 + 5 | stop_grace_period: 40s |
scripts/checks/kubernetes-example.sh and
scripts/checks/release-compose.sh fail when an example’s grace period
does not outlast its delay, its drain and its bindings’ budget;
release-compose.sh holds the quickstart too. Both read the three values
from the example’s configuration and copy no default from the code, so each
example sets all three.
ferrofed healthcheck
ferrofed healthcheck --config /etc/ferrofed/ferrofed.toml
The command reads the configuration the way serve does and asks the
gateway running on this host for GET {base}/health/readiness. It connects
to the port of server.listen, on 127.0.0.1 or [::1] when the address
is a wildcard and on the address itself otherwise, and prints one line. It
exits 0 only when readiness answers 200 within three seconds, and 1 for
any other status, a refused connection, no answer in time, or a
configuration that does not load: the two codes a container runtime’s health
check reads. With [server.tls] set it asks over https and accepts exactly the
certificate certificate_file holds, presenting healthcheck_identity_file
where the listener requires a client certificate
(TLS on the listeners). The image runs it as its
HEALTHCHECK, and so does the compose.yaml gateway service.
The line uses the words every FerroHEALTH product’s healthcheck prints:
| Outcome | Line | Exit |
|---|---|---|
Readiness answers 200 | ferrofed: <address>: healthy | 0 |
| Readiness answers another status | ferrofed: <address>: unhealthy: readiness answered <status> | 1 |
| No answer within three seconds | ferrofed: <address>: unhealthy: readiness did not answer in time | 1 |
The listener presents a certificate other than certificate_file holds | ferrofed: <address>: unhealthy: the listener presents a certificate other than server.tls.certificate_file holds; send SIGHUP so the gateway reads it | 1 |
| A refused connection, a failed handshake or another failure | ferrofed: <address>: unhealthy: <reason> | 1 |
| The configuration does not load | ferrofed: unhealthy: <reason> | 1 |
A Kubernetes pod probes the routes directly
(Kubernetes): startup and readiness on
{base}/health/readiness, liveness on {base}/health. Point every probe at
the base path you configured, because every path outside it answers 404.
Overload protection
One federated query becomes a request to every member it asks, so a busy gateway passes its load on to every CDR behind it, and holds every answer it reads in memory. Four limits keep that in bounds:
| Limit | Key | Default | Past it |
|---|---|---|---|
| Requests the gateway serves at once | server.max_concurrent_requests | 512 | 503 overloaded, with Retry-After |
| Requests one verified caller sends | [server.caller_rate] | off | 429 rate-limited, with Retry-After |
| Requests the gateway sends one member endpoint at once | federation.max_in_flight_per_node | 64 | the request waits; past its per-node deadline the member is time-out |
| Bytes the gateway reads of one answer from a member or its token endpoint | federation.max_node_answer_size | 16777216 (16 MiB) | the answer is dropped unread and the member is node-error |
serve, config check and a reload refuse a zero in any of them, naming the
key. A reload that changes one logs it as needing a restart, like the rest
of [server] and [federation].
[server]
max_concurrent_requests = 512 # one more at once answers 503 overloaded
retry_after = "1s" # the Retry-After of that 503, in whole seconds
[server.caller_rate] # unset, no caller is rate limited
requests_per_second = 10 # sustained, per caller
burst = 20 # at once, after a quiet spell
[federation]
max_in_flight_per_node = 64 # per member endpoint, never shared between endpoints
max_node_answer_size = "16MiB" # of one answer; a longer one makes its member node-error
The concurrency limit
A request that arrives while the gateway serves max_concurrent_requests
others is answered 503 with the code overloaded and a Retry-After of
retry_after seconds (RFC 9110 §15.6.4, §10.2.3). The gateway
refuses it before it reads anything else of it: the caller is not verified,
and no resolver, localizer or member is asked. The health family,
{base}/health and {base}/health/readiness, is never refused, so an
orchestrator’s liveness probe does not restart a gateway for being busy.
The dependency report, {base}/operator/dependencies, is no probe and is
held to the limit like every other request.
Each replica counts its own requests. Size the limit from what the members
can take: with n members asked by each query, max_concurrent_requests
queries put up to n times as many requests on the members together.
The per-caller rate
With [server.caller_rate] set, each caller has a bucket of burst
requests that refills at requests_per_second. A request past an empty
bucket is answered 429 with the code rate-limited and a Retry-After of
the whole seconds until the bucket holds one again (RFC 6585 §4).
The caller is the one client authentication verified:
its token’s issuer and client_id. The gateway never reads a forwarded
address such as X-Forwarded-For, so a client cannot spread its requests
over addresses it names, and the clients behind one proxy are not counted
as one. A request outside client authentication, such as a health probe or
GET {base}/.well-known/jwks.json, has no caller and is never rate limited.
Each replica holds its own buckets, so a caller balanced over r replicas
gets up to r times the rate. The gateway tracks at most 10 000 callers at
once and forgets those whose bucket has refilled.
The per-member cap
The gateway sends at most max_in_flight_per_node requests to one member
endpoint at once, whatever sent them: federated queries, routed reads and
writes, the ask-all probe, template uploads and stored-query distribution.
A request past the cap waits for a slot until its per-node deadline
(federation.per_node_timeout, or the shorter budget a client asked for
with Prefer: wait). One still waiting then was abandoned at the per-node
timeout, so the member is time-out in meta.federation.endpoints[] with
an error saying the cap was full and nothing was sent (§11.1, §11.5,
N38). Under the all-or-nothing default that fails the query with 504, as
any time-out does; under partial the query answers with the other
members’ rows. A routed request answers 504 with node-timeout. No member
is marked down for it, since the gateway learned nothing of the member.
One slow member therefore holds at most its own slots: requests to every other member go out at once. Each replica has its own caps, and a registry reload starts the new registry’s caps while requests on the previous one finish under theirs.
The answer bound
The gateway reads at most max_node_answer_size of one answer, from a
member or from its token endpoint, whatever sent the request. An answer
whose Content-Length is past the bound is dropped unread, and one sent
without a length is read until it passes the bound and dropped then, so a
member that answers without end holds at most the bound in memory per
request. The member answered with nothing the gateway could use, so it is
node-error in meta.federation.endpoints[], with an error naming the
bound (§11.1). Under the all-or-nothing default that fails the query with
424; under partial the query answers with the other members’ rows, and
complete is false either way (§11.4). A routed request answers 424
with node-error.
Set the bound above the largest page a member answers: a member’s result set is the rows the query selects, and a routed read is the resource as the member holds it. A reload that changes it logs it as needing a restart.
Counting the refusals
Every refusal is counted in ferrofed_overload_refusals_total by limit
(concurrency, caller-rate, node-in-flight), the last with the
endpoint (Metrics). A 503 or 429 refusal is also logged at
WARN with its limit and no value of the request; a capped member is
reported in the answer’s meta.federation. The shipped alert rules page on
concurrency refusals and open a ticket on the other two
(Dashboard and alert rules).
Metrics
The gateway counts what it already observes: the requests its clients send
it, by route and status, and how long they take; the requests it sends to
each member node, and to its resolver, localizer and demographics service,
and how long they take; the integrity incidents it raises; the security
events it logs; the requests its limits refuse; and the registry reloads. One OpenTelemetry meter provider holds the
counts. A Prometheus server scrapes them from GET /metrics on an admin
listener of their own, and the gateway can also push them to an
OpenTelemetry collector over OTLP. Both surfaces read the same provider, so
a metric never exists on one and not the other. Both are off by default.
The dependency report, GET {base}/operator/dependencies, stays as it is:
it reports the last state the gateway observed of each member, and the metrics count every request
over time.
Turning it on
[metrics]
listen = "127.0.0.1:9464" # the admin listener; unset, nothing listens
allow_remote = false # true lets listen name a non-loopback address
scrape_clients = ["prometheus"] # the clients whose client-credentials token a scrape may carry; empty, the scrape is open
[telemetry.otlp]
endpoint = "http://127.0.0.1:4317" # an OTLP gRPC collector; without the table, nothing is pushed
metrics = true # push the metrics; true unless set
The push shares its collector, its TLS and its resource with the
trace export: one [telemetry.otlp] table names both, and
traces = false keeps the spans in the process.
The admin listener serves GET /metrics and the operator’s
stored-query distribution
(POST /admin/stored-queries/{name}/{version}/distribute),
clear of a held ehr_id collision
(POST /admin/ehr-ids/{ehr_id}/clear),
export of the identity state
(POST /admin/identity-state/export), the
reload SIGHUP asks for
(POST /admin/reload), and under config.ui = "managed"
the configuration revisions under
/admin/config. It answers every other path 404, the revision routes
too under config.ui = "off", and never sits under the base path. It is never the
gateway’s own listener, so no client of the federation reaches it.
Who the admin listener serves
| Request | Admitted | Refused |
|---|---|---|
GET /metrics, no scrape_clients named | every peer | never |
GET /metrics, scrape_clients named | a scrape with a client-credentials token (RFC 6749 §4.4) an [[auth.issuer]] signed for a client scrape_clients names | 401 unauthenticated with a WWW-Authenticate: Bearer challenge, for a scrape with no token, a token that does not verify or another scheme; 403 operation-refused for another client’s token or a person’s |
| A write action, such as the distribution | a caller whose access token carries auth.product_scope, from any peer | 401 unauthenticated without a token that verifies, 403 scope-insufficient without the operator scope |
A write action under profile = "development" | as above, and a loopback peer (127.0.0.0/8, ::1) that sends no credential | as above |
A read of the configuration revisions, GET /admin/config… | as a write action, and never recorded as one | as a write action |
A write action is verified by the same client authentication as the
gateway’s own listener, against the issuers of [auth] and in its mode,
and needs no purpose of use. Without auth.product_scope no caller is an
operator, so the write actions are refused to every caller outside the
development profile. A scrape user admits the scrape alone, never a write
action. Every refusal is counted under
ferrofed_security_events_total as admin-write-refused or
scrape-refused, and logged under the target ferrofed::security with
its reason, never the credential.
Every admitted write action is recorded under ferrofed::security in two
lines. The first, event admin-write-admitted, is written before the
action has any effect and is counted under the same name. The second,
event admin-write-finished, is written when the action ends, with its
outcome: the status it was answered, or abandoned when it never
answered because its client went away or its handler failed. Each line
carries the operator’s issuer and subject, the method and route
template as action, and the time as at (RFC 3339), and nothing else:
no token, header, path value, body or stored query. A loopback peer the
development profile admitted without a credential names no issuer or
subject. Neither the issuer nor the subject is ever a metric label.
The subject is the one the issuer put in the operator’s token, and it may
name a person. The gateway keeps it as issued, because a keyed hash under
a key that changes at every start could not attribute an action after a
restart. Keep these lines as long as you keep your other security records,
and restrict who reads them. No [telemetry] filter quiets the
ferrofed::security target below info, so the records are written
whatever the filter says.
scrape_clients names clients of an [[auth.issuer]] the gateway trusts
(Client authentication). The scraper
obtains its own token with the client-credentials grant (RFC 6749 §4.4),
and the gateway admits it only when ferrohealth-smart classifies the token
as a system’s, one with no person behind it, issued to a listed client. The
gateway admits no Basic user and takes no static bearer token, here or
anywhere else. Prometheus obtains the token with oauth2 in the scrape job:
scrape_configs:
- job_name: ferrofed
oauth2:
client_id: prometheus
client_secret_file: /etc/prometheus/secrets/ferrofed-scrape-client-secret
token_url: https://idp.example.org/realms/ferrofed/protocol/openid-connect/token
static_configs:
- targets: ["gateway.example.org:9464"]
Mutual TLS authenticates the scrape as well: with [metrics.tls],
client_auth = "required" and its client_ca_file, every client of the
listener must present a certificate that CA signed (TLS on the listeners).
Give the scraper such a certificate with tls_config in its job.
config check says who a listener that is not on a loopback address
answers. serve and config check refuse:
- a
listenaddress that is not a loopback address, such as0.0.0.0:9464, unlessallow_remote = trueis set. Set it only when the address is reachable from your scraper and your operators and nothing else, for example inside a pod network a network policy closes; the policy is defence in depth, and holds only where the cluster’s network plugin enforces it; - outside
profile = "development", alistenaddress that is not a loopback address with nothing to authenticate the scrape: neitherscrape_clientsnor a[metrics.tls]withclient_auth = "required"; scrape_clientswith no[[auth.issuer]]to sign their tokens;- a
listenaddress equal toserver.listen.
serve and config check refuse the collector as Tracing
describes: an endpoint that is neither an http:// nor an https:// URL,
and an http:// one with a user name or a password in it outside
profile = "development".
The push sends every 60 seconds; the standard OTEL_METRIC_EXPORT_INTERVAL
environment variable, in milliseconds, changes the interval. A push that
fails is logged at WARN under the target opentelemetry-otlp, and the
counts stay readable on /metrics. On SIGTERM the gateway pushes once
more after the drain.
[metrics] and [telemetry.otlp] take effect on a restart only: a
reload that changes one logs the key
as needing a restart, like [server].
A remote collector over TLS
An https:// endpoint pushes the metrics over TLS to a collector
anywhere, with the trust_roots_file and client_identity_file of
[telemetry.otlp] that Tracing
describes.
Reloading over the admin listener
POST /admin/reload runs the reload SIGHUP runs
(Reloading the registry): the
configuration file, [auth] and the listeners’ certificate files are read
again, checked as a whole, and applied together or not at all. It is a
write action, admitted for the operator scope and recorded with its
operator, and it takes no body:
POST /admin/reload
Authorization: Bearer <an access token with the operator scope>
It answers 200 with what it applied, the same facts the registry reloaded log line carries, and scope replica, since each process
reloads alone:
{
"members": 2,
"endpoints_added": [],
"endpoints_removed": [],
"members_removed": [],
"auth_changed": true,
"certificates": ["server.tls"],
"needs_restart": [],
"scope": "replica"
}
A refused reload answers 422 (configuration-invalid) with the refusal’s
class in its message, and the running configuration stays.
A container runtime that cannot send a signal into the container, such as
Kubernetes with the distroless image, which has no shell, reloads this way.
In the Kubernetes example, apply the changed ConfigMap, wait until the
kubelet has updated the mounted files (a ConfigMap mounted as a volume is
updated on the kubelet’s periodic sync,
https://kubernetes.io/docs/concepts/configuration/configmap/#mounted-configmaps-are-updated-automatically),
then send the reload to each pod, for example through
kubectl port-forward pod/ferrofed-0 9464:9464. A rolling restart of the
StatefulSet applies the same change, and also every setting a reload
leaves for a restart.
The metrics
The gateway names its instruments the OpenTelemetry way, and the Prometheus
exporter renders each name with _ for ., _total after a counter, and
the unit after a histogram.
| Prometheus name | Instrument | Type | Labels | Counts |
|---|---|---|---|---|
ferrofed_integrity_incidents_total | ferrofed.integrity.incidents | counter | kind | the integrity incidents the gateway emitted, one per incident line under ferrofed::integrity |
ferrofed_node_requests_total | ferrofed.node.requests | counter | endpoint, outcome, class | the requests the gateway sent to a member endpoint |
ferrofed_node_request_duration_seconds | ferrofed.node.request.duration (unit s) | histogram | endpoint | the time a member endpoint took to answer, with the series _bucket (and le), _sum and _count |
ferrofed_consent_prefilter_requests_total | ferrofed.consent.prefilter.requests | counter | outcome, reason | the calls to the consent pre-filter, by denied, no-signal, not-asked, unavailable or partial; a not-asked call, one that never reached the consent service, also carries a reason: namespace (the patient is named in a namespace the service is not asked by, such as a pseudonymised BSN for Mitz) caller-claims (the caller’s token does not state the claims the service is asked on behalf of), caller-claims-invalid (the token states them in a form the service’s question does not take) or patient-value (the patient’s value is not one the identifier the service is asked by takes) |
ferrofed_localizer_requests_total | ferrofed.localizer.requests | counter | outcome | the calls to the localizer, by candidates, no-records, not-configured, unavailable, or audit-failed for an XCPD exchange whose audit message could not be recorded |
ferrofed_demographics_requests_total | ferrofed.demographics.requests | counter | outcome | the calls to the demographics step, by identified, no-match, ambiguous, unavailable, or audit-failed for an exchange whose audit record could not be stored |
ferrofed_http_requests_total | ferrofed.http.requests | counter | http_request_method, http_route, status_class | the requests the gateway answered, by the route template and the status class 1xx to 5xx, a refused one included |
http_server_request_duration_seconds | http.server.request.duration (unit s) | histogram | http_request_method, http_route, http_response_status_code, url_scheme, network_protocol_version, and error_type on a 5xx | the time from receiving a request to answering it, the OpenTelemetry HTTP server metric (https://opentelemetry.io/docs/specs/semconv/http/http-metrics/) |
http_server_active_requests | http.server.active_requests | gauge | http_request_method, url_scheme | the requests being served now |
ferrofed_resolver_requests_total | ferrofed.resolver.requests | counter | outcome | the calls to the cross-reference resolver, a PIX Manager, one per patient lookup across the members asked, by resolved, not-resolved, unavailable (the service failed for a member) or time-out (its budget ran out) |
ferrofed_resolver_request_duration_seconds | ferrofed.resolver.request.duration (unit s) | histogram | none | the time each resolver call took |
ferrofed_localizer_request_duration_seconds | ferrofed.localizer.request.duration (unit s) | histogram | none | the time each localizer call took, an XCPD or NVI exchange; a not-configured call asks nothing and is not timed |
ferrofed_demographics_request_duration_seconds | ferrofed.demographics.request.duration (unit s) | histogram | none | the time each PDQm call took |
ferrofed_security_events_total | ferrofed.security.events | counter | event, and reason on caller-refused | the security events of the log targets ferrofed::security: a caller refused at client authentication by its reason, an issuer’s key set or introspection endpoint that cannot be had, and every identifier-hygiene event: a query refused before dispatch, a patient predicate stripped, a request the outbound gate stopped, a query parameter or a declared value refused, a probe refused, a stored-query definition refused, a patient grant’s confinement, an admin listener write action or scrape refused at the listener’s authentication, and an admin listener write action an admitted operator ran |
ferrofed_overload_refusals_total | ferrofed.overload.refusals | counter | limit, and endpoint on node-in-flight | the requests a limit refused (Overload protection) |
ferrofed_fhir_dispensations_total | ferrofed.fhir.dispensations | counter | outcome | the dispensations the FHIR face received that reached the owner lookup, once each, by created, unowned (no member holds the patient or the prescription, or none may say so), claimed (two members hold the prescription), several (one member holds it twice), unroutable (owners that differ, or a reference and an identifier that disagree), silent (a member that could own it did not answer the lookup), refused (the owner refused it), unknown (the owner did not answer, so whether it wrote it is unknown) or failed; no prescription, patient or member is ever a label (Receiving a dispensation) |
ferrofed_registry_reloads_total | ferrofed.registry.reloads | counter | result | the registry reloads SIGHUP asked for |
ferrofed_config_revision_follows_total | ferrofed.config.revision.follows | counter | result | under config.ui = "managed", the revisions another replica marked running that this replica applied or refused (Configuration revisions) |
ferrofed_config_revision_in_step | ferrofed.config.revision.in_step | gauge | none | under config.ui = "managed", 1 while this replica runs the revision the store marks running and 0 while it does not; present from the first read of the mark |
ferrofed_identity_feed_messages_total | ferrofed.identity_feed.messages | counter | result | the ITI-93 messages the identity feed received, by applied, refused (not held to the PMIR profiles), unauthenticated (no feed token) or audit-failed (its audit record could not be stored, and nothing was applied) |
ferrofed_audit_spool_events | ferrofed.audit.spool.events | gauge | none | the ITI-20 audit messages waiting in the spools for the audit repository and the FHIR Feed repository of [audit], summed; present only with one configured |
ferrofed_audit_spool_bytes | ferrofed.audit.spool.bytes | gauge | none | the bytes the spool holds, its quarantine included |
ferrofed_audit_quarantined | ferrofed.audit.quarantined | gauge | none | the audit messages in the spool’s quarantine, which could not be read or were no whole frame |
ferrofed_audit_delivered_total | ferrofed.audit.delivered | counter | none | the ITI-20 audit messages delivered to the audit repository since the process started |
ferrofed_audit_retries_total | ferrofed.audit.retries | counter | none | the failed attempts to deliver, each followed by a backoff |
ferrofed_audit_refused_total | ferrofed.audit.refused | counter | none | the audit messages the spool refused for want of room under spool_max_events or spool_max_size, the messages queued for a write counted with those stored; each fails its exchange as an audit failure |
target_info | the resource | gauge | service_name, service_version, telemetry_sdk_* | always 1: the gateway and its version |
Every label value comes from a closed set or from your registry document, never from a request, so no patient identifier, query text, header value or path reaches the surface:
| Label | Values |
|---|---|
kind | EhrIdCollision, IndexInsertCollision, LearnedCreatingSystemConflict, PrescriptionIdentifierCollision, RegisteredCreatingSystemConflict |
endpoint | an endpoint id of the registry document |
outcome | active, node-error, time-out, offline, consent-denied; on ferrofed_fhir_dispensations_total, created, unowned, claimed, several, unroutable, silent, refused, unknown, failed |
class | member (the member’s own answer or failure), client-error (a 4xx, other than a consent refusal, the member answered a request routed to it with) |
result | applied, refused |
http_route | a route template of the gateway, such as /v1/query/aql or /v1/ehr/{ehr_id}/composition, under the base path, or <unmatched> for a path no route names; a path identifier is written as its parameter name, never its value |
http_request_method | GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH, CONNECT, TRACE, QUERY, or _OTHER for any other method |
http_response_status_code, error_type | the HTTP status the gateway answered |
status_class | 1xx, 2xx, 3xx, 4xx, 5xx |
url_scheme | http where the listener speaks plain HTTP and TLS ends in front of it, https where it serves [server.tls] itself |
network_protocol_version | 1.0, 1.1 or 2, the HTTP version of the request |
event | caller-refused, key-set-unavailable, introspection-unavailable, aql-refused, patient-predicate-stripped, subject-parameters-consumed, outbound-gate-stopped, query-parameter-refused, parameter-value-refused, ehr-id-probe-refused, definition-subject-literal, held-definition-refused, patient-confinement, patient-context-unavailable, admin-write-refused, scrape-refused, admin-write-admitted, write-unrecorded, trc-patient-mismatch, trc-patient-unresolved, emergency-limit-reached: the event field of the log line |
reason | on ferrofed_security_events_total, why the gate refused the caller: missing, malformed, invalid, dpop-proof, dpop-token, unavailable, operation, scope, demographic-client, purpose-of-use, patient-context, patient-demographic, natural-person, assurance, contact-point-attributes, correlation |
limit | concurrency, caller-rate, node-in-flight |
le | a bucket bound in seconds: on the member, resolver, localizer and demographics histograms 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, +Inf; on http_server_request_duration_seconds the bounds the OpenTelemetry HTTP conventions advise, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, and +Inf |
The incident, reload, security event and dispensation counters, and the concurrency and
caller-rate refusals, show every label value at 0 from the start, so an
alert on their increase works from the first scrape. A node
request series appears with the first request to that endpoint, and an
endpoint a reload removes keeps its series until a restart.
Which calls the node request series cover
Six kinds of call send a request to a member. Each request one of them
sends is counted once in ferrofed_node_requests_total and timed once in
ferrofed_node_request_duration_seconds, under the same endpoint, so the
histogram’s _count equals the counter summed over outcome. A request
that never left the gateway is in neither: a member that was
not-resolved, excluded or not-localized, and a request the gateway
could not send, for want of a client or a credential, or because the
identifier-hygiene gate withheld it. §11.1 has no status for a request the
gateway could not send, so a member record in meta.federation still
reports that member offline; the series do not count it. A request whose
deadline passed before it left is not counted either, in any of the six
calls, because the node was never asked. The client still sees the budget
run out: a time-out in the member record, or a 504 (node-timeout) for
a routed request or a probe. A call the gateway fails with a 500 because
one of its own tasks panicked counts no member at all, since a defect in
the gateway is never a node’s time-out.
| Call | Requests counted | outcome read from | Time recorded |
|---|---|---|---|
A federated query, GET or POST {base}/v1/query/aql, and a stored-query invocation | one per member sent the query | its §11.1 status in meta.federation | its latency_ms |
| A fan-out template upload | one per member sent the upload | its §11.1 status in meta.federation | its latency_ms |
A stored-query distribution, a PUT naming members or the admin listener’s repair | one per member sent the definition | its §11.1 status in meta.federation | its latency_ms |
A stored-query drift check, a GET naming members | one per member asked for its copy | its §11.1 status in meta.federation | its latency_ms |
A request routed to one node: the {base}/v1/ehr/ area, GET {base}/v1/ehr?subject_id=…, a definition request naming one endpoint, a demographic request | one | the node’s answer | from sending it to the answer |
The ask-all probe, GET /ehr/{ehr_id} at every member for a read whose owner no earlier step named | one per member probed | the node’s answer | from sending it to the answer, or to the moment the overall budget ran out |
The per-member record a federated query, a template upload, a
distribution and a drift check write is the one the client reads in
meta.federation, and the counter follows it exactly. A routed request and
a probe have no such record, so their outcome is read from the node’s
answer by the same rules, the ones the access record of a
routed request names its origin’s status by, so the two never read one
answer apart. Each outcome therefore covers these calls:
outcome | Covers |
|---|---|
active | a member that answered with success, and a probed member that answered 404, which says it holds no such EHR |
node-error | in every call, any answer that is not a success, a 4xx included (except a consent refusal the registry names, and a probed member’s 404), and a node that refused the gateway’s onward credentials; in a drift check, a copy that differs from the registry’s definition or is missing. A 4xx a member answered a routed request with also carries class="client-error", so you can tell a client’s mistake from the member’s failure |
time-out | a member that gave no answer before its per-node deadline, and a member still being waited on when the overall budget ran out |
offline | a member the gateway sent a request to and could not reach |
consent-denied | a request whose node answered 403 with a consent refusal code the registry lists for it (Consent): a federated query member, a routed or by-subject read, or an ask-all probe, counted so whether or not the client’s answer withholds it (Withholding consent exclusions); a member a consent pre-filter dropped is sent no request and is not counted |
The latency in the self-description
OPTIONS {base}/ reports two statistics for each member endpoint, whether
or not [metrics] is on:
latency_ms_p50: the median time the endpoint took to answer.latency_ms_p95: the 95th percentile, the slow tail a median hides.
Both are computed over the same window: the last 100 requests the gateway
sent that endpoint, every call in the table above counted, the ask-all
probes included, and a time-out timed to its deadline. Each is the
nearest-rank percentile, so it is a time a request took, in whole
milliseconds. The window lives as long as the process and survives a
registry reload; an endpoint sent no request since the gateway started has
neither member. The operator console shows both side by side, and the
ferrofed_node_request_duration_seconds histogram carries the same
requests, so you can read a percentile over any range from Prometheus with
histogram_quantile(0.95, sum by (le, endpoint) (rate(ferrofed_node_request_duration_seconds_bucket[5m]))).
Dashboard and alert rules
Every release attaches a Grafana dashboard, ferrofed-dashboard.json, and a
Prometheus rule file, ferrofed-alerts.yaml; both are in the repository
under deploy/observability/. Import the dashboard and pick your Prometheus
as its datasource: it charts the requests by status class and route, the
members by outcome and answer time, the resolver, localizer and demographics
calls, the security events, the limits’ refusals, the integrity incidents
and the audit spool. Load the rule file through rule_files in
prometheus.yml, or wrap its groups in a PrometheusRule for the Prometheus
Operator. Its alerts carry severity: page or severity: ticket:
| Alert | Fires when |
|---|---|
FerroFEDServerErrors | over 5% of the answers are 5xx for 10 minutes |
FerroFEDSlowAnswers | the 95th percentile answer takes over 20 seconds for 10 minutes |
FerroFEDOverloaded | the concurrency limit refuses requests for 5 minutes |
FerroFEDCallerRateLimited | a caller is rate limited for 15 minutes |
FerroFEDMemberFailing | a member endpoint fails over 10% of its requests for 10 minutes, a 4xx it answered a routed request with (class="client-error") left out |
FerroFEDMemberCapSaturated | a member’s in-flight cap stays full for 5 minutes |
FerroFEDResolverFailing, FerroFEDLocalizerFailing | the resolver or the localizer does not answer for 5 minutes |
FerroFEDIntegrityIncident | an integrity incident is raised |
FerroFEDRegistryReloadRefused | a registry reload is refused |
FerroFEDAuditSpoolBacklog, FerroFEDAuditRefused | the audit spool holds over 1000 records for 15 minutes, or refuses one |
FerroFEDOutboundGateStopped | the outbound gate stops a request that would have carried a patient identifier |
FerroFEDWriteUnrecorded | the record of a write a member was asked to make could not be stored in the place reserved for it, so the write stands unrecorded |
FerroFEDCallerRefusals | callers are refused at authentication over once a second for 10 minutes, by reason |
FerroFEDAuthenticationUnavailable | an issuer’s key set or introspection endpoint cannot be had |
The thresholds are a starting point; tune them to your traffic. Add an alert
on your scrape job’s up as well, since a gateway that does not answer the
scrape counts nothing. scripts/checks/observability.sh holds both files to
the metrics the gateway exports, and runs promtool check rules.
The Kubernetes package (deploy/kubernetes/base/) serves the admin listener on
port 9464 of each pod, its scrape client named in scrape_clients, annotates the pods for a Prometheus
that reads prometheus.io/scrape, and opens the port to the Prometheus pods
of the monitoring namespace and the console alone with a network policy.
Give the Prometheus job the scrape client’s secret with
oauth2.client_secret_file, or add the service-monitor component for the
Prometheus Operator (The Kubernetes package). The
gateway authenticates the scrape and every write action by itself, so the
policy is defence in depth.
The gateway does not call a webhook. An incident is counted, and its log
line under ferrofed::integrity carries the routing ids you act on; route
the alert from your Prometheus or your collector.
Tracing
The gateway can export its own spans to an OpenTelemetry collector over
OTLP, so a federated query shows up as one trace: the client request at the
root, the resolution and the fan-out under it, and one span per request to a
member node. Each node request also carries a W3C traceparent of the
gateway’s own trace, so a node that traces joins the same trace. The export
is off by default, and with it
off no node request carries a traceparent.
Turning it on
[telemetry.otlp]
endpoint = "http://127.0.0.1:4317" # an OTLP gRPC collector; without the table, no span is exported
traces = true # export the spans; true unless set
sample_ratio = 1.0 # the share of requests whose spans are exported, 0.0 to 1.0
The same table pushes the metrics to the same collector unless
metrics = false, under one resource: service.name = "ferrofed", the
version, and deployment.environment.name when [telemetry] environment
names one. serve and config check refuse an endpoint that is neither
an http:// nor an https:// URL. Over http:// the export speaks gRPC
without TLS, so run that collector beside the gateway, on the same host or
in the same pod; over https:// it speaks gRPC over TLS
(A remote collector over TLS). They also
refuse an http:// endpoint with a user name or a password in it, outside
profile = "development", since that credential would travel in cleartext
(What must travel encrypted).
serve refuses a [telemetry.otlp] table with no endpoint.
Spans leave in batches. On SIGTERM the gateway flushes the spans and the
metrics it still holds after the drain. An export that fails is logged at
WARN under the target opentelemetry-otlp; the gateway serves on. The
export takes effect on a restart only: a
reload that changes the table logs
telemetry.otlp as needing a restart.
telemetry.filter decides what the console logs and nothing else, so a
quieter log never thins a trace. It never quiets the security log: the
console writes every line under the target ferrofed::security at info or
above whatever the filter says.
A remote collector over TLS
An https:// endpoint exports the spans over TLS to a collector anywhere,
with no forwarder beside the gateway. The gateway trusts the platform’s
roots, and two keys add what a private collector needs:
[telemetry.otlp]
endpoint = "https://otel.example.org:4317"
trust_roots_file = "/run/secrets/ferrofed/otel-ca.pem" # PEM roots beside the platform's
client_identity_file = "/run/secrets/ferrofed/otel-client.pem" # chain and key, PEM, for mutual TLS
Each file is read once, at start, and checked: the client identity must hold
a certificate and its private key, and the trust roots at least one
certificate. serve refuses either key beside an http:// collector, since
it would go unused. A user name or a password in an https:// collector URL
passes the transport rule. A changed file takes effect on a restart.
Sampling
sample_ratio sets the share of client requests whose spans are exported,
from 0.0, none, to 1.0, every one. It is 1.0 unless set, and serve
and config check refuse a value outside 0.0 to 1.0. Every trace is the
gateway’s own, so the decision is made once, at the request span, from its
random trace id, and every span under it follows that decision: a request’s
span tree is exported whole or not at all. A node request of a trace that is
not sampled still carries a traceparent, with its sampled flag off
(-00), so a node that traces knows the gateway did not keep that trace.
Like the export itself, the ratio takes effect on a restart only.
The spans
| Span | Under | Attributes |
|---|---|---|
{method} {route}, such as POST /v1/query/aql | nothing: the root of the gateway’s own trace | http.request.method, http.route (the route template), http.response.status_code, request_id (the id the gateway minted) |
localize | the request | members |
consent_prefilter | the request | members |
resolve | the request | members, resolved |
fan_out | the request | endpoints, http.response.status_code |
merge | fan_out | endpoints, rows |
probe | the request | endpoints, holding |
template_fan_out, stored_query_distribution, stored_query_drift | the request | members |
node_request | whichever of the above sent it, or the request for a routed request | endpoint_id, operation (the ITS-REST operationId), contact (answered, silent or unsent), http.response.status_code, outcome (the §11.1 status, for a query or a stored definition) |
The export layer adds target and the span’s busy and idle time to each.
Every value is a route template, an id from your registry document, an
ITS-REST operation, a status or a count. No span carries a patient
identifier, query text, a header value, a body, a token or a path with its
ids, and no tracing event is exported: a log line stays in the log. A path
ehr_id is not recorded either, because the gateway cannot tell an ehr_id
it was handed from a patient identifier, and the route template already says
which resource was read.
The trace context
Every client request starts a trace of the gateway’s own, with a random
trace id, as W3C Trace Context §3.4 and §6.1 allow a service to do. The
gateway never reads a client’s traceparent or tracestate: it neither
continues the client’s trace nor links to it, and no exported span records
any part of it. A client chooses the trace id it sends, and 32 hexadecimal
characters can encode anything, a patient identifier included
(3132333435 is 12345). A trace id taken from a client would carry that
identifier to every node, and a link to the client’s span would carry it
into your collector, and the gateway lets no patient identifier into a span
any more than into a node request (§5.4.1, N33). Every trace id and span id
your collector receives is one the gateway generated.
ITS-REST declares no trace header, and a node that does not trace ignores
it (RFC 9110 §5.1). With the export on, each node request carries a
traceparent that names the gateway’s trace and the span of that node
request as the parent, so a node that traces joins the gateway’s trace. No
part of it comes from the client. On a routed request, the client’s own
traceparent and tracestate are stripped like every header the ITS-REST
operation does not declare.
The usage report
ferrofed serve sends a small JSON report to FerroPULSE, a collector that
Cadasto B.V. runs for every FerroHEALTH product. It goes out at each process
start and once a day. It says that the gateway is alive, which version it
runs, how many member nodes it federates as a range, which identity and
consent services it is configured with, and how fast it answers, in coarse
aggregates. The report is on by default. Every boot logs one INFO line that
says whether it is on and how to switch it off.
This page lists every field the report carries, says when it is sent, gives
the switch, and carries the privacy notice for the data the collector stores.
The wire format is FerroPULSE’s report envelope v2, sent by the family’s
ferrohealth-usage-report crate every FerroHEALTH product shares; no openEHR
or Federation Tier specification governs any of it, and the report is the
family’s own design.
- When a report is sent
- What a report contains
- What a report never contains
- Switch it off
- Network
- Privacy notice
When a report is sent
There are two kinds of report, and both are one HTTPS POST to
https://report.ferropulse.eu/v1/report with a JSON body of at most 64 KiB.
start: at eachservestart, once the gateway listens. A gateway sends at most one start report per ten minutes: the time of the last one is kept in the state directory, so a container in a restart loop sends one start report per ten minutes, not one per restart.daily: the first attempt 10 minutes after the start report, then about every 24 hours (23.5 to 24.5 hours after the previous one, spread at random). It covers the requests since the previous daily report of the same process, or since the gateway started.
The jobs config check, healthcheck, admission check, conformance run and
report send nothing.
The report needs a state directory, [usage_report] state_dir
(FERROFED__USAGE_REPORT__STATE_DIR), where the gateway keeps the instance
id. Without one, or with one it cannot write, the gateway sends nothing:
without a stable id each restart would count as a new installation. The
release’s compose file, the quickstart’s compose.yaml and the Kubernetes
example turn it on: each sets
FERROFED__USAGE_REPORT__STATE_DIR=/var/lib/ferrofed/usage-report on the
writable volume at /var/lib/ferrofed, and the two compose files also set
FERROFED__USAGE_REPORT__DEPLOYMENT=compose. The container image sets no
state directory, so a plain docker run sends nothing until you mount a
writable volume and point the key at it:
docker run -v ferrofed-state:/var/lib/ferrofed \
-e FERROFED__USAGE_REPORT__STATE_DIR=/var/lib/ferrofed/usage-report \
ghcr.io/ferrohealth/ferrofed:<version>
A bind mount needs a host directory that uid 65532 can write.
The boot line, with the report on, reads:
usage report ON: instance id, version, licence type, deployment, uptime, host size, member count as a range, the configured identity services, coarse request latency per route group and node request counts, at each start and once a day; no clinical content, identifiers, URLs or request paths. Off: FERROFED__USAGE_REPORT__ENABLED=false. Details: https://ferrofed.eu/docs/operate/usage-report.html
It carries the configured endpoint as the structured field endpoint. With
the report off, the line says why, for example:
usage report OFF: switched off (FERROFED__USAGE_REPORT__ENABLED=false); nothing is sent. Details: https://ferrofed.eu/docs/operate/usage-report.html
usage report OFF: CI detected (GITHUB_ACTIONS); nothing is sent. Details: https://ferrofed.eu/docs/operate/usage-report.html
usage report OFF: no state directory (FERROFED__USAGE_REPORT__STATE_DIR is unset), and without a stable instance id each restart would count as a new installation; nothing is sent. On: mount a writable volume and set FERROFED__USAGE_REPORT__STATE_DIR to it. Details: https://ferrofed.eu/docs/operate/usage-report.html
What a report contains
Every report carries these fields:
| Field | Sent in | What it is |
|---|---|---|
schema | both | Always 2, the version of the report format. |
event | both | start or daily. |
first_start | start | true when this start created the instance id, which marks a new installation; false on every later start. |
product | both | Always ferrofed. |
instance_id | both | A random UUID (version 4), created at the first start and stored in <state_dir>/instance-id. It is not derived from the host or anything else about the deployment. |
version | both | The gateway version, for example 0.0.12. |
git_sha | both | The commit the release lane built the binary from, 7 to 40 hex digits. A local build sends 0000000. |
licence | both | The licence grant type, non-commercial (what the Business Source License grants everyone). |
deployment | both | How the gateway was deployed: helm, compose, binary or unknown, from [usage_report] deployment, which the compose file sets to compose. |
uptime_s | both | Seconds since this process started. |
cpu_bucket | both | The CPUs available to the process, as a range: 1-2, 3-4, 5-8, 9-16 or 17+. |
memory_bucket | both | The memory available to the process in GiB, the smaller of the host’s memory and the container limit, as a range: <4, 4-8, 8-16, 16-32 or 32+. Off Linux the gateway cannot read it and sends <4. |
status | both | The state facts below. |
metrics | daily | The performance aggregates below. |
The status object:
| Member | What it is |
|---|---|
profile | The deployment profile, production or development. |
members | The member nodes of the registry the gateway federates, as a range: 0, 1-2, 3-5, 6-10, 11-50 or 51+. Never a node name, an endpoint id or a URL. |
services | The identity, localization and consent services the configuration names, sorted: mcsd, mitz, nvi, pdqm, pixm, pmir, xcpd. Never a service URL or a credential. |
stored_queries | Whether the gateway offers the stored-query registry. |
fhir | Whether the gateway serves the FHIR R4 face of the exchange format. |
A start report, formatted here for reading (the gateway sends it on one line):
{
"schema": 2,
"event": "start",
"first_start": true,
"product": "ferrofed",
"instance_id": "5f0c6b2e-3d4a-4c8b-9e1f-2a3b4c5d6e7f",
"version": "0.0.12",
"git_sha": "1a2b3c4d",
"licence": "non-commercial",
"deployment": "compose",
"uptime_s": 4,
"cpu_bucket": "1-2",
"memory_bucket": "<4",
"status": {
"profile": "production",
"members": "3-5",
"services": ["pdqm", "pixm"],
"stored_queries": true,
"fhir": false
}
}
The daily metrics
The daily report adds one object that covers the window since the previous daily report:
"metrics": {
"window_24h": {
"requests_bucket": "1k-10k",
"groups": {
"query": {
"p50_ms": 180.2, "p95_ms": 610.0, "p99_ms": 1400.5,
"errors_5xx": 0,
"histogram": [0, 0, 4, 40, 300, 900, 500, 60, 5, 1, 0]
}
},
"counters": {
"node_requests": 3620, "node_timeouts": 4, "node_errors": 2,
"responses_4xx": 12
}
}
}
-
requests_bucket: the number of counted requests in the window, as a range:0,1-100,100-1k,1k-10k,10k-100kor100k+. Each range includes its lower bound and excludes its upper one, so1-100means 1 to 99. The health probes (/health,/health/readiness) and the JWK Set are not counted. -
groups: one entry per group of requests that saw at least one in the window. A request is placed in a group by the route template it matched under{base}, never by the path or the parameters the client sent:Group Routes query{base}/v1/query/aql, and a stored query under{base}/v1/query/composition{base}/v1/ehr/{ehr_id}/compositionand every path under itehrevery other route under {base}/v1/ehrdefinitionevery route under {base}/v1/definition/fhirevery route under the FHIR face’s base operatorevery route under {base}/operator/otherevery other route, OPTIONS {base}/among them, and a request no route matched -
p50_ms,p95_ms,p99_ms: the median, 95th and 99th percentile latency of the group in milliseconds, estimated from its histogram. -
errors_5xx: the responses with a 5xx status. -
histogram: 11 request counts, one per latency bucket. The bucket upper bounds in milliseconds are 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, and no bound for the last. -
counters: each counter that counted at least one event in the window; one that counted none is left out.node_requests: the requests the gateway sent to a member node, a federated query’s fan-out, a routed request and an ask-all probe alike.node_requestsover thequerygroup’s requests is the average fan-out.node_timeouts: the node requests reportedtime-out(§11.1).node_errors: the node requests reportednode-errororoffline(§11.1).responses_4xx: the responses with a 4xx status over every counted group.
What a report never contains
A report carries no patient data, no patient identifier, no AQL text, no
query parameter, no result row, no ehr_id or other openEHR identifier, no
request path, no node name, endpoint id or URL, no service URL, no
credential, no error message, no user name, no client IP address, no host
name and no customer or staff name. The report types in the gateway have no
field that could hold any of these, and the collector refuses a report with a
value that is not a boolean, a number or a short lower-case token.
The collector does record the IP address the report arrives from. See What the collector stores.
Switch it off
FERROFED__USAGE_REPORT__ENABLED=false
or, in the configuration file:
[usage_report]
enabled = false
The release’s compose file and the quickstart’s compose.yaml forward this
variable from your shell or .env, so
FERROFED__USAGE_REPORT__ENABLED=false docker compose up switches the report
off there too. The quickstart’s FerroEHR nodes and its FerroPIX report to
FerroPULSE as well, each as an installation of its own product, and the same
FERROFED__USAGE_REPORT__ENABLED=false stops theirs.
FERROEHR__USAGE_REPORT__ENABLED, when you set it, decides for the nodes
alone. In a CI job the report is off by itself: when
GITHUB_ACTIONS or CI is true or 1, the gateway sends nothing, and
enabled = true does not change that. A container does not inherit the
runner’s environment, so a test lane that starts the image passes
--env FERROFED__USAGE_REPORT__ENABLED=false; the guard
scripts/checks/usage-report-off.sh fails a launcher in this repository that
does not.
With the report off, the gateway sends nothing to FerroPULSE and writes
nothing to the state directory, and the boot line says usage report OFF.
Nothing else in the gateway changes. Switching it off stops new reports only.
Reports already stored at the collector stay until the retention period ends,
unless you ask for them to be erased. A change of [usage_report] takes a
restart (Reloading the registry).
Network
- Destination: outbound HTTPS to
report.ferropulse.eu, port 443.[usage_report] endpointmoves it; the gateway refuses to start on an endpoint that is plain HTTP (except to a loopback host) or that carries credentials. - One direction only: the gateway reads the status code of the answer and nothing else. It does not follow redirects, and nothing the collector answers changes the gateway’s configuration or behaviour.
- Failures stay local: a send gives up after 5 seconds without a connection or 10 seconds in total. A failure is logged at DEBUG, and a refusal by the collector at WARN. Boot, readiness and request handling never wait on the report, so a slow or unreachable collector affects none of them. A daily report that does not reach the collector is tried again an hour later with the same window; one the collector answered, even with a refusal, is not resent.
Privacy notice
This section gives the information that Articles 13 and 14 of the GDPR list, for the personal data a report and its stored record can contain. A report names no person. It can still relate to one: the collector stores the address each report comes from, and a gateway run by one person on their own internet connection can be linked to that person through the address or through the instance id. Where that is so, the report is personal data, and this notice applies to it. The collector is not anonymous.
Controller
Cadasto B.V., the Netherlands. Write to info@cadasto.com or use https://www.cadasto.com/contact/. Cadasto has not appointed a data protection officer.
Purposes and lawful basis
Cadasto processes the reports for three purposes:
- to know which FerroFED versions run in the field, so it can tell how many installations a security release concerns and whether they upgrade;
- to know which identity and consent services the installations use and how large their federations are, as ranges;
- to see how the routes and the member nodes perform in real use, so it can find and fix slow paths.
The lawful basis is Art. 6(1)(f) GDPR, legitimate interest. The interest is Cadasto’s, as the maker of FerroFED: keeping the product it publishes secure and maintained, which needs the versions in use and the reach of each security release, and improving it, which needs the service mix and field performance. The data come from the FerroFED gateway itself. You are not obliged to send them: the report can be switched off, and switching it off changes nothing else in FerroFED.
What the collector stores
- Every report as it arrives, with the fields listed above.
- With each report: the time of receipt, the UTC day, the source IP address (IPv4 or IPv6), and the country derived from that address.
- Per instance id: when it was last seen, its current version, and whether it is alive.
- Per day: latency totals summed across the whole fleet, without instance ids.
The country comes from an IP-to-country database bundled with the collector, so no address leaves the collector host for the lookup. IP geolocation by DB-IP, licensed CC BY 4.0. Reports from the address ranges of GitHub-hosted CI runners are marked as such and left out of every dashboard.
Recipients and location
Hetzner Online GmbH (Gunzenhausen, Germany) hosts the collector in its cloud and processes the reports as Cadasto’s processor, under a data processing agreement (Art. 28 GDPR). The collector runs in Hetzner’s data centre in Falkenstein, Germany. The FerroPULSE dashboard that shows the reports sits behind a GitHub login with no public or anonymous access; only members of the FerroHEALTH organisation can sign in.
Retention
A daily job deletes reports and start reports 13 months after they were received, an instance record 13 months after the instance was last seen, and the fleet-wide daily totals 13 months after their day. Backups of the collector’s database are kept for 14 days, so a deleted report can remain in a backup for at most 14 days more.
Your right to object
You have the right to object at any time, on grounds relating to your particular situation, to the processing of reports that are personal data concerning you (Art. 21(1) GDPR).
To stop future reports, switch the report off as described in Switch it off. No request to anyone is needed.
To have the reports already stored erased, write to info@cadasto.com with the instance id, which the gateway keeps in
<state_dir>/instance-id. Under the release’s compose file it sits on theaudit-spoolvolume:docker run --rm -v <project>_audit-spool:/s busybox cat /s/usage-report/instance-idprints it. Cadasto then deletes every report, start report and instance record stored for that instance id, source addresses included (Art. 17(1)(c) GDPR). Switch the report off first, or the next report starts a new record.The fleet-wide daily totals hold no instance ids, so an erased instance’s share in days already summed stays in those totals.
Your other rights
At the same address you can ask for access to the reports stored for your instance id (Art. 15), for their rectification (Art. 16) or erasure (Art. 17), or for restriction of their processing (Art. 18). You have the right to lodge a complaint with a supervisory authority, in particular in the Member State of your habitual residence, place of work or place of the alleged infringement (Art. 77).
The operator console
The operator console is a web interface to a FerroFED federation, for the
people who run it. It ships as its own binary, ferrofed-console, and its own
image, ghcr.io/ferrohealth/ferrofed-console, beside the gateway. No
specification governs the console: it is FerroFED’s own design.
The console was named ferrofed-viewer before v0.0.12. To upgrade, pull
ghcr.io/ferrohealth/ferrofed-console in place of
ghcr.io/ferrohealth/ferrofed-viewer, rename every FERROFED_VIEWER__
override to FERROFED_CONSOLE__ and FERROFED_VIEWER_CONFIG to
FERROFED_CONSOLE_CONFIG, and run the binary as ferrofed-console. The
cookies are renamed with it, so every operator signs in once more.
The console is one more client of the gateway. It reaches the gateway over
HTTP, on the same public surface any other client uses, and it adds no
behaviour to the gateway. It stores no clinical data: the query console
shows the signed-in operator the rows a query answered, and neither the
console nor the browser keeps them. Every page and every answer is
Cache-Control: no-store, and the page puts nothing in browser storage, a
service worker or a URL.
What is built
-
A sign-in page and a shell, built on FerroUI. The console draws every page with
ferrohealth-ui, the FerroHEALTH component library, and its FerroFED theme. The server draws every page whole, and FerroUI’s small script modules add the menus, the dialogs, the row editing and the find’s suggestions on top; with scripts turned off every page still reads and every action still posts. A visitor reads a sign-in card. A signed-in operator lands on a dashboard and reads every page in a shell: a top bar with the find field, the gateway’s status (which names the gateway’s version and the replica that answered), and the operator’s name, which opens a menu holding Your preferences, the theme and Sign out; and a navigation grouped under Overview, Federation, Queries, Gateway and Console that marks the page you are on and folds to a rail. On a narrow screen the navigation is a drawer the Menu button opens. Your preferences (/preferences) has three tabs, Appearance, Language and region, and Keyboard; each preference is kept in anHttpOnlycookie of its own (ferrofed_console_theme,ferrofed_console_density,ferrofed_console_motionand the others), holding one word and nothing else, for a year, and the server draws each page in them. The footer of every page names the product, its version and Cadasto B.V., and links the About page, which names the manufacturer in full with the gateway the console reads and holds the gateway’sOPTIONSdocument, with its download. -
Operator sign-in at an OpenID Provider:
GET /loginredirects the browser to the provider’s authorization endpoint with the authorization code grant, anonceand a PKCE challenge (RFC 6749 §4.1, RFC 7636). Thestate, thenonceand the PKCE verifier stay on the console’s server as a pending sign-in, which the browser knows only by an opaqueHttpOnlycookie that expires with it. The provider’s redirect back to/auth/callbackis checked against it once. The code then goes to the provider’s token endpoint with the PKCE verifier (RFC 6749 §4.1.3, RFC 7636 §4.5), with the client secret as HTTP Basic for a confidential client. The ID Token that comes back must verify against the provider’s JWK Set and carry the configured issuer, the console’s client id as its one audience, anexpstill ahead, and the sign-in’snonce(OpenID Connect Core 1.0 §3.1.3.7). An ID Token for more than one audience, or signed with a shared secret, is refused. Only then does a signed-in session begin, holding the operator’s access token on the server, and the browser goes back to/. A session the browser already held ends when the new one begins. An ID Token that fails a check is401, and a provider that refuses or cannot be reached is502. -
The access token is refreshed on the console’s server. When the provider answers the sign-in with a refresh token, the session keeps it on the server beside the access token; it never reaches a cookie, a page, the browser or a log line (RFC 6749 §10.4). A request that arrives within 60 seconds of the access token’s expiry, or in the second half of a lifetime shorter than two minutes, first sends the refresh token to the token endpoint (RFC 6749 §6), with the client secret as HTTP Basic for a confidential client, and the session goes on with the new access token. A rotated refresh token replaces the old one, which is never sent again, and one refresh of a session runs at a time, so requests that arrive together spend a rotated token once. A refresh the provider refuses with an error answer (RFC 6749 §5.2),
invalid_grantor any other, ends the session: a view sends the browser to/loginand drops the session cookie. A provider that cannot be reached, or that answers with a server error, leaves the session to try again on the next request, and a view sends the browser to/loginmeanwhile. A session with no refresh token ends when its access token expires, and one whose provider sends noexpires_inis never refreshed. The ID Token of the sign-in stays the sign-out’s hint; one a refresh answers is not read. Setoffline_access = truein[oidc]to ask for theoffline_accessscope and theprompt=consentit requires (OpenID Connect Core 1.0 §11), for a provider that issues a refresh token only then. -
Operator sign-out: the “Sign out” button in the operator’s menu, which the operator’s name in the top bar opens, posts to
POST /logout, which ends the signed-in session on the console’s server and removes the session cookie. Where[oidc]names the provider’send_session_endpointand the session held an ID Token, the console then redirects the browser there with that ID Token asid_token_hint, itsclient_id, and thepost_logout_redirect_uriyou registered with the provider (OpenID Connect RP-Initiated Logout 1.0 §2). Otherwise (no endpoint, no[oidc]at all, a Basic sign-in, or no session left to end) the browser goes back to/. A sign-out never answers503. AGET /logoutis405. -
The session watch.
GET /sessionanswers what the session has left,{"idle": s, "absolute": s}in seconds, andPOST /sessionextends it as activity does; once the session is gone both answer401with an RFC 9457 problem body. Every page carries FerroUI’s session watch over it, which warns before the idle timeout, offers “Stay signed in”, and ends every tab together. -
Requests from the console’s own pages only. Every request that is not a
GET,HEADorOPTIONS, the sign-out and every form among them, must come from the console’s own pages: a request the browser marksSec-Fetch-Site: same-origin, or, without fetch metadata, one whoseOrigin, or else whoseReferer, has the origin ofredirect_uri. Any other, one that names no origin among them, is403before the session is read or the gateway asked, so no other site can sign an operator out or spend their sign-in on a query. The session cookie isSameSite=Laxas well, and every action is aPOSTto the page it sits on: aGETruns nothing. -
Two separate pools of server-side state. Pending sign-ins live for
sign_in_timeout_sand are bounded bymax_sign_ins; a full pool drops its oldest pending sign-in, so a flood ofGET /loginholds at most that many and stops costing anything once it ends. A signed-in session is created only once a sign-in completes, lives foridle_timeout_swithout a request andabsolute_timeout_sat most, and is bounded bymax_sessions, which refuses a new session rather than evicting one. The idle and the absolute timeouts end a session whatever its tokens say, however long a refresh token would last. No sign-in traffic ever removes a signed-in session. The console does not limit sign-ins per client: behind a load balancer it cannot tell clients apart without trusting a forwarded address, so put a rate limit for/loginat the edge. Both pools live in the console’s memory: a restart ends every session, and more than one replica needs a load balancer that keeps an operator on one replica. -
The pages, each rendered on the console’s server from the gateway’s own surface, called with the signed-in operator’s own access token:
Page What it shows Read from /the dashboard: the organisations, the nodes reachable, the open incidents and the running configuration, the nodes that need attention, the topology in brief, the open incidents and the configuration at a glance the pages below /topologythe gateway, the services it asks and the organisations with their nodes, as a map or a list, filtered by state, with How it is drawn saying what each edge’s line and mark mean OPTIONS {base}/andGET {base}/operator/dependencies/organisations,/organisations/nodesevery organisation with its nodes and worst state, and every node with its organisation, system_id, state and median and 95th percentile latencythe same /organisations/{org},/organisations/{org}/{node}one organisation and its nodes; one node with its facts, its endpoints and, on its Routes tab, the creating systems the routing table sends to it the same, and GET {base}/operator/creating-systems/servicesevery service the gateway asks, grouped, with its last observed state GET {base}/operator/dependencies/integritythe open and the resolved integrity incidents and the count of each kind since the gateway started GET {base}/operator/incidents/stored-queries,/stored-queries/{name}every stored query by namespace with its versions, a namespace the gateway takes no write under marked read-only; one stored query with the AQL of the version shown and, on its Versions tab, every version GET {base}/operator/stored-queriesand/operator/stored-queries/{name}/self-descriptionwhat OPTIONS {base}/declares, on the tabs Federation (with the manufacturer it names), Capabilities, ITS-REST areas, Limits and EndpointsOPTIONS {base}//searchthe organisations, nodes and stored queries the find field names the same /search/answer?q=the same answer as JSON, which the panel under the find field reads as you type the same A page without a signed-in session sends the browser to
/loginbefore the gateway is asked anything, whatever the case of its path or a trailing slash. A page the gateway refuses shows the gateway’s status and its stable error code; a401links back to sign-in. A page whose answer the console cannot read says so with the status, and one the gateway cannot answer says so too; no page is ever silently empty. The/operator/routes need a token carrying the operator scope its issuer names. No page shows a patient identifier, a token or clinical data.The top bar’s status names the gateway replica that answered, as the gateway’s operator surface names it. The health, the incidents and the routing table are each replica’s own, so read them as that replica’s view, never the federation’s. The console reads the gateway through the one
base_urlit is configured with, so behind a load balancer it shows the replica the balancer chose for each request. -
The query console,
/query: an AQL query, or a stored query by name and optional version, runs through the gateway’s own query surface,POST {base}/v1/query/aqlorPOST {base}/v1/query/{name}, as the signed-in operator, exactly as any client sends it. The form offers what the gateway’sOPTIONS {base}/declares: the member endpoints and organisations to target (openEHR-federation-endpoint,openEHR-federation-organisation, §8.4), its dedup modes (openEHR-federation-dedup, §10), and the best-effort opt-in (openEHR-federation-completeness: partial, §11.4) where it offers one.offsetandfetchare optional. The answer shows the rows undercolumns[]as the gateway renders them, and every endpoint ofmeta.federation.endpoints[]with its status, latency, row count and error. An answer whosemeta.federation.completeisfalsesays “Incomplete answer” in words before its rows. A504or424the gateway answers under its all-or-nothing default shows its status and the endpoints that failed, from the diagnostic envelope it carries (§11.4). A refusal shows its status and stable error code, and a result set with no readablemeta.federationis shown as unreadable, never as an answer.Name a patient through a parameter, a name and a value on a row of their own, never in the AQL text; each value is sent as a string. The form posts its fields in the request body to the console, which sends them to the gateway in the request body and answers with the page, the answer under the form, so nothing you enter reaches a URL or the browser history. The fields carry
autocomplete="off", and the console logs a refusal by its status and code alone, never the query or a parameter. -
The operator’s admin actions, where
[admin]names at least one gateway replica./integrityoffers the clear of a heldehr_id, which callsPOST /admin/ehr-ids/{ehr_id}/clearon the admin listener of the replica you pick, or of every replica one after another, since each replica keeps its own routing state. The form offers theehr_ids the incidents name. A stored query’s page offers the drift repair of the version shown, which callsPOST /admin/stored-queries/{name}/{version}/distributewith the members inopenEHR-federation-endpoint(*for every active member) on the first replica configured, since the replicas share the stored-query store. Each action is a form in a confirmation dialog, which the console checks again on its server. Its answer shows at the top of the page and names the replica it went to: what a clear found there, or whether every member took the version and each member’s outcome. A replica that refuses shows its status and stable error code, a401links back to sign-in, and one the console cannot reach is named as such. The console calls the admin listener with your own access token, which never reaches the browser, so the gateway admits the action only for a token carryingauth.product_scopeand records you as the operator who ran it. -
/configuration, where[admin]names at least one gateway replica whose configuration is managed (config.ui = "managed"). It reads the first replica’s configuration revisions. The header names the revision that runs and the one saved and not applied, and a card above every tab shows what the saved revision changes, each setting with its running value marked−and its new value marked+, and its apply. Five tabs, each at its own path, hold the rest:/configuration/members, every member with its owner and state;/configuration/credentials, every onward credential and binding section with its secrets as set, with the file each is read from, never by value;/configuration/stored-queries, the stored versions by name;/configuration/history, the newest revisions, each with its download as TOML and its comparison with the running revision, filtered by origin in the Origin menu; and/configuration/import-export, the running revision’s download and the import of TOML as a dry-run revision. A member the registry document or a care services directory provides opens as a read-only detail that names its source. A console member is added or edited on a page of its own (/configuration/members/new,/configuration/members/{node_id}), each Query API and FHIR endpoint a sub-card of its own fields, with its categories as checkboxes and its profiles as a list of rows, and saved as a new revision, which you then apply. A credential’s form shows the fields of the grant you choose, and each secret is kept, set by its value in a masked field the page never fills, whose eye shows what you typed while you hold it, read from a file, or cleared; a binding section’s form shows each setting as a field of its own. When the running revision awaits its confirmation, the card counts down to its deadline and offers Confirm and Roll back; when the saved revision takes a second operator’s approval, it shows who made it, who approved it, and Approve (Credentials and binding sections). An apply, a roll back, a confirmation, an approval, a removal and a stored version each ask you to confirm in a dialog of their own, which the console checks again; a save is not applied and asks none. A console member’s or section’s row menu (⋯) holds Edit and Remove…, which asks in a dialog for the reason the revision keeps; its own page offers the removal too. How configuration works opens an explanation of what stays in the file and what a revision holds. Every form is a plain post to its page: with scripts turned off, Add and Remove of a row post the form and answer with it one row longer or shorter, and with them they add or remove the row in place; a form’s answer shows at the top of the page, and a form the console or a replica refuses is drawn again as you typed it, the field the console refuses marked with what is wrong under it. A secret you typed is never drawn again. A replica whose configuration is not managed answers404, and the page says so and offers no form (Console-managed resources). -
The health routes of the FerroHEALTH family:
GET /health/livenessanswers200while the process serves,GET /health/readinessanswers200while it takes requests and503once it drains onSIGTERM, andGET /healthfollows readiness with{"state":"up"}. Thehealthcheckcommand the image runs asksGET /health.
Every answer carries a Content-Security-Policy that admits scripts and
stylesheets from the console’s own origin alone (script-src 'self'; style-src 'self'), X-Content-Type-Options: nosniff,
X-Frame-Options: DENY and Referrer-Policy: no-referrer, and no page is
stored by a cache. FerroUI’s stylesheet, theme and script modules are
served under /ferrohealth-ui/<version>/, cached for a year since a new
version is a new path. Nothing is compressed, so no compression side channel
reads what an operator entered.
The console serves plain HTTP and sends no Strict-Transport-Security
header. Terminate TLS at a reverse proxy in front of it and set
Strict-Transport-Security (RFC 6797) there, as the
hardening guide asks: a browser that has seen the header
then reaches the console over HTTPS alone, so no one on the network path can
turn the operator’s sign-in into a plain HTTP request.
The query console’s answer is rendered on the console’s server, with every value a node sent escaped, and it works the same with scripts turned off: Run is a plain form post.
The screens
Each screen is shown in the light theme, with a link to the same screen in
the dark theme. The testkit takes them against a development gateway over
two stub nodes, node A that answers and node B that fails, so every page
carries the amber development banner. To take them again, install the pinned
browser with scripts/release/journey-tools.sh target/journey-tools, and run
the capture behind its gate:
FERROFED_JOURNEYS=1 FERROFED_SCREENSHOTS=1 \
cargo nextest run -p ferrofed-testkit -E 'test(/screenshots::/)'
A console route with no screenshot fails the testkit’s
every_console_route_has_a_screenshot_in_light_and_dark.
Sign-in
The sign-in card in the dark theme
Dashboard
The dashboard in the dark theme
Topology
The topology in the dark theme
Organisations
The organisations in the dark theme
The page of organisation org-a in the dark theme
The page of node node-a in the dark theme
The Routes tab of node node-a in the dark theme
Services
The services the gateway asks, grouped, with the state of each in the dark theme
The page of the identity cross-reference service in the dark theme
Integrity
The integrity page in the dark theme
Query console
The empty query console in the dark theme
The Stored query tab of the query console in the dark theme
A complete answer in the dark theme
An incomplete answer in the dark theme
A refused query in the dark theme
Stored queries
The stored queries by namespace in the dark theme
The page of org.example in the dark theme
The Versions tab of org.example in the dark theme
Self-description
The Federation tab of the self-description in the dark theme
The Limits tab of the self-description in the dark theme
Configuration page
The page opens on the deployment-wide settings, one tab per section:
Federation behaviour, Fan-out and paging, Consent, Stored queries, Access
log, FHIR face and Authentication. Authentication is read-only, because only
the configuration file sets it: the audience, the operator scope, the token
issuers and each trusted issuer, with every claim name, the correlation
header and the added countries of a national contact point it declares.
Each setting is a row with its value, a mark when the value does not come
from where the page’s settings come from (From the environment, Default, or
From the file under managed revisions; a value the running revision sets,
or the file with revisions off, carries none), its configuration key, and a
Next start mark
when a change takes effect only at the next start of each replica, with
the value that start takes once a saved change waits for it. A secret is
named by the file it is read from, never by its value. The rows come from
the gateway’s running configuration (GET /admin/config/running,
Configuration revisions).
Edit on a tab opens a form with a control for each setting of one value;
saving it posts the settings you changed, and only those, as a revision,
which you then apply as any revision. A list or a table, such as the
category map, and a secret are changed by an import. With configuration
revisions off (config.ui = "off"), the six settings tabs show alone and
read-only.
The Federation behaviour tab in the dark theme
The edit of the Federation behaviour tab in the dark theme
The Stored queries tab in the dark theme
The Members tab of the configuration page in the dark theme
The Credentials and bindings tab in the dark theme
The History tab in the dark theme
The member form in the dark theme
Your preferences
The Appearance tab of Your preferences in the dark theme
The Language and region tab of Your preferences in the dark theme
Find
From the second character you type, a panel under the find field shows the organisations, nodes, creating systems and stored queries that match, five of a kind. The arrow keys move through it, Enter opens a hit and Escape closes it. Submitting the field opens the find page, which lists every match.
The answer of the find field for “node” in the dark theme
About and not found
The About page in the dark theme
The not-found page in the dark theme
Why a console of its own
The gateway authenticates every request by bearer token and holds no browser
session (Client authentication). The console holds the
operator’s session on its own server and calls the gateway with the
operator’s token, so the gateway’s authentication stays as it is. The gateway
also serves no static files: it answers 501 on a path it does not serve,
and its request log carries no body and no header value.
Configuration
The console reads a TOML file named by --config or FERROFED_CONSOLE_CONFIG,
with the family’s loader (ferrohealth-config). Every key can be set from the
environment as FERROFED_CONSOLE__<SECTION>__<KEY>, or on the command line as
--set section.key=value, repeatable, which wins over both; and the client
secret can be read from a file through client_secret_file. A refused
configuration names the key, and the variable or --set pair that set it,
never a value, so no secret reaches a message or a log; text that is not TOML
is named by its line and column. config check prints the running
configuration with every secret redacted and where each key came from.
The console log is the family’s [telemetry] section: RUST_LOG or
telemetry.filter chooses what it logs, as on the gateway
(What the log records).
[server]
listen = "0.0.0.0:3000"
[gateway]
base_url = "https://gateway.example.org/"
timeout = "30s"
[session]
secure_cookie = true
sign_in_timeout = "5min"
max_sign_ins = 1000
idle_timeout = "30min"
absolute_timeout = "12h"
max_sessions = 10000
[oidc]
issuer = "https://idp.example.org/realms/ferrofed"
# The three endpoints are read from the issuer's discovery document when
# they are left out (OpenID Connect Discovery 1.0 §4).
authorization_endpoint = "https://idp.example.org/realms/ferrofed/protocol/openid-connect/auth"
token_endpoint = "https://idp.example.org/realms/ferrofed/protocol/openid-connect/token"
jwks_uri = "https://idp.example.org/realms/ferrofed/protocol/openid-connect/certs"
client_id = "ferrofed-console"
client_secret_file = "/run/secrets/console-client-secret"
redirect_uri = "https://console.example.org/auth/callback"
scopes = ["openid"]
offline_access = false
end_session_endpoint = "https://idp.example.org/realms/ferrofed/protocol/openid-connect/logout"
post_logout_redirect_uri = "https://console.example.org/"
[[admin.replicas]]
name = "gateway-0"
admin_url = "https://ferrofed-0.ferrofed-admin.internal:9464/"
[[admin.replicas]]
name = "gateway-1"
admin_url = "https://ferrofed-1.ferrofed-admin.internal:9464/"
[server] locale, en by default, is the language a page renders in when
neither the operator’s language preference nor the browser’s
Accept-Language names one the console ships; English is the one it ships
today, and a language joins as a catalog directory with no code change.
locale = "qps" is the pseudo-locale, which draws every word accented and
two fifths longer to show where a longer translation would not fit; it is
for checking the layout, never offered to an operator.
The [oidc] and [session] tables are those of ferrohealth-session, the
family’s console sign-in. [session] store is memory by default, the
sessions held in the console’s process; store = "sealed" keeps each one
in an AES-256-GCM sealed cookie instead, for a console that runs as several
instances, with the same key_file (base64 of at least 64 bytes) on every
instance, and refuses offline_access, so no refresh token is ever put in
a cookie.
With secure_cookie = true the session and sign-in cookies carry the
__Host- prefix, which binds each to the console’s host (RFC 6265bis
§4.1.3.2); with it off, for a loopback trial, they carry none.
secure_cookie = false sends the cookies over plain HTTP, so the console
refuses it at start unless redirect_uri is an http URL on a loopback
host (localhost, 127.0.0.0/8 or ::1). The refusal names
session.secure_cookie. A console without [oidc] sets no cookie, so the
key has nothing to govern there and is not checked.
Without an [oidc] table the console offers no sign-in, and GET /login
answers 503. With issuer alone, the console reads the provider’s
authorization, token and key-set endpoints, and its end-session and
revocation endpoints, from the issuer’s discovery document when it starts
(OpenID Connect Discovery 1.0 §4); a document that is missing or cannot be
reached refuses the start. Each endpoint the table names takes the place of
the discovered one, and with all three named the console asks the provider
nothing at start. config check never asks the provider. revocation_endpoint
has a sign-out revoke the session’s refresh token (RFC 7009), ca_file
adds PEM trust roots for a provider behind a private certificate authority,
and timeout_s (10 by default) bounds each call to the provider. The
provider’s URLs must be https unless their host is loopback, and scopes
must include openid. offline_access, off by
default, adds the offline_access scope and prompt=consent to the
sign-in; scopes must not list offline_access itself, and the refusal
names oidc.scopes. The console refreshes the access token with whatever
refresh token the provider issues, with or without it. end_session_endpoint is
optional, and post_logout_redirect_uri needs it. ferrofed-console config check
reads and checks a configuration without binding a socket.
[[admin.replicas]] names the admin listener of each gateway replica the
console runs the operator’s admin actions on. Each entry has a name, which
the console shows and the operator picks the replica by (letters, digits,
., _ and -, at most 64, each name once), and an admin_url, the
http or https root of that replica’s admin listener. Without an entry
the console offers no admin action, and its views are as before. The list
can be set from the environment as one TOML value, for example
FERROFED_CONSOLE__ADMIN__REPLICAS='[{ name = "gateway-0", admin_url = "https://ferrofed-0.internal:9464/" }]'.
The console’s server calls each admin listener directly, so it must reach
it on an internal network that nothing outside the deployment can reach;
never publish an admin listener. Its default bind is loopback, so set the
gateway’s [metrics] listen to a non-loopback internal address with
allow_remote = true (Metrics),
and prefer an https admin listener, since the operator’s access token
travels on each call. The console’s client of the admin listener is the one
it calls the gateway with: it trusts the system’s root certificates and
presents no client certificate, so an admin listener that asks for mutual
TLS cannot be reached from the console.
The image sets server.listen to 0.0.0.0:3000, runs as the numeric user
65532, and runs healthcheck as its HEALTHCHECK. The console serves no
site bundle, so server.site_root is an unknown key, which it refuses by
name.
From the release compose file
The compose.yaml every release carries runs the console beside the
gateway under the console profile, from the published image at the
release’s version, with the gateway’s hardening: a read-only root
filesystem, every capability dropped, no-new-privileges, and CPU and
memory limits. Its port binds the loopback interface, as the gateway’s
does.
# the client secret at your OpenID Provider, read as a file secret
printf '%s' "$CLIENT_SECRET" > secrets/console_oidc_client_secret
export FERROFED_CONSOLE__OIDC__ISSUER=https://idp.example.org/realms/ferrofed
export FERROFED_CONSOLE__OIDC__AUTHORIZATION_ENDPOINT=https://idp.example.org/realms/ferrofed/protocol/openid-connect/auth
export FERROFED_CONSOLE__OIDC__TOKEN_ENDPOINT=https://idp.example.org/realms/ferrofed/protocol/openid-connect/token
export FERROFED_CONSOLE__OIDC__JWKS_URI=https://idp.example.org/realms/ferrofed/protocol/openid-connect/certs
export FERROFED_CONSOLE__OIDC__CLIENT_ID=ferrofed-console
export FERROFED_CONSOLE__OIDC__CLIENT_SECRET_FILE=/run/secrets/console_oidc_client_secret
export FERROFED_CONSOLE__OIDC__REDIRECT_URI=http://127.0.0.1:3000/auth/callback
export FERROFED_CONSOLE__SESSION__SECURE_COOKIE=false
docker compose --profile console up --wait
Then open http://127.0.0.1:3000. The console reaches the gateway over
the compose network at http://ferrofed:8080/
(FERROFED_CONSOLE__GATEWAY__BASE_URL). secure_cookie = false is
admitted only because the redirect URI is plain HTTP on loopback; behind a
TLS reverse proxy, drop it and name the proxy’s URL as the redirect URI.
Without the FERROFED_CONSOLE__OIDC__* variables the console starts and
offers no sign-in. Every variable the service passes through is set only
when your shell or .env sets it.
The configuration page needs a replica’s admin listener. Set the gateway’s
[metrics] listen = "0.0.0.0:9464" with allow_remote = true in
ferrofed.toml, leave it unpublished, and name it to the console:
FERROFED_CONSOLE__ADMIN__REPLICAS='[{ name = "gateway-0", admin_url = "http://ferrofed:9464/" }]'.
FERROFED_CONSOLE_PORT, FERROFED_CONSOLE_CPUS and
FERROFED_CONSOLE_MEMORY move the port and the limits, and
FERROFED_CONSOLE_OIDC_CLIENT_SECRET_FILE another secret file.
Hardening
This checklist takes a gateway from a working configuration to one you can put in front of real CDRs. Each item names the setting or the deployment control, and why it matters. The why is the threat model, whose boundaries (B1 to B8) the items cite. No specification governs this page: our own design.
Network placement
- Put a reverse proxy in front of the gateway, on the same host or in
the same pod, or serve TLS on the listener. By default the listener speaks
plain HTTP, so the hop from the proxy to the gateway carries bearer tokens
and patient identifiers in the clear (B1). Keep
server.listenon a loopback address, its default127.0.0.1:8080, when the proxy runs beside it; in a container, bind the address the proxy reaches and nothing wider. Where the hop crosses a shared network, set[server.tls]withclient_auth = "required"and aclient_ca_filethat admits the proxy alone, and[metrics.tls]for the admin listener (TLS on the listeners). - Publish container ports on one address. A port published on
0.0.0.0is reachable from the network even when the host firewall says otherwise (The quickstart);FERROFED_BIND_HOSTin the releasecompose.yamldefaults to127.0.0.1. - Decide which open routes the network may reach. The gateway
answers without a token on the health family,
GET {base}/andGET {base}/.well-known/jwks.json, and the PMIR feed route checks its own token.GET {base}/names the version; the dependency report, which names every member endpoint and its state, needs the operator scope (GET {base}/operator/dependencies). At the proxy, keep the health routes to your orchestrator and monitoring, the JWK Set reachable from every node’s authorization server, and the feed route reachable from the PMIR Registry (B1, B3). - Restrict ingress. Admit client traffic to the gateway port from
the proxy alone, and the admin port from your scraper and your operators
alone. The Kubernetes package’s NetworkPolicy admits the reverse proxy
and the console to the gateway port, and the scraper and the console to
the admin port (
deploy/kubernetes/base/networkpolicy.yaml); it holds only where the cluster’s network plugin enforces NetworkPolicy (B5). - Restrict egress. The gateway connects only to the URLs in your
configuration: the members and their token endpoints, the identity
services, the issuers’ key sets, the audit repository and the collector,
and, while the usage report is on,
report.ferropulse.euon port 443 (The usage report). Allow those and nothing else. The Kubernetes package’s base leaves egress open, and itsegresscomponent closes it to DNS and the destinations you list (Network policies) (B2, B3, B7).
TLS
- Terminate TLS at the proxy with the protocol versions and cipher
suites of RFC 9325 (BCP 195). Set
Strict-Transport-Security(RFC 6797) there for the gateway and for the operator console, which sends none of its own (B1, B4). - Keep the production profile. Outside
profile = "development", the gateway refuses to start when a credential or a patient identifier would travel over plainhttp, and a stored-query database password withoutsslmode=require. The startup banner and aWARNline name each unencrypted credential the development profile admits (What must travel encrypted). - Use
httpsfor every node that receives a credential, and mutual TLS where a node or a national service asks for it:client_identity_fileandtrust_roots_filein thetlstable of the endpoint’s credentials or of the service’s table (Mutual TLS to a node, Mutual TLS to the identity services). - Send the IHE audit over TLS. The FHIR Feed URL is
httpsand the ITI-55 syslog repository is TLS outside development (The audit trail). - Run the OpenTelemetry collector beside the gateway. The metrics push and the span export are gRPC without TLS; let the collector forward over TLS (Tracing, #644).
Callers and issuers
- Trust as few issuers as you can. Every token an issuer signs is
admitted for the scopes it carries, so an issuer you list is trusted for
every caller it vouches for (B7). Serve each issuer’s metadata, key set
and introspection endpoint over
https. - Name the privileged clients per issuer.
backend_clientsforsystem/aql-*anddemographic_clientsfor the DEMOGRAPHIC API, under[[trust.issuer]], and have only the issuer your operators sign in at grantauth.product_scope(Client authentication). - Require an assurance level per issuer. Set
[auth.issuer.assurance]on every issuer whose tokens reach patient data, with the values it writes for each level and the least level you accept;config checknames each issuer without one (Professionals and assurance). - Keep
trust.purpose_of_use.required = true, its default, unless your §13.4 decisions say why not (Purpose of use). - Bind a patient grant only with a cross-reference you trust. An
[trust.issuer.patient]binding confines apatient/grant through your identity service’s links; leave it unset otherwise (Patient grants). - Use the edge mode only with a signing proxy.
trust.mode = "edge"verifies a signed assertion and nothing else; never configure a proxy that forwards an unsigned identity header (RFC 7239 §8.1). - Know how to remove an issuer. Remove its
[[auth.issuer]]and[[trust.issuer]]entries and reload, withSIGHUPorPOST /admin/reload(Reloading the registry); write the steps into your incident runbook.
Secrets
- Read every secret from a file. Each credential has a
_filekey:password_file,password_hash_file,client_secret_file,private_key_file,client_identity_file,key_file. Setting a secret both inline and by file is refused (Configuration). - Make each file readable by the gateway’s user alone. The image
runs as
65532:65532; mount the files read-only with mode0400, or0440with the gateway’s group, as the Kubernetes example does (The container image). - Keep secrets out of the environment and the configuration file. A
FERROFED__…_FILEvariable names a file, never the secret. - Give each endpoint its own credential. The gateway sends an endpoint’s credential to that endpoint alone; one shared credential lets one node replay it at another (B2).
- Bind onward tokens where the node allows it, with DPoP
(
dpop_key_file) or mutual TLS (RFC 8705), so a token taken from a node cannot be replayed from elsewhere (Onward credentials).
Signing keys and rotation
- Generate the signing key for the nodes you serve. P-384 signs ES384; choose P-256 when a node holds to the FAPI 2.0 Security Profile (Signing keys and the JWK Set).
- Set
node_jwks_cacheto the longest time a node caches the JWK Set.config checkthen refuses arotation_overlaptoo short for a safe rotation. - Rotate in three rolling restarts: publish the new key as
next_key_file, sign with it askey_filewith the old one asprevious_key_file, then retire the old one (Rotating the signing key). Rotate on a schedule, and at once when a key may have leaked. - Rotate onward credentials and the PMIR feed token with the party that issued them; each is read at start, and a registry reload re-reads the endpoint credentials.
The admin listener
- Leave
[metrics] listenunset unless you scrape. Unset, nothing listens. - Keep it on loopback. Set
allow_remote = trueonly when the address is reachable from your scraper and your operators and nothing else (Metrics). An operator console configured with[admin]reaches it from its own server, so bind it to an internal address the console’s server can reach and no public network can (The operator console). The console presents no client certificate: guard the scrape withscrape_clientson a listener the console calls, never with a required client certificate. - Authenticate the scrape off loopback. Register your Prometheus as
a client of an
[[auth.issuer]], name it inmetrics.scrape_clients, and give your Prometheus its client secret withoauth2.client_secret_file; or set[metrics.tls]withclient_auth = "required"and aclient_ca_filethat admits the scraper. Outside the development profile the gateway refuses to start with a listener off loopback and neither. - Set
auth.product_scopeand have only your operators’ issuer grant it, to them alone. A write action, such as the stored-query distribution, needs a token carrying it from every peer, loopback included; without it no caller is an operator (The operator surface). - Never run a production gateway under
profile = "development". It admits any process that reaches the listener’s loopback address to the write actions without a credential.
The audit spool
- Send the records to your Audit Record Repository. A gateway with
a registry records every access to patient data with the caller, so
outside
profile = "development"it needs[audit] destination = "repository"with an[audit.repository]table:config check,serveand a reload refuse an unset destination,offandlog, namingaudit.destination. Thelogdestination names no caller and no patient, so it cannot hold the access records (B6, The access log). - Give each replica a spool of its own on durable storage. Two gateways must never drain one directory, and a lost spool loses records that name patients for good. Use a named volume under Docker and a persistent volume claim per replica under Kubernetes, as the StatefulSet example does (Losing a spool).
- Encrypt the volume at rest. The gateway holds no key to encrypt the
spool, and the records in it name patients and callers. Replace the
example’s
encryptedstorage class with one of yours that encrypts. - Leave the directory modes alone. The gateway creates the spool
0700with files0600and refuses to start when it is open to others. - Watch the backlog. Alert on
FerroFEDAuditSpoolBacklogandFerroFEDAuditRefused; a full spool fails the transactions it cannot record, and the gateway then answers every access to patient data503 access-unrecorded. Remove a quarantined record only after you have read why the repository refused it (The audit trail).
Identity services
- Use
httpsand, where offered, mutual TLS to the PIX Manager, the PDQm Supplier, the XCPD gateways, the NVI and Mitz (B3). - Give the PMIR Registry its own client. Register it at an
[[auth.issuer]], name it inpmir.feed_clients, and agree the client with the Registry’s operator; a message without its token changes nothing. - Set
federation.binding_ttlno longer than you would accept a follow-up routed on a superseded identity (Resolution bindings).
Resource limits
- Size the overload limits from what the members can take.
server.max_concurrent_requests(512),[server.caller_rate](off by default),federation.max_in_flight_per_node(64) andfederation.max_node_answer_size(16 MiB) (Overload protection). Turn the per-caller rate on. - Keep the request limits.
server.body_limit(1 MiB) andserver.request_timeout(30 s), with the proxy’s own timeout longer, so the client sees the gateway’s answer. - Rate-limit
/loginon the console at the edge. The console bounds its pending sign-ins and sessions and cannot tell clients apart behind a balancer (The operator console). - Set container limits. CPU, memory and ephemeral storage, as the
release
compose.yamland the Kubernetes example do. - Let the drain finish. Keep the runtime’s grace period above
server.drain_delayplusserver.shutdown_timeoutplusgateway.bindings_drain_timeout(Stopping without dropping a request).
Logs, metrics and traces
- Ship the JSON log to a store with access control and a retention
period. The log carries no patient identifier, but it does carry routing
ids,
ehr_ids in integrity incidents, and the security events you investigate from. Record the retention in your records of processing. - Load the shipped alert rules (
ferrofed-alerts.yaml) and route their pages: caller refusals, an unavailable issuer, a request the outbound gate stopped, an integrity incident, the audit spool (Dashboard and alert rules). Add an alert on the scrape job’sup. - Treat an
outbound-gate-stoppedevent as an incident. It means a request would have carried a patient identifier to a node and was stopped. - Do not raise the log filter to
debugin production unless you are debugging, and return it after;telemetry.filterchanges what is logged, never what is traced.
Operator console
- Keep
secure_cookie = true. The console refusesfalseunlessredirect_uriis on a loopback host. - Register the console as a confidential client with its secret in
client_secret_file, and an exactredirect_uriat the provider. - Run one replica, or a balancer that keeps an operator on one replica. Sessions live in the console’s memory.
- Give operators the operator scope only. The console calls the gateway with the operator’s own token, so that token’s scopes are what the console can do.
The container
- Run the published image unchanged. It is distroless and has no
shell; it runs as
65532with a read-only root filesystem, every capability dropped, and writes only to the spool volume (The image). KeeprunAsNonRoot,allowPrivilegeEscalation: falseand theRuntimeDefaultseccomp profile, as the Kubernetes example sets them. - Verify what you run. Pin the image by digest and check its
attestation with
gh attestation verify, and verify a release tarball the same way (The container image). - Never run the development profile near real data. It admits cleartext credentials to loopback services, and its banner says so in red.
- Check every configuration before it ships. Run
ferrofed config checkin your deployment pipeline; a refused file exits78naming the key. - Run the newest release. Only the newest release is supported:
when a new release is published, every earlier release stops receiving
fixes, and upgrading is free. A security fix ships as a patch release of
the newest release, with the fix alone or the reason a functional change
ships with it stated in the notes, and a published
release is never changed, so upgrade to the newest release to get the fix
(
SECURITY.md).
Troubleshooting
This page starts from what you see: a status a client reports, a line in the log, a metric that moves, a gateway that does not start. Each entry names the cause, how to confirm it from what the gateway already emits, and the fix, with a link to the page that explains the setting. No specification governs troubleshooting: our own design.
The page has four parts. Where to look says what each
signal carries. Symptoms covers the failures an operator meets
most. The gateway does not start covers every
refusal of config check and serve. Error codes has one
row for every code the gateway answers with, held to the gateway’s own table
by a test.
Where to look
- The answer. Every error the gateway reports itself is the ITS-REST
Errorbody with a stablecodeand therequest_id(Errors and status codes). A federated query that fails under all-or-nothing completeness answers a result set instead, whosemeta.federation.endpoints[]names each node with itsstatusand itserror. - The request line. Every request writes one line,
request, with themethod, the route template asroute, thestatus,latency_msand the gateway’srequest_id: atINFOfor a success,WARNfor a4xxandERRORfor a5xx(What the log records). Every other line about the same request carries the samerequest_id. - The log target. With
telemetry.format = "json", each line carries atarget; theprettyformat leaves it out, so search by the message there. Four targets are named on purpose:ferrofed::security(client authentication, identifier hygiene and the admin listener, never quieted bytelemetry.filter),ferrofed::integrity(integrity incidents),ferrofed::audit(audit records written to the log) andopentelemetry-otlp(a failed trace export). Every other line’s target is the Rust module that wrote it, such asferrofed_server::reload,ferrofed_server::accessorferrofed_engine::dispatch::reported, as the entries below name it (Tracing). - The metrics on the admin listener:
ferrofed_node_requests_totalbyendpointandoutcome,ferrofed_security_events_totalbyeventandreason,ferrofed_overload_refusals_totalbylimit, the resolver, localizer, audit spool and reload series (Metrics). - The dependencies.
GET {base}/operator/dependenciesnames the state the gateway last saw of each member endpoint and each identity service:up,failing,downorunknown(Health probes).
Symptoms
Every query answers 424
Symptom. Federated queries answer 424 with a result set and no rows.
Cause. Under all-or-nothing completeness, one node that answered with an error fails the whole query, and so does a member the cross-reference service could not answer for (§11.4, N37).
Confirm. Read meta.federation.endpoints[] of one answer. A member with
status node-error names the node’s own status and message in error,
such as the node answered 500 Internal Server Error: …. A member with
not-resolved and an error is one the resolver failed for; the resolver
counts it as ferrofed_resolver_requests_total{outcome="unavailable"}, and
GET {base}/operator/dependencies reports resolver failing or down. The
failing node counts under ferrofed_node_requests_total{outcome="node-error"}.
Fix. Fix the node the error names, or the cross-reference service
(Identity resolution). To serve the members that answer while
one is down, a client sends openEHR-federation-completeness: partial, which
federation.best_effort must offer
(Completeness).
Every node is offline or time-out
Symptom. Queries answer 504, and every endpoint is offline or
time-out.
Cause. The gateway cannot reach the nodes (a wrong URL in the registry
document, DNS, a firewall, TLS), or they do not answer inside
federation.per_node_timeout, or the gateway already has
federation.max_in_flight_per_node requests open at each of them.
Confirm. The endpoint’s error reads the node could not be reached: …
with the HTTP client’s reason, or no answer before the deadline. The
dependency state is down, and the node counts under
ferrofed_node_requests_total{outcome="offline"} or {outcome="time-out"}.
A request that waited for a slot is counted by
ferrofed_overload_refusals_total{limit="node-in-flight", endpoint="…"}.
Compare ferrofed_node_request_duration_seconds with the timeout.
Fix. Correct the endpoint url in the registry document and reload
(The registry document), open the path
from the gateway to the node, raise per_node_timeout above the node’s
99th percentile (Timeouts), or size
max_in_flight_per_node to what the node can serve
(The per-member cap).
Nodes answer 401
Symptom. Inside a query, endpoints are node-error with error
the node answered 401 Unauthorized. On a request routed to one node, the
client gets 424 node-refused.
Cause. The node does not accept the gateway’s onward credential: the
[credentials."<endpoint>"] section is missing or wrong, the node does not
trust the gateway’s signing key, or the token endpoint refused the gateway.
Confirm. When the gateway could not get a token at all, the endpoint’s
error is no onward credential could be obtained, so nothing was sent,
followed by the token endpoint’s RFC 6749 code when it sent one, and the
gateway logs no onward credential could be obtained, so nothing was sent to the node at WARN under ferrofed_engine::dispatch::reported, with the
endpoint and the token endpoint’s own account in error. A 401 from the
node itself leaves the dependency state up, because the node answered.
Fix. Set or correct the endpoint’s credentials section, its scope, its
token_endpoint and its client_id
(Onward credentials). The
token endpoint verifies the gateway’s client assertion, and a node its
signed caller token, against the gateway’s JWK Set at
{base}/.well-known/jwks.json; check that each can reach it and holds the
current key
(Signing keys and the JWK Set).
Every caller is refused 401
Symptom. Clients get 401 unauthenticated, 403 scope-insufficient or
403 purpose-of-use-required, and no node is asked.
Cause. The token is not one the [auth] configuration accepts.
Confirm. Each refusal logs a request was refused at client authentication at WARN under ferrofed::security, with event
caller-refused, the reason, the check and the status, and counts
ferrofed_security_events_total{event="caller-refused", reason="…"}. The
answer’s challenge says only invalid_token for every token that does not
verify (RFC 6750 §3.1), so read the check in the log.
Fix. By check: issuer not trusted is an iss no [[auth.issuer]]
lists; audience is an aud other than the issuer’s audience; a token
the issuer’s kind admits no caller for (an unlisted client, an unlisted
actor) is no caller kind; expired and
not yet valid past the issuer’s clock skew point at a clock out of step;
no key of the set is a kid the issuer does not publish; algorithm not accepted, typ names another kind and a required claim is missing are
tokens that are no RFC 9068 access tokens; inactive is a token the
introspection endpoint no longer knows. By reason: scope and
purpose-of-use are grants the token lacks
(Client authentication).
Every caller is refused 503 authentication-unavailable
Symptom. Every request of one issuer’s callers answers 503 authentication-unavailable.
Cause. The gateway cannot fetch or read that issuer’s metadata or key set, or its introspection endpoint does not answer.
Confirm. an issuer's metadata or key set could not be read, so its tokens cannot be verified (event key-set-unavailable) or an issuer's introspection endpoint did not answer, so its tokens cannot be verified (event
introspection-unavailable) at WARN under ferrofed::security, naming
the issuer and the fetch error.
Fix. Make the issuer’s metadata, its key set or its
introspection endpoint reachable from the gateway, or raise the issuer’s
timeout_ms
(The key set).
Requests are refused 503 overloaded or 429 rate-limited
Symptom. Clients get 503 overloaded or 429 rate-limited with a
Retry-After.
Cause. The gateway serves server.max_concurrent_requests requests at
once, or one caller sent more than [server.caller_rate] admits.
Confirm. a request was refused: the gateway serves as many requests as it takes at once or a request was refused: its caller sent more requests than one caller may, at WARN under ferrofed_server::overload, and
ferrofed_overload_refusals_total{limit="concurrency"} or
{limit="caller-rate"}.
Fix. Size the limits to your members and callers (Overload protection).
Requests are refused 503 access-unrecorded
Symptom. Queries and routed reads answer 503 access-unrecorded.
Cause. The gateway could not store the record of the access, so it withholds the answer.
Confirm. the access could not be recorded, so its answer is withheld
at ERROR under ferrofed_server::access, with the cause in error. The
spool refused the record when an audit message was refused: … naming the
spool’s bounds appears at WARN under ihe_atna::atna::spool and
ferrofed_audit_refused_total rises. Two other lines under the same target
point at a defect to report with the request_id: an access was served with no verified caller, so its answer is withheld and an answer of a patient-data operation carried no access, so it is withheld.
Fix. Free the spool by bringing the audit repository back
(the next entry), give the spool room
(spool_max_events, spool_max_size) or a faster disk
(spool_write_timeout) (The spool and the failure
policy). A write may have
reached the node before the record failed, so a client reads before writing
again (Failing closed).
The audit spool grows
Symptom. ferrofed_audit_spool_events and ferrofed_audit_spool_bytes
rise, and GET {base}/operator/dependencies reports audit_repository or
audit_feed degraded.
Cause. The audit repository is not taking the records, so they wait in the spool, or it refused some, which wait in the quarantine.
Confirm. Under ihe_atna::atna::forwarder: the audit repository could not be reached; its messages stay spooled or an audit message could not be delivered; the connection is dropped and the message stays spooled for an
ITI-20 syslog repository, and an AuditEvent could not be delivered; it stays spooled for a FHIR Feed repository, each at WARN with
ferrofed_audit_retries_total rising. the audit repository refused an AuditEvent; it is quarantined at ERROR, with its sequence and HTTP
status, raises ferrofed_audit_quarantined.
Fix. Bring the repository back; the spool drains in order once it
answers. Read why a quarantined record was refused, fix that, and remove it
(The audit spool). A spool that fills turns
every access into 503 access-unrecorded.
A reload is refused
Symptom. After SIGHUP, the gateway still serves the old registry.
Cause. The new configuration does not load, or changes something only a restart applies.
Confirm. registry reload refused, the running registry stays; `ferrofed config check` names the fault at ERROR under
ferrofed_server::reload, with the failure class, and
ferrofed_registry_reloads_total{result="refused"}. A changed setting a
reload does not apply logs changed settings take effect only on a restart; the running values stay at WARN, naming the keys in settings. A listener
certificate that does not read refuses the whole reload with the class
listener-certificate and its table.
Fix. Run ferrofed config check against the same file, which prints the
key at fault, correct it, and send SIGHUP or POST /admin/reload again
(Reloading the registry).
A request is refused 409 ehr-id-collision
Symptom. Reads and writes that name one ehr_id answer 409 ehr-id-collision, and the message lists the endpoints that claim it.
Cause. Two members or more claim the same ehr_id, a defect in the
federation (§12.5.2, N42).
Confirm. integrity incident: an ehr_id is claimed by more than one member, and the request was refused at ERROR under ferrofed::integrity,
with kind EhrIdCollision, the detection and the claimants, and
ferrofed_integrity_incidents_total{kind="EhrIdCollision"}.
Fix. Have the member that issued or adopted the ehr_id in error fix it,
then clear the collision on each replica’s admin listener with
POST /admin/ehr-ids/{ehr_id}/clear
(Clearing a held collision). A read
that is still refused 409 with detection ask-all after the clear
means the collision still stands at the nodes. Until then a client reaches
one member by naming it in openEHR-federation-endpoint
(Integrity incidents).
The operator console shows a refusal
Symptom. A view of the operator console shows a status and a code where its rows should be.
Cause. The gateway refused the operator’s token. A 403 scope-insufficient is a token without auth.product_scope, which
the /integrity and /stored-queries views need; a 401 is a token that
does not verify or a session that ended.
Confirm. The gateway logs the refusal as caller-refused under
ferrofed::security, with reason scope or the 401’s reason.
Fix. Set auth.product_scope and have the operator’s provider grant it (The operator surface).
On a 401, sign in again. An /integrity view that lists no incident is
a gateway that has raised none since it started
(The operator console), and no view is
ever silently empty.
Readiness answers 503
Symptom. GET {base}/health/readiness answers 503, and a load
balancer or the container runtime takes the gateway out.
Cause. The body’s phase is booting before the gateway serves, and
draining from the moment SIGTERM or SIGINT arrives. No member node and
no identity service gates readiness.
Confirm. Read the body’s phase. ferrofed healthcheck prints
ferrofed: <address>: unhealthy: readiness answered 503 and exits 1; a
configuration that does not load prints ferrofed: unhealthy: ….
Fix. booting lasts until the gateway has bound its listener and
started its bindings. A gateway that cannot start exits instead, with a
cannot start or cannot serve line
(The gateway does not start). A draining
gateway was asked to stop; size the grace period
(Stopping without dropping a request).
The admin listener answers 401
Symptom. Prometheus reports the scrape target down with 401, or the
stored-query distribution answers 401 or 403.
Cause. metrics.scrape_clients is set and the scrape does not carry a
client-credentials token of one of them, or a write action’s caller has no token
that verifies or no auth.product_scope.
Confirm. a request to the admin listener was refused at its authentication at WARN under ferrofed::security, with event
scrape-refused and reason missing, invalid or unlisted, or event
admin-write-refused and the client-authentication reason.
Fix. Give the scrape job a scrape client’s credentials in oauth2, or
give the operator a token carrying auth.product_scope
(Who the admin listener serves).
Every request under {base}/v1/ answers 501
Symptom. Every ITS-REST path answers 501 not-implemented.
Cause. No registry is configured, so the gateway federates nothing.
Confirm. The startup banner’s Registry line reads none, and
OPTIONS {base}/ answers 501 too.
Fix. Set registry.document, or [registry.mcsd]
(The registry).
Every query answers 404 no-destination
Symptom. Queries answer 404 no-destination and no node is asked.
Cause. No registry member is in scope: every endpoint is suspended or excluded, or the targeting names organisations that manage no endpoint.
Confirm. OPTIONS {base}/ lists each endpoint with its membership
standing.
Fix. Correct the membership in the registry document and reload (The registry document).
The gateway does not start
config check and serve refuse the same configurations. A refusal found
while reading the configuration prints ferrofed: cannot start: on stderr,
followed by the fault and each cause after it, separated by : , and exits
78. A refusal found once the log has started is logged instead, as
cannot start at ERROR under ferrofed_server::command with the chain in
error, and also exits 78. A listener that cannot be bound logs cannot serve with binding <address> or binding metrics.listen <address> and
exits 1. No refusal quotes a secret or a patient identifier; each names
the key. Run ferrofed config check --config <file> to see the fault without
binding a socket (Running it).
| The line says | Cause and fix |
|---|---|
the configuration file … could not be read | The path is wrong or not readable; check --config and FERROFED_CONFIG (Running it). |
the configuration … is not valid: … | An unknown key, a value of the wrong type or a TOML syntax fault, at the key, line and column named; after the environment overrides points at a FERROFED__ variable (The file). |
the configuration could not be assembled | The merged configuration could not be written back; report it with the line (Complaints and incidents). |
… names no configuration key or … addresses a key under a value that is not a section | A FERROFED__ variable names no key; spell it FERROFED__<SECTION>__<KEY> (The environment). |
… is deprecated and replaced by …, and both are set | Keep the new key only (The compatibility policy). |
… is set together with …_file, … names …, which could not be read, … which holds no secret | Set a secret inline or in a file, not both, and make the file readable and not empty (Secrets). |
… is not set, and its section needs it | Set the named key. |
… is not a URL, … carries a user name or password, … must be an http or https URL … | Correct the URL; credentials go in their own section (The file). |
… is not a socket address, … is not a base path, … is zero; it must be positive | Correct the value of the named key (The base path). |
… is not https, and … would travel over it in cleartext | A credential or patient identifier would go over plain http outside the development profile; use https (What must travel encrypted). |
… is plain http to a host that is not loopback | An issuer’s key set or introspection endpoint is plain http; use https (Client authentication). |
auth.… with a fault, or the gateway federates, and [auth] names no [[auth.issuer]] | Correct [auth]; a gateway with a registry lists at least one issuer (Client authentication). |
… is not an endpoint id, … names …, which is no endpoint of the registry | A [credentials], [trust.issuer.patient], federation.demographic_endpoint or access_log.retention.origins key names an endpoint the registry document does not declare (Onward credentials). |
… names more than one credentials scheme, … names no credentials, … takes basic credentials, not …, … takes basic credentials or the oauth client credentials grant, not … | One credentials section names exactly one scheme its service takes (Onward credentials). |
… cannot be sent in the Authorization header, … cannot be sent: it holds … | A basic user or password with a character RFC 7617 forbids (Running it). |
… is not a usable OAuth 2.0 client-credentials grant, … is not a SMART on openEHR system scope, … needs [signing], … names a grant it cannot use, the FAPI 2.0 and assertion audience faults | Correct the grant (OAuth 2.0 to a node). |
… is not a space-delimited list of RFC 6749 §3.3 scope-tokens | An identity service’s scope (A PIX Manager behind OAuth 2.0). |
… is not a usable signing key, … holds a key that signs …, signing.rotation_overlap … must be at least …, set [signing] when a registry is configured | Correct [signing] (Signing keys and the JWK Set). |
… is not a usable DPoP key | A P-256 or P-384 PKCS#8 key (DPoP). |
… is not usable in a Nuts grant, … names a holder that cannot present, the Nuts client and DID document faults | Correct the Nuts grant (The Nuts grant). |
TLS material or mutual TLS faults, endpoint … is not an https URL, and credentials.… presents a TLS client certificate | Correct the certificate, key or CA files, and use https with a client certificate (Mutual TLS to a node). |
| The certificate, key or client CA of a listener | Correct [server.tls] or [metrics.tls] (TLS on the listeners). |
… is read only by ferrofed healthcheck | Remove healthcheck_identity_file where no listener asks for a client certificate (ferrofed healthcheck). |
… is neither an IP address nor a CIDR block | Correct server.trusted_proxies (Behind a reverse proxy). |
gateway.public_url must be …, gateway.public_url names the path …, … is not the route the gateway serves under gateway.public_url | Correct gateway.public_url, or remove the key that repeats it (The public base URL). |
a value the reader refused at telemetry.…, telemetry.otlp.endpoint must be an http:// URL …, telemetry.otlp.endpoint is not set, and its table is present, … is set, and telemetry.otlp.endpoint is not https://, telemetry.logged_query_parameters is set | Correct [telemetry] or [telemetry.otlp] (Tracing). |
unknown key … telemetry.otlp_endpoint, … metrics.otlp_endpoint, … trace_sample_ratio, … otlp_tls | Move the key under [telemetry.otlp] (Turning it on). |
metrics.listen is …, which is not a loopback address, … and nothing authenticates GET /metrics there, … the address server.listen binds, the metrics exporter faults | Correct [metrics] (Turning it on). |
server.request_timeout … must exceed federation.overall_timeout …, federation.localization.timeout … must be below …, pdqm.endpoint.timeout … plus …, nl_gf.mitz.endpoint.timeout … plus … | Size the budgets so each part fits inside the overall one (Timeouts). |
server.shutdown_timeout … must be at least server.request_timeout … | Raise the drain, or leave it unset (Stopping without dropping a request). |
federation.fan_out_stored_queries distributes … | Configure [stored_queries] with a backend that stores, or turn distribution off (Distributing stored queries). |
… does not apply to stored_queries.backend = …, the … stored-query store … could not be opened | Correct [stored_queries] (Stored queries). |
… needs FerroFED's own PostgreSQL store, and the configuration sets no [store] …, stored_queries.url is no longer read …, store.url does not require TLS …, … is not a PostgreSQL connection string …, FerroFED's own PostgreSQL store [store] could not be opened, FerroFED's store is at migration … | Correct [store] (FerroFED’s own store). |
the registry document … could not be loaded, the registry could not be read from the care services directory, set registry.document or [registry.mcsd], not both | Correct the registry source; config check names the rule the document breaks (The registry). |
set federation.node_selection …, set federation.id …, the OPTIONS {base}/ self-description cannot be built | A gateway with a registry declares both (Node selection, Federation id). |
… are both …; set one, … are all …; set one | Two sections fill one role, such as two resolvers; keep one (Choosing one). |
[dev] is no longer read | Remove [dev] and resolve patients through [[pixm.manager]] (No patient index). |
the [pixm] resolver …, pixm.…, … needs a cross-reference resolver … | Correct [pixm]; a patient grant and [pdqm] need a resolver (A PIX Manager). |
pdqm.…, the [pdqm] demographics step cannot be enabled | Correct [pdqm] (Demographics first). |
the PMIR …, … must be a path that starts with /, … | Correct [pmir] (The identity feed). |
the outbound client of [<table>.endpoint] cannot be built | Correct the service’s endpoint table: its TLS material or its credential (The endpoint table of a service). |
the localizer cannot be set up, xcpd.audit = "off" …, [xcpd.audit_repository] applies only under …, … cannot be a syslog header field | Correct [xcpd] or its audit repository (XCPD localization). |
nl_gf.nvi.namespaces lists …, … is not a credential the NVI takes | Correct [nl_gf.nvi] (Dutch localization). |
nl_gf.mitz.…, the [nl_gf.mitz] consent pre-filter cannot be enabled | Correct [nl_gf.mitz] (Dutch consent). |
audit.destination = "off" …, … = "log" records no caller and no patient …, [audit.repository] applies only under …, the [audit] trail cannot start | Set [audit] with destination = "repository" outside development, and a spool directory the gateway may write ([audit]). |
no access log: …, [access_log] declares a category map that cannot be used, [access_log.retention] declares a retention that cannot be used | Set [audit] destination = "repository" and correct [access_log] ([access_log]). |
the public document at … would sit inside the ITS-REST surface, two public documents would be served at … | A document the gateway serves, such as the DID document of a Nuts grant, would sit under the base path’s ITS-REST surface or on another document’s path; change the DID or the base path (The Nuts grant). |
the node clients could not be built, the HTTP client … could not be built | The cause after it names the fault, such as a [credentials] section for an endpoint the registry document does not declare (Onward credentials); report any other with the line (Complaints and incidents). |
Error codes
One row for every code the gateway answers on its own behalf. The query
refusals (not-aql, unreducible and the rest) are all 400: each logs a federated query was refused before dispatch at WARN under
ferrofed::security, event aql-refused, with its kind and byte range,
and the client rewrites the query (Query refusals).
A row’s “Confirm” names what the gateway writes beside the request line,
which every row has.
| Code | Status | Cause | Confirm | Fix |
|---|---|---|---|---|
body-invalid | 400 | The body or query string is not the ITS-REST request the route takes. | The request line alone. | The client sends the ITS-REST request (Gateway codes). |
completeness-invalid | 400 | openEHR-federation-completeness is repeated or neither all nor partial. | The request line alone. | The client sends one valid value (Completeness). |
partial-unsupported | 400 | A client asked for partial, and federation.best_effort is false. | OPTIONS {base}/ offers no best-effort. | Set best_effort = true, or the client drops the header (Completeness). |
dedup-invalid | 400 | openEHR-federation-dedup is repeated or names no mode. | The request line alone. | The client sends none or version-identity (Gateway codes). |
parameter-invalid | 400 | A query parameter is no AQL literal, or an operator route’s limit is out of range. | The request line alone. | The client sends a string, number or boolean (Gateway codes). |
patient-invalid | 400 | The patient identifier or namespace forms no patient reference. | The request line alone. | The client names the patient and its namespace (Gateway codes). |
no-destination | 404 | No member is in scope, or every probed member answered 404. | OPTIONS {base}/ shows the endpoints’ standing. | Correct the registry membership, or the client names an existing EHR (Every query answers 404). |
ehr-id-collision | 409 | Two members claim one ehr_id. | EhrIdCollision under ferrofed::integrity. | The member at fault fixes it, then clear it on each replica (Clearing a held collision). |
controlling-system-unreachable | 409 | A versioned write amends a version another member created. | a versioned write was refused at a node that does not control the version it amends at WARN under ferrofed_server::facade::route::ehr. | The client writes through the member that created the version; correct a [[creating_system]] mapping that names the wrong node (The registry document). |
internal | 500 | The gateway failed on its own side. | the federated query failed or the routed request failed at ERROR, or outbound-gate-stopped under ferrofed::security, with the request_id. | Report it with the request_id and the lines (Complaints and incidents). |
not-found | 404 | The path is outside the base path and every surface. | The request line shows <unmatched>. | Point the client and probes at server.base_path (The base path). |
not-implemented | 501 | The path is an ITS-REST area the gateway does not serve, or no registry is configured. | The banner’s Registry line. | Configure a registry, [stored_queries] or federation.demographic_endpoint (Every request answers 501). |
endpoint-unknown | 400 | A directive or openEHR-federation-endpoint names no registry endpoint. | OPTIONS {base}/ lists the endpoints. | The client names an endpoint the registry holds (The registry document). |
organisation-unknown | 400 | A directive or header names no registry organisation. | OPTIONS {base}/ lists the organisations. | The client names an organisation the registry holds (The registry document). |
target-required | 400 | A write, a new EHR or a definition request names no node. | The request line alone. | The client names the endpoint in openEHR-federation-endpoint (Gateway codes). |
endpoint-several | 400 | A request routed to one node selects several endpoints. | The request line alone. | The client names one endpoint (Gateway codes). |
query-parameter-refused | 400 | A routed request carries a query parameter its operation does not declare. | query-parameter-refused at WARN under ferrofed::security. | The client drops the parameter (Declared values). |
node-timeout | 504 | The routed node, or a probed member, did not answer in time. | the routed request failed at ERROR, code node-timeout; the endpoint’s dependency state down. | Fix the node, or raise per_node_timeout (Timeouts). |
node-unreachable | 504 | The routed node, or a probed member, could not be reached. | the routed request failed at ERROR, code node-unreachable; the dependency state down. | Correct the endpoint url or the network path (Every node is offline). |
node-refused | 424 | The routed node refused the gateway’s onward credentials with 401. | The node’s own log shows the 401; the endpoint’s dependency state stays up. | Correct [credentials."<endpoint>"] (Nodes answer 401). |
targeting-conflict | 400 | Two targeting mechanisms select different node sets. | The request line alone. | The client uses one mechanism (Gateway codes). |
ehr-id-invalid | 400 | The path ehr_id is no HIER_OBJECT_ID. | The request line alone. | The client sends the ehr_id a node issued (Gateway codes). |
node-error | 424 | A probed member answered neither a success nor 404. | The message names the member and its status; a 5xx makes its dependency state failing. | Fix the member the message names (Health probes). |
probe-requires-uuid | 400 | An unrouted read names an ehr_id that is no bare UUID. | ehr-id-probe-refused at WARN under ferrofed::security. | The client names the endpoint (Gateway codes). |
query-name-invalid | 400 | A stored query name breaks the ITS-REST form. | The request line alone. | The client corrects the name (Stored queries). |
query-version-invalid | 400 | A stored query version is no major.minor.patch or prefix. | The request line alone. | The client corrects the version (Stored queries). |
query-type-unsupported | 400 | A definition’s query_type is not AQL. | The request line alone. | The client stores AQL (Stored queries). |
subject-literal | 400 | A definition names its patient by a literal. | definition-subject-literal at WARN under ferrofed::security. | The client names the patient through a $parameter (Stored queries). |
stored-query-held | 409 | The name and version are already stored, or a PUT with no version lost the race for each version the registry assigned it. | The request line alone. | The client stores a new version, or sends the PUT with no version again (Stored queries). |
stored-query-unknown | 404 | No stored query at that name and version. | GET {base}/operator/stored-queries lists what is held. | Store it, or the client names one held (Stored queries). |
preceding-version-invalid | 400 | A versioned write names no single version it amends. | The request line alone. | The client sends one quoted OBJECT_VERSION_ID in If-Match (Gateway codes). |
parameter-value-invalid | 400 | A routed value does not match what its operation declares. | parameter-value-refused at WARN under ferrofed::security, with the carrier. | The client corrects the value (Declared values). |
media-type-not-acceptable | 406 | Accept admits no media type the operation answers in. | The request line alone. | The client accepts a listed type (Gateway codes). |
media-type-unsupported | 415 | Content-Type is no media type the operation takes. | The request line alone. | The client sends a listed type (Gateway codes). |
subject-several | 400 | The subject of GET {base}/v1/ehr resolves at several members. | The message lists the endpoints. | The client names one endpoint (Gateway codes). |
resolution-unavailable | 424 | The cross-reference service could not answer, or none is configured. | the read of an EHR by subject was not served at ERROR under ferrofed_server::facade::subject::unserved; ferrofed_resolver_requests_total{outcome="unavailable"}; resolver not up. | Bring the resolver back, or configure one (Identity resolution). |
ehr-id-held | 409 | A new EHR’s ehr_id is held at another member. | a new EHR was refused: another member holds its ehr_id at WARN under ferrofed_server::facade::route::ehr, naming the holders. | The client creates it at the holder, or under another ehr_id (Gateway codes). |
definition-endpoint-targeted | 400 | A distributed definition carries a FROM ENDPOINT or ORGANISATION directive. | The request line alone. | The client stores it without naming members (Distributing stored queries). |
stored-query-fan-out-unsupported | 400 | A stored-query request names members, and federation.fan_out_stored_queries is off. | OPTIONS {base}/ declares definition.stored_query_fan_out as false. | Turn distribution on, or the client drops the header (Distributing stored queries). |
stored-query-read-only | 405 | A PUT reached the read-only files backend. | stored_queries.backend = "files". | Add the definition as a file and restart (Read-only: files). |
stored-query-reserved | 409 | A PUT names the reserved namespace eu.ferrofed.eehrxf. | The request line alone. | The client uses its own namespace (The gateway’s own queries). |
consent-denied | 403 | The consent pre-filter denied every member that might hold the subject. | ferrofed_consent_prefilter_requests_total{outcome="denied"}. | The patient’s consent decides; nothing to fix at the gateway (Consent). |
unauthenticated | 401 | No token the gateway accepts. | caller-refused under ferrofed::security, with the reason. | By reason (Every caller is refused 401). |
credential-malformed | 400 | Two Authorization fields, or one that is no credential. | caller-refused, reason malformed. | The client sends one well-formed Authorization field (Client authentication). |
scope-insufficient | 403 | No granted scope covers the operation, or the client is no demographic client. | caller-refused, reason scope or demographic-client. | Grant the scope, or list the client in demographic_clients (Scopes per route). |
purpose-of-use-required | 403 | The token declares no purpose of use. | caller-refused, reason purpose-of-use. | The issuer adds it, or set trust.purpose_of_use.required (Purpose of use). |
authentication-unavailable | 503 | The issuer’s key set or introspection endpoint cannot be had. | key-set-unavailable or introspection-unavailable under ferrofed::security. | Make the issuer reachable (Every caller is refused 503). |
operation-refused | 403 | The request addresses the ADMIN API under {base}/v1/admin/. | caller-refused, reason operation. | None: the gateway admits no caller there (Scopes per route). |
localization-unavailable | 424 | The localizer did not answer under on_failure = "closed", or its XCPD exchange could not be audited. | the localizer did not answer at WARN or the localization exchange could not be audited at ERROR under ferrofed_server::facade::localize; ferrofed_localizer_requests_total. | Bring the localizer or its audit repository back (Node selection). |
patient-context-missing | 403 | Only a bound patient/ grant covers the operation, and the token has no ehrId. | caller-refused, reason patient-context. | The issuer adds the ehrId claim (Patient grants). |
patient-confinement | 403 | A patient/ grant reaches beyond its patient. | patient-confinement at WARN under ferrofed::security, or caller-refused with reason patient-demographic. | The client stays inside its patient (Patient grants). |
patient-context-unavailable | 424 | The cross-reference could not resolve a patient/ grant’s patient, or the patient a national contact point’s TRC binds. | patient-context-unavailable, or trc-patient-unresolved for a TRC patient, at ERROR under ferrofed::security. | Bring the resolver back, and check ehr_id_system (Patient grants). |
subject-unavailable | 404 | Under disclose = false, no member the gateway may read holds the EHR; or a national contact point’s ITS-REST request reaches outside its TRC patient. | The request line alone, by design; a contact point’s break of its binding is trc-patient-mismatch under ferrofed::security. | Nothing: the answer hides a consent exclusion, or another patient’s existence (Withholding consent exclusions). |
overloaded | 503 | server.max_concurrent_requests requests were in flight. | limit concurrency under ferrofed_server::overload. | Size the limit (The concurrency limit). |
rate-limited | 429 | One caller sent more than [server.caller_rate] admits. | limit caller-rate under ferrofed_server::overload. | Raise the rate, or the caller slows down (The per-caller rate). |
access-unrecorded | 503 | The record of an access could not be stored. | the access could not be recorded, so its answer is withheld at ERROR under ferrofed_server::access. | Free the spool (Requests are refused 503 access-unrecorded). |
natural-person-required | 401 | A patient-data request’s token names no natural person. | caller-refused, reason natural-person. | Use a token of a person; a system’s token reaches no patient data (Professionals and assurance). |
authentication-assurance-insufficient | 401 | The token states no assurance at the issuer’s minimum. | caller-refused, reason assurance. | The user authenticates at the level, or correct [auth.issuer.assurance] (The assurance level). |
contact-point-attributes-required | 403 | A national contact point’s token falls short of the MyHealth@EU contact-point profile. | caller-refused, reason contact-point-attributes. | The contact point sends what the eHDSI assertion carries under ehdsi (National contact points). |
emergency-limit-reached | 403 | A professional a national contact point relays reached the limit of emergency accesses in the window. | emergency-limit-reached at WARN under ferrofed::security, naming the professional and EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS. | None at the gateway: the limit holds until the oldest access leaves the window (The emergency access limit). |
emergency-uncounted | 503 | The store did not answer when an emergency access was counted. | a national contact point's emergency access could not be counted at ERROR under ferrofed_server::access::emergency. | Bring the store back (FerroFED’s own store). |
trc-missing | 403 | A national contact point’s patient-data request on the ITS-REST face carries no patient of a treatment relationship confirmation. | The request line alone. | The contact point’s authorization server writes the TRC’s subject-id in the claim trc_patient names (National contact point). |
trc-unbound | 403 | A national contact point’s ITS-REST request names no patient or EHR its TRC binds: a population query, the creation of an EHR, a DEMOGRAPHIC request. | trc-patient-mismatch under ferrofed::security. | Nothing on the gateway: the contact point asks for its TRC patient’s data alone (National contact point). |
correlation-invalid | 400 | A national contact point’s correlation header is malformed. | caller-refused, reason correlation. | The contact point sends one header of 1 to 128 visible ASCII bytes (National contact points). |
configuration-invalid | 422 | A configuration revision on the admin listener is refused as config check refuses a file, sets the bootstrap core, holds a secret inline, or the reload refused it. | The message names the key, as ferrofed config check would. | Correct the revision and save it again (Configuration revisions). |
revision-stale | 409 | A save, apply or rollback was based on revisions another operator or replica changed first. | GET /admin/config names the newest and the running revision. | Read the revisions again and base the change on the newest (Configuration revisions). |
revision-unknown | 404 | A revision route names no revision the store holds. | GET /admin/config/revisions lists the history. | Name a held revision (Configuration revisions). |
rollback-unavailable | 409 | The running revision has no earlier one to roll back to. | GET /admin/config shows previous as null. | Save and apply the configuration you want instead (Configuration revisions). |
resource-file-owned | 409 | A console change names a member or stored query the file, its registry document or a directory provides. | GET /admin/config/resources names its owner. | Change it in its source, or give the console resource identities of its own (Console-managed resources). |
resource-unknown | 404 | A removal names no console member of the newest revision. | GET /admin/config/resources lists the console members. | Name a member whose owner is console (Console-managed resources). |
approval-required | 409 | The revision changes a resource config.approval names, and no second operator approved it. | GET /admin/config shows review.approval and review.approvals. | Another operator approves it with POST /admin/config/revisions/{id}/approve (Second-operator approval). |
approval-own-revision | 403 | The revision’s author approved it. | The revision’s author is the approver. | Another operator approves it (Second-operator approval). |
approval-not-required | 409 | The revision changes nothing config.approval names. | review.approval is empty. | Apply it without an approval (Second-operator approval). |
operator-unverified | 403 | A confirmation or an approval came from a caller with no verified token, such as a development loopback peer. | config-revision-confirmed or config-revision-approved is absent under ferrofed::security. | Send the operator’s own token (Commit-confirm). |
confirmation-unavailable | 409 | The confirmation names a revision that does not run awaiting one, or its deadline passed. | GET /admin/config shows current.awaiting, or config-unconfirmed-rolled-back under ferrofed::security. | Apply the change again and confirm it within config.confirm (Commit-confirm). |
confirmation-pending | 409 | The running revision awaits its confirmation. | GET /admin/config shows current.awaiting with its deadline. | Confirm it or roll it back, then apply (Commit-confirm). |
secret-store-unavailable | 409 | A secret value was sent and config.secrets_dir is unset. | The message names the secret’s key. | Set config.secrets_dir, or set the secret by a reference to a file (Secrets are write-only). |
configuration-unmanaged | 404 | The gateway runs with config.ui = "off". | GET /admin/config answers it. | Set config.ui = "managed" with a store, or manage the gateway by its file (Configuration revisions). |
Releases
FerroFED ships on the 0.0.x line. A milestone on the tracker is a release, cut when the milestone has no open issue left. This page lists what each release added to the gateway, newest first; the changelog has every change of every release, and Upgrading says how to move a deployment to a newer one.
| Release | Date | What it added |
|---|---|---|
| v0.0.18 | 2026-11-10 | Discharge and imaging reports, and the contact point. The FHIR face lists and serves discharge and imaging reports from a member’s openEHR record or its own FHIR endpoint, and receives discharge reports. A national contact point’s requests are held to the patient of its treatment relationship and to the MyHealth@EU emergency limit, and every node request goes under a token exchanged for its caller. |
| v0.0.16 | 2026-11-05 | Laboratory reports, and prescriptions from openEHR. The FHIR face receives, lists and serves laboratory reports in the European exchange format, from a member’s openEHR record or from the member’s own FHIR endpoint. A member that keeps its prescriptions in openEHR serves them as HL7 Europe prescriptions and receives dispensations, and the console draws every page on its server. |
| v0.0.15 | 2026-10-30 | The family’s server, and the MyHealth@EU catalogue. The gateway and its console run on the FerroHEALTH family’s shared server, configuration, health and telemetry crates, with TLS 1.3 by default and every error a problem details document. FerroFED’s reading of the MyHealth@EU requirements catalogue is published as a self-assessment, with the gateway changes it found for a national contact point. |
| v0.0.14 | 2026-10-28 | No patient index, and safer summaries. The gateway keeps no table of patients in any profile: every patient resolves through FerroPIX, the family’s patient index, which the quickstart runs. The patient summary marks a probable duplicate medicine across members, a repeated entry is never merged into one, and a dispensation is checked against the owner’s before it is written. |
| v0.0.13 | 2026-10-25 | The national contact point, served. A national contact point’s connector reads the patient summary, the document list and the prescriptions on the FHIR face and posts a dispensation, held by an end-to-end test. A received document is validated in full against its profiles, and the gateway ships a Kubernetes package with its images pinned by digest. |
| v0.0.12 | 2026-10-24 | The console rebuilt, and every finding judged. The operator console is rebuilt on FerroUI as ferrofed-console: every page is drawn on the server and every action works with no WebAssembly. Client authentication runs on the family’s shared resource-server crate, static bearer tokens are gone, and each release is refused while a scan finding in its images or binaries has no statement that judges it. |
| v0.0.11 | 2026-10-16 | Prescriptions on the FHIR face, and a new console. The FHIR face searches and reads a patient’s electronic prescriptions and writes a dispensation to the member that owns its prescription, passed through in the HL7 Europe MPD profiles from the members that serve them. The operator console is redesigned, and can keep the configuration as versioned revisions an operator saves, applies and rolls back. |
| v0.0.10 | 2026-10-15 | EHDS readiness, and ready for production. Every access to patient data is recorded with the verified caller and the categories of the data, as the EHDS logging component asks. A FHIR face answers the patient summary in the European exchange format and receives a patient summary into the member you name. Several replicas, restarts with no dropped request, TLS on the listener, overload limits and inbound metrics are built, and the specification is pinned at 1.0.0. |
| v0.0.9 | 2026-10-05 | An operator console, and a conformance statement. The operator console shows the members, incidents and stored queries and runs a federated query with every node’s status and completeness, signed in through OpenID Connect with no JavaScript of its own. The conformance statement scores each conformance point of the Federation Tier by a test or a recorded deferral, with the node profile checked against FerroEHR and EHRbase, and ferrofed conformance run scores your own deployment. |
| v0.0.8 | 2026-10-04 | Every caller verified, every node reached its own way. Each caller is authenticated at the gateway, and each node is reached with its own credential: OAuth 2.0 with DPoP, or the Dutch Nuts and FAPI 2.0 grants. Patients are located through XCPD, PIXm, PDQm or the Dutch NVI, consent can be checked with Mitz first, and every IHE exchange is audited. |
| v0.0.7 | 2026-10-03 | Ready to operate. A check to run before you admit a node, a registry reload on SIGHUP, health probes, Prometheus and OpenTelemetry metrics, a stored-query store on a file, PostgreSQL or a read-only directory, and a compose file in every release that starts the gateway in front of the CDRs you already run. |
| v0.0.7 | 2026-10-03 | Writes and definitions reach their owner. 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, and stored queries live at the gateway as immutable versions it can distribute to the members. |
| v0.0.6 | 2026-10-03 | Follow-ups reach the right node. A request under /v1/ehr/{ehr_id} goes to the one node that holds that EHR: the node the client names, else the one the gateway learned, else the one it finds. Two nodes claiming one EHR id are refused, never guessed between. |
| v0.0.6 | 2026-10-03 | Directed and self-describing. A client names nodes in the AQL or in a header and can select each row’s endpoint as a column. OPTIONS / says what the gateway does and which members stand behind it. |
| v0.0.6 | 2026-10-03 | Merged as one CDR would. ORDER BY with LIMIT, bounded OFFSET pages, DISTINCT and the aggregates are applied across nodes, and a copied version can be kept once. What cannot be exact is refused with a reason. |
| v0.0.6 | 2026-10-03 | Never a silent partial answer. Every node in scope is reported with a status. When a node that was asked does not answer in its time budget, the default is to fail the query. A client that asks for flagged partial rows gets them, every missing node named. |
| v0.0.3 | 2026-10-02 | No identifier in what the gateway sends a node. The patient identifier is resolved to each node’s local EHR id outside AQL, through an IHE PIXm PIX Manager, and consumed at the gateway. The rewrite refuses a query that would leak it, and an outbound gate checks every request again before it leaves. A body the client writes reaches its node as the client sent it. |
| v0.0.3 | 2026-10-02 | Transparent to the client. /v1/query/aql takes a conformant openEHR query with no federation syntax and answers one ordinary ITS-REST result set, with the federation’s additions in one metadata member that names every endpoint. |
The milestones are the roadmap, and the open issues the worklist behind it.
How the work is organised
The tracker is the worklist. Every piece of work is a GitHub issue with a plain summary, the specification sections it answers to, and an acceptance checklist. Nothing is tracked only in a chat or a commit message.
Milestones are releases
Milestones run on a 0.0.x line, starting at v0.0.1. A release is cut when its
milestone has no open issue left, and its notes are the matching section of
CHANGELOG.md.
The line:
| Milestone | Scope | Release |
|---|---|---|
| v0.0.1 | the repository setup and the architecture of record | 0.0.1, 2026-10-01 |
| v0.0.2 | the Cargo workspace and the first federated query over two nodes | 0.0.3, 2026-10-02 |
| v0.0.3 | identity resolution outside AQL (§5) | 0.0.3, 2026-10-02 |
| v0.0.4 | the federated answer: completeness, timeouts, ordering, de-duplication (§9 to §11) | 0.0.6, 2026-10-03 |
| v0.0.5 | the ITS-REST surface and follow-up routing (§7a, §12) | 0.0.6, 2026-10-03 |
| v0.0.6 | targeting and self-description (§8, §7a.2) | 0.0.6, 2026-10-03 |
| v0.0.7 | definitions and membership (§12.6, §12.7, §12b) | open |
| v0.0.8 | security and the bindings (§13 to §15, Annex A, Annex B) | open |
| v0.0.9 | conformance: every conformance point scored (§16, §17) | open |
A release can carry more than one milestone. v0.0.2 and v0.0.3 shipped together as release 0.0.3, and v0.0.4 to v0.0.6 as release 0.0.6; no final 0.0.2, 0.0.4 or 0.0.5 was tagged. The releases page carries each release with its signed assets, and the project board shows the same issues by status.
Type, priority and labels
Each issue carries GitHub’s issue type (Bug, Feature or Task) and the
organisation’s Priority and Effort fields. A Task also carries one
work-kind label (documentation, chore, refactor, perf, test or ci).
Labels name the specification an issue touches: spec:federation,
spec:openEHR, spec:IHE or spec:NL-GF. The research issues that wrote the
architecture of record carry research; their deliverable was cited evidence
on the issue thread.
Pull requests
A pull request answers one issue and says so with Closes #<n>. Every commit
is signed, every first-party file carries the SPDX header, and the pull
request is itself the acceptance of the contribution licence terms
(CONTRIBUTING.md).
Checks and gates
Every check is a committed script or a pinned tool you can run yourself. The
design of the workflows is in
docs/ci-cd.md;
this page is the short version.
The required check
A pull request merges into main only when conclusion passes, the one
job of ci.yml that passes when every other job in it passed or was skipped.
main also requires signed commits and a branch that is up to date with it.
The two tiers of ci.yml
The first tier runs on every change, because it needs no Rust build (the manifest check installs the toolchain only to read the manifests):
| Check | What it guards |
|---|---|
| zizmor | workflow security |
| actionlint | workflow correctness |
| shellcheck | every first-party shell script |
| hadolint | every first-party Dockerfile |
| kubeconform | the Kubernetes package under deploy/kubernetes/, its base and its example overlay rendered by kustomize, in strict mode |
| observability | the Grafana dashboard and the Prometheus rule file under deploy/observability/: promtool check rules, and every metric they name is one the gateway exports (scripts/checks/observability.sh) |
| comment-style | the comment budgets of the Rust sources |
| file-length | no hand-written Rust file or book page over 1000 lines; a listed file may shrink and never grow |
| duplication | the duplicated lines of the hand-written Rust, measured by jscpd at 50 tokens over 5 lines, may fall and never rise; a rise names both places of each new block |
| versions | every repeated pin agrees with docs/VERSIONS.md |
| favicon-sync | the book’s favicons match the brand mark |
| conformance-matrix | the conformance matrix agrees with the specification and with the tests that claim each point, and the README conformance badges agree with the matrix and the AQL golden pass list |
| e2e-placement | every test that checks the FERROFED_E2E gate lives in its crate’s e2e test module, and the CI end-to-end job still sets the gate and selects test(/^e2e::/) |
| aql-splice | no Rust source outside the tests builds AQL text with format!, concat!, push_str or +: a query is a template openehr-query parses, its values bound or sent as query parameters |
| obligations | the obligations checklist agrees with the vendored specification, the conformance matrix and the tests it names, and a re-pin fails until every changed page is reclassified |
| technical-documentation | every element of Annex III of Regulation (EU) 2025/327 has a row in the technical documentation index whose file and heading exist, and a version whose harmonised components differ from the previous release has its review rows (Art 30(2)) |
| annex-ii | every point of Annex II of Regulation (EU) 2025/327 has a row in the Annex II checklist whose phrase is in that point’s text, whose evidence pages, headings and tests exist, and whose gap names its issue, with the page rendered from the checklist |
| chapter-iii | every obligation of Chapter III of Regulation (EU) 2025/327 on the manufacturer, and every item of Annex IV, has a row in the conformity record whose phrase is in that provision’s text, whose Art 105 date is right, whose evidence exists and whose gap names its issue, and no row claims more than the rest of the record supports |
| site-links | every internal link and anchor of the assembled site (the landing page and this book) and of the README, checked offline by lychee, so a page or an anchor that does not exist fails the change |
| tracker-helpers | the self-tests of the scripts/gh tracker helpers |
| crate-version-guard self-test | the crate-version guard judges only what a pull request changes, against a stub repository |
| manifests | every Cargo manifest, of the workspace and of fuzz/, parses, read by cargo metadata without compiling, so a manifest a merge broke fails here first; the one first-tier check that installs the pinned toolchain |
The second tier is the Rust lane: formatting, the fuzz crate’s lockfile,
clippy, the tests, the end-to-end suite against two containerised nodes, the
release compose files (no compose file builds the image, the release
compose.yaml renders, and its example ferrofed.toml and registry.toml
pass ferrofed config check, and so does the example Kubernetes
ConfigMap with synthetic secrets), rustdoc, cargo deny, the MSRV build, every feature of each published crate
on its own, the packaging dry run, the crate-version guard and dependency
review. A detect job gates
it on the root Cargo.toml, which exists, so the tier runs on every change;
the crate-version guard and dependency review run on pull requests only.
The conformance report job reads the JUnit reports of the test run and
the end-to-end run, and writes the report section 16.4 of the specification
asks for: every test track of section 16.3 and every conformance point of
section 17 as pass, fail, not run, deferred or open, with the issue and the
reason of each row that is not scored. A point section 17 scores against a
node or the federation operator is reported in a class of its own, never as
a gateway pass (section 16.2). Its result comes from the node profile
checks, which the end-to-end run executes against each harness CDR product,
and the report lists what each check observed. The report is in the job
summary and in the conformance-report artifact. The job fails when a
covered track or point did not pass, and a node or operator point never
fails it. Locally, with Docker running, bash scripts/conformance/report.sh --run runs the whole workspace with the FERROFED_E2E gate set and writes
target/conformance/report.md, report.tsv and node-profile.tsv.
The fhir-validator (EEHRxF documents) job runs HL7’s FHIR Validator over
every patient summary document the tests write, offline, against the
vendored HL7 Europe packages, and fails on an error. The tests write their
documents only when FERROFED_EMIT_DIR names a directory. The job’s
results, the validator’s output with every issue listed, are the
eehrxf-fhir-validation artifact. Locally, with Java 17 or later, set
FERROFED_EMIT_DIR for a test run and run
scripts/checks/fhir-validator.sh over that directory with the version and
checksum the job sets.
The Docs workflow builds this book and the landing page on every pull
request, and publishes them from main. A book that does not build fails it.
The required link check is the site-links job of ci.yml, because the
Docs build is no required check.
No check fails a page that cites a closed issue as remaining work. Whether an issue is closed is the tracker’s state, and a list of closed issues committed here would be wrong the moment the next one closes, so an offline check has nothing true to compare with. A check that asks the tracker could see the state, but a page also cites closed issues on purpose, as the record of what shipped, and no rule can tell that use from a claim of remaining work. A page names a closed issue with “(now closed)” or in the past tense, and review holds it to that.
Running them locally
bash scripts/checks/versions.sh --self-test
bash scripts/checks/versions.sh
bash scripts/checks/comment-style.sh --all
bash scripts/checks/file-length.sh --self-test
bash scripts/checks/file-length.sh
bash scripts/checks/duplication.sh --self-test
bash scripts/checks/duplication.sh
bash scripts/checks/favicon-sync.sh
bash scripts/checks/conformance-matrix.sh
bash scripts/conformance/report.sh --self-test
bash scripts/checks/e2e-placement.sh --self-test
bash scripts/checks/e2e-placement.sh
bash scripts/checks/aql-splice.sh --self-test
bash scripts/checks/aql-splice.sh
bash scripts/checks/obligations.sh --self-test
bash scripts/checks/obligations.sh
bash scripts/checks/technical-documentation.sh --self-test
bash scripts/checks/technical-documentation.sh
bash scripts/checks/annex-ii.sh --self-test
bash scripts/checks/annex-ii.sh
bash scripts/checks/chapter-iii.sh --self-test
bash scripts/checks/chapter-iii.sh
bash scripts/checks/site-links.sh --self-test
bash scripts/checks/site-links.sh
bash scripts/checks/crate-version-guard.sh --self-test
bash scripts/checks/manifests.sh --self-test
bash scripts/checks/manifests.sh
for helper in fields labels migrate-fields rel; do bash "scripts/gh/$helper.sh" --self-test; done
find scripts .claude/hooks -name '*.sh' -exec shellcheck --severity=style {} +
actionlint
zizmor --min-severity=low .github/
scripts/site/assemble.sh _site
Advisory analyzers
CodeQL, OpenSSF Scorecard and SonarQube Cloud run too. Their findings are read and weighed, but they are advisory and never a reason on their own to change code.
The performance gate
Every pull request that changes what the gateway is built from runs the
perf job, and so does every push to main and every release. It fails
when the gateway does work while idle, or is slower, heavier or busier under
load than the build of its base, measured beside it on the same runner in
the same job. FerroPIX shipped an outbox sender that busy-looped at about
8,000 PostgreSQL transactions a second while idle, and nothing caught it
before release; this gate catches that shape. No specification governs it:
it is FerroFED’s own design over the family’s measuring tool,
ferrohealth-perf.
Why the head is judged against its base
GitHub’s hosted runners differ: two runners that report the same CPU model and memory measured one commit 25 to 45 % apart in p50 latency and CPU per request (FerroEHR #5095). A baseline stored from one machine failed unchanged code on another, so the gate keeps none. It builds the base of the change and the change itself, the head, runs both on one runner, and judges the head over the base. The difference between machines reaches both alike and cancels.
The base is:
- for a pull request, the merge base of the commit CI checks out with the pull request’s base branch;
- for a merge group, its base on
main; - for a push to
main, the commit before the push; - for a manual dispatch, the previous release tag;
- for the release commit, the previous release tag, or the oldest commit after it that carries the gate contract when that release carries none (see below).
What runs
The perf workflow’s build job installs ferrohealth-perf at its pin in
docs/VERSIONS.md, and scripts/perf/gate.sh build builds the release
gateway of the base, from git archive of the base commit under
target/perf-base/, and of the head, and keeps the base tree’s
configuration contract beside its binary. scripts/perf/gate.sh pack hands all
of them to the attempt jobs as one artifact, so an attempt builds nothing
and a retry measures at once (#1807). In each attempt,
scripts/perf/gate.sh unpack restores them and scripts/perf/gate.sh run
then:
- starts the head’s quickstart, the four FerroEHR nodes and FerroPIX from
compose.yaml, withperf/compose.yamlpublishing FerroPIX on loopback, seeded withdocker/quickstart/patients.tsv, and gives the base its own copy of each of those services that differs in the base’s tree, as the compose projectferrofed-perf-baseon127.0.0.2; - starts a PostgreSQL server over TLS for FerroFED’s own store, with a database for each gateway;
- checks with
ferrofed config checkthat the base binary reads its own tree’s configuration, and starts both gateways, each over its own nodes and FerroPIX and under its own tree’s configuration contract: the head on port 8080 and the base on port 8070, each with the stored-query registry on its own store database; - runs
ferrohealth-perf aboverperf/plan.toml; - plants a poller of the head’s store that never waits, and runs the idle
phase of
perf/busy-loop.tomlbeside it, which must fail on idle transactions. That proves the gate still catches the case it was built for.
The A/B run has two parts:
- Idle. No request for 60 seconds. The head’s CPU time must stay under 2 % of one core and its store under one committed transaction a second, with the store’s sweep and every other background worker running. These limits are absolute and compared with nothing.
- Load, in pairs. Four pairs, each a load phase against both gateways at once: 20 arrivals a second to each for 60 seconds after a 10-second warm-up, open loop, one gateway’s arrivals half an interval after the other’s, the gateway that leads alternating by pair. Run side by side, both arms see the same runner, whose speed can move by a third from one minute to the next. The arrivals go over the gateway’s hot paths: the self-description, a patient’s federated query, a population query, a stored query run by name, a template list routed to the one member the registry names, and a national contact point’s patient summary under its treatment token and as an emergency access (see below). Every request must be answered with the status the plan names.
A national contact point under load
perf/ferrofed.toml trusts a synthetic national contact point whose
tokens are bound to a DPoP key (RFC 9449), with the FHIR face for its
patient summary and the emergency access limit [emergency] threshold = 5
per window = "1s". gate.sh makes the contact point’s signing key, its
key set and the P-256 DPoP key under target/perf-run/, and mints its
tokens for the treatment and the emergency purposes, which the plan names
as {ncp_token} and {ncp_emergency_token}. Two request groups use them
(#1744):
| Group | Request | Token |
|---|---|---|
contact-point-summary | GET /fhir/Patient/$summary?identifier=urn:oid:2.999.1.1%7Cffd-test-0001 | the treatment token |
contact-point-emergency | the same summary, as an emergency access | the emergency token |
Each request names the key in dpop_key_file, so ferrohealth-perf sends
its token in the DPoP scheme with a fresh proof, and carries the
ncp-correlation-id header the contact point declares. Node A is reached
through an onward lane of kind persons, which carries no contact point’s
professional
(The onward lane of a node),
so node A fails before anything is sent to it. Both groups send
openEHR-federation-completeness: partial, which federation.best_effort
admits at its default, and the summary names node A beside what nodes B to
D answer. No other group sends the header, so nothing they measure changes. The tool sends the
plan’s requests in turn, so the emergency group gets one arrival in seven,
under three a second at the plan’s rate of 20, below the limit; a
self-test check fails a plan whose rate over its request count reaches 5.
The gateway counts every emergency access in its store, with or without an
access log (#1737).
After the A/B run the gate sums the emergency accesses the head answered
as expected in the A/B report and reads how many attempts the head’s store
wrote to ferrofed.emergency_access, from PostgreSQL’s cumulative
statistics (one insert or update per attempt). Fewer attempts than answers
fail the gate, and the job summary names both counts. The base of the
change serves the same groups once it carries the contact point, which
every release from v0.0.17 does; a base that answers them otherwise only
adds a note.
The onward lane under load
Node A is reached through an onward lane
(The onward lane of a node),
so every request a caller sends to node A has the caller’s token exchanged
for one of node A’s issuer, and the gateway caches the issued token. The
test kit’s ferrofed-perf-token stands in for node A’s authorization
server: gate.sh starts it before the nodes, it answers the
client-credentials grant and RFC 8693 token exchange with ES256 tokens, and
it writes its key sets under target/perf-run/onward, which
perf/compose.yaml mounts into node A beside perf/ferroehr-onward.toml,
the quickstart’s node configuration with the token endpoint’s issuers
trusted. The other three nodes run with their authentication off, so the
gateway sends them no credential: from FerroEHR 5.7.0 a Basic user is a
person bound to a demographic PERSON, and the gate measures the gateway,
not the nodes’ sign-in.
Node A’s share of the load, the definition reads and its part of every
query, carries the exchange and the cache in both arms. A missing or broken
cache shows as one more round trip per request, which a 25 % latency
tolerance can miss, so the gate also counts the exchanges. After the A/B run
it sends 200 definition reads to node A through the head and reads the
token endpoint’s /calls: more than 5 % as many exchanges as requests fails
the gate. Then it starts a third gateway, the head with cache = false on
node A’s onward table, and sends it the same 200 reads; it must fail that
limit, which proves the count still catches an uncached lane. The job
summary names both counts.
Each arm runs its own tree’s configuration
Each arm boots under its own tree’s configuration contract: the tree’s
perf/ferrofed.toml and the FERROFED__ overrides of its perf/arm.env,
one NAME=VALUE a line. The gate fills @PORT@ with the arm’s port and
@DIR@ with its work directory, where it writes the store’s connection
string, and sets FERROFED_CONFIG and FERROFED__USAGE_REPORT__ENABLED=false
itself. It refuses any other placeholder and any line that sets no
FERROFED__ variable, with exit 2. A pull request that renames a key
changes both files for the head alone, so the base keeps reading the keys
its binary knows. Both arms start from the checkout, so a relative path in
either configuration reads the head’s tree and the files run makes under
target/perf-run/.
An earlier gate started both arms with the head’s perf/ferrofed.toml, so
both did the same work: when
#1741 gave the head
the contact point, the FHIR face and the PDQm client, its peak memory came
out at 1.24 of the base. That gate could not judge a change that renamed a
key, because the base binary refused the head’s file at boot
(#1813). A change to
perf/ferrofed.toml that enables more work is now measured as part of the
head, the family rule FerroBRIDGE, FerroTERM and FerroPIX follow.
The contract includes the services the tree pins. Each arm runs over the
four FerroEHR nodes and the FerroPIX its own compose.yaml and
perf/compose.yaml start, at that tree’s image pins and under that tree’s
configuration and seed. The gate compares the two trees’ resolved compose
definitions service by service, with the files each one mounts and, for the
nodes, the files they are seeded from. Every group of services connected by
depends_on that holds a difference, such as FerroPIX with its database
and its seed, the base gets its own copy of, started from the base’s tree
as the compose project ferrofed-perf-base with every port it publishes
on 127.0.0.2 in place of 127.0.0.1. The groups that are the same, the
nodes when only FerroPIX moved, both arms share, so the runner holds one
copy of them. The base gateway gets its own perf/ferrofed.toml with each
address of its own services’ ports, on 127.0.0.1 or localhost, moved to
127.0.0.2, and its own registry document of the nodes it runs over.
Nothing else in the file changes. A base whose
services publish beyond 127.0.0.2, or whose file reads no
target/perf-run/registry.toml, is refused with exit 2. When FerroPIX
0.0.25 moved its machines from Basic credentials to client credentials
tokens (#1825), a
gate that ran both arms over the head’s FerroPIX had the base’s Basic
requests refused fast, and judged the head over a base that did no
patient resolution at all.
A base from before perf/arm.env has its overrides derived from its own
scripts/perf/gate.sh: the gate evaluates that script’s gateway_env()
with its own own_urls() and constants and keeps the FERROFED__ lines.
A tree with neither file, or a gateway_env() that sets nothing, is
refused with exit 2.
Before the gateways start, the gate runs ferrofed config check with the
base binary over the base’s own configuration and overrides. A base that
refuses them stops the run with exit 2, naming each unknown key or quoting
the refusal:
perf-gate: the base at <commit> does not know the key(s) emergency of its own perf/ferrofed.toml: the pair is not judged
The release base
The release lane’s base is the previous release. A release that carries no
gate contract (neither perf/arm.env nor a gate whose gateway_env() sets
a gateway’s overrides; v0.0.16 has none) cannot be booted as an arm, so the
base is then the oldest commit after that tag that carries one.
scripts/perf/gate.sh build resolves it and writes the base and why to
target/perf-base/base.md, which the job summary shows, and the release
workflow adds that line to the release notes:
Performance: The base is `<commit>`, the oldest commit after v0.0.16 that carries the gate contract, since v0.0.16 carries neither perf/arm.env nor a scripts/perf/gate.sh whose gateway_env() sets a gateway's overrides.
The release workflow refuses to draft a release whose gate named no base.
Reading the report
Each load metric (throughput, latency p50, p95 and p99 per group, peak
memory, CPU time per request and transactions per request) is the median of
an arm’s four phases. Its noise floor is the worse of the two arms’ spread,
how far an arm’s worst phase strayed from its median. A metric breaches when
the head’s median is worse than the base’s by more than the larger of its
tolerance and that floor. The tolerances are the plan’s [tolerance]
table: latency and CPU 25 %, throughput 20 %, memory 20 %, transactions per
request 10 %. A metric fails only when the head also breaches against the
base in most of the pairs, so a stall in one phase clears and a regression,
which repeats in every pair, fails.
The job summary names the base commit, the head’s idle limits, and per
metric the base, the head, their ratio, the tolerance, the floor, the
threshold and in how many pairs the head breached. Requests the head
answered other than expected fail the gate; those the base answered other
than expected, such as a route the change adds, are notes. Each attempt
uploads its reports as perf-report-attempt-<n>: the JSON reports under
target/perf/, both gateways’ logs, the compose and seed logs, the base
commit and base.md, the line naming the base, so ferrohealth-perf compare --report target/perf/latest.json
judges a report again without a gateway.
The tool exits 0 for a pass, 1 for a breach or a request answered other than expected, and 2 for a run it cannot judge: a phase whose generator could not hold its rate, whose second half differs from its first, or that stalled (every route’s p99 over ten times its p50 at once), and a base that fell behind the schedule.
Retries
Some runners stall every route for a whole job: on an AMD EPYC 9V74 the
tails reach 100 to 300 ms while the p50 holds
(FerroPIX #511). An
attempt that ends with exit 2 runs again on a fresh runner on any CPU model,
since a run that cannot be judged says nothing about the code. An attempt
that ends with exit 1 runs again only on a CPU model the perf workflow lists
in STALL_PRONE. An attempt job cancelled while the run itself goes on, by
a runner that shut down or a job that reached its time limit, has no exit
code and says nothing about the code either, so the next attempt runs. At
most two retries run, and scripts/perf/verdict.sh passes the gate when the
last attempt that ran passed. A breach on any other model fails at once, and
a run cancelled by its concurrency group or by a person retries nothing. The
job summary names each attempt’s CPU model, its verdict and why a retry ran.
Changing the plan or the tolerances
The plan is the head’s: a change to perf/plan.toml is measured on both
gateways in the same pull request. A tolerance is the product’s and moves
only in a pull request that says why.
Running it yourself
The gate reads each gateway’s CPU and memory from /proc, so it runs on
Linux with Docker, Compose, curl, git and openssl:
cargo install ferrohealth-perf --version =0.11.0 --locked
bash scripts/perf/gate.sh build origin/main
bash scripts/perf/gate.sh run
bash scripts/perf/gate.sh --self-test checks the pin reader, the plan
rendering, the registry rewrite, the base resolution with the release
base, each arm’s overrides from perf/arm.env and from an older tree’s
gate, the move of the base’s services and configuration to its own
address, the base’s refusals and the two plans’ idle limits on any machine.
Adding a country
The Federation Tier’s core names no national service. A deployment fills
the roles the specification names with the services of its region:
resolution (§5.2), localization (§14), the consent pre-filter (§13.2.1),
addressing (§8, §15) and the authentication to each node (§13). FerroFED
holds each region’s services in one binding: one module of the server, one
Cargo feature, and one implementation of the Binding trait. The IHE binding
of Annex A and the Dutch binding of Annex B are built this way, and a new
country follows the same three steps.
Start from evidence. The research on
#488 maps the five roles
for several countries and names which existing crates cover each one. A
role that ihe-iti or the onward OAuth 2.0 grants already cover needs no new
code, only configuration.
1. A specification crate
The wire of each national service goes in a library crate under crates/,
named for the specification it implements and never for FerroFED, with one
feature per service. It depends on nothing in FerroFED, so another product
can use it as it is. Vendor the specification it implements first, with a
scripts/vendor/*.sh fetch script, a PROVENANCE.md and a row in
docs/VERSIONS.md, and hold the crate to it with tests at the wire.
2. The adapters and the binding module
The adapters that turn the crate’s clients into the role traits
(Localizer, Resolver, ConsentPrefilter, Demographics, in
app/ferrofed-identity/src/role/) sit in a folder of their own,
app/ferrofed-identity/src/<name>/, behind a feature of the same name,
beside the IHE adapters in ihe/ and the Dutch adapters in nl/. An onward
grant the country’s nodes need is a folder of its own under
app/ferrofed-engine/src/onward/grant/, behind a feature, as the Nuts grant
is under nl/.
The binding itself is one module, app/ferrofed-server/src/binding/<name>/,
with a unit struct that implements Binding. It declares:
- its sections: each key it reads, and whether a registry reload applies
a change (
Reload::Applies) or the change takes a restart (Reload::Restart); - its resolve: each section read and checked into its settings, refused
under the key at fault, with every secret read from its
_filesibling; - its offers: the roles its configured sections fill, read without building anything, so the server can hold the role rules first;
- one builder per role it fills:
resolver,demographics,localizerandprefilter, each built over the registry snapshot; - its transport sites: every URL a patient identifier or a credential
travels to, held to
httpsoutside the development profile; - its reload rules:
effectivekeeps the boot’s value of what takes a restart, andneeds_restartnames each such key that changed; - its self-description: the
modeeach role it builds carries, whichOPTIONS {base}/declares aslocalization.modeorconsent.prefilter; - its health indicators and instruments, when it records through something of its own, such as an audit trail.
An onward credential kind of the region, such as the Nuts grant of the Dutch
binding, is an OnwardGrant the binding resolves from its table under
[credentials."<endpoint id>"].
The server holds the role rules for every binding: at most one resolver, one consent pre-filter and one localizer of a binding’s own. Two bindings that both fill one of those roles are refused with one error naming both sections, so a new country adds no pairwise check.
3. One feature line
Register the binding in four places, each under its feature:
- the feature in
app/ferrofed-server/Cargo.toml, asbinding-<name>, with the optional dependencies it brings in, and indefaultwhen the released binary should carry it; - the module and its entry in the list of compiled bindings in
src/binding/mod.rs; - its section in
Configand its resolved settings inSettings, a field each, so the configuration refuses the section in a build without the feature; - its process state, instruments or registry source in
src/binding/process.rs, when it has any.
Gate its tests under tests/it/ with the same feature. The
features (cargo-hack) job of CI lints the server with each feature alone,
so a binding that only builds beside another one fails there.
What a binding does not do
A binding never adds a branch for a node, a vendor or a region to the core, and never lets a patient identifier reach a node in any carrier (§5.4, N33). A role the specification leaves to the node, such as the consent decision itself (N27), stays with the node; the binding’s pre-filter only narrows who is asked.
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).