Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/aql and its GET form, with one ITS-REST RESULT_SET and a meta.federation report 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 BY with LIMIT, bounded OFFSET pages, 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_id to 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.

PageThe question it answers
A federated query, end to endWhat happens between your AQL and the merged answer?
Where the patient identifier stopsHow does the gateway keep the patient identifier away from every node?
Follow-ups and writesHow does a read or a write find the one node that owns the record?
Definitions and stored queriesWhere do templates and stored queries live?
Trust and keysWho signs what, and who trusts which key?
DeploymentWhat 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 with printer::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.

StateWhere it livesSurvives a restart
Organisations, nodes, endpoints, system_ids and creating_system_id mappingsthe registry document you review, or the mCSD directory, loaded into a snapshotyes, it is your file or your directory
ehr_id to node indexmemory, bounded, least recently used out firstno
creating_system_id routes learned from answersmemoryno
Resolution bindings per verified callermemory, under a lifetime and a capacityno
Access tokens from the nodes’ token endpointsmemory, per endpoint, until 30 seconds before each expiresno
Stored-query definitionsredb, PostgreSQL or read-only filesyes
Outbound credentials and the signing keysone secret file eachyes, 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 is not-localized and never asked. A directed query is never localized (§8).
  • A localizer that fails fails closed: no member is asked, every member is not-localized with the localizer’s error, and meta.federation.localization.error carries the same error, so an outage never reads as a patient with no data. Only federation.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-resolved and does not fail the query (N6). A PIX Manager that cannot answer is a failure, reported on the member and failing the query 424 under 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 BY with LIMIT n: each node is sent LIMIT n, and the gateway orders the union and cuts it to n again (§11.6.1, N39, CP-32). A node that returned n rows out of the Tier’s order is node-error, because its cut may hide a row.
  • OFFSET k: each node is sent LIMIT k + n, up to a window you configure, and the gateway slices the merged order (§11.6.2, N39).
  • Aggregates: COUNT, SUM, MIN, MAX and AVG over several nodes come back as one row, recombined exactly; any other undirected aggregate is a 400 (§11.6.3, N14, CP-10).
  • De-duplication: off by default. openEHR-federation-dedup: version-identity keeps one copy of a version held at several nodes, the one from the CDR that created it, and meta.federation.dedup names 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-resolved and consent-denied are answers. They clear complete and never fail the query (§11.3, N6). A patient no member holds is a 200 with no rows.
  • A localizer that failed closed leaves every member not-localized, so the answer is a 200 with complete: true and no rows. Read meta.federation.localization.error to tell that apart from a patient no member holds (§14.1).
  • excluded and not-localized members were never in scope, so they do not clear complete (§11.1).
  • Read meta.federation.complete, never the status code: a 200 can carry complete: false (§11.4).
  • A partial request for an aggregate the gateway recombines is a 400, 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 an ENTRY-level subject with its issuer or type. 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, != or LIKE, the subject in ORDER BY, or the patient value anywhere else in the query is a 400 before any dispatch (§5.4.1, §5.4.3).
  • The value is the test. A clinician or facility identifier on composer or health_care_facility is 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_id rows, 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 Host header come from your registry, never from a request, so the gate does not read them (§5.4.1).
  • The openEHR-federation-client token 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_IDENTIFIER inside a committed COMPOSITION reaches 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 node GET {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 an ehr_id that is a bare UUID, because any other HIER_OBJECT_ID form 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.

SurfaceWhat it carries instead
Error bodies and refusalsthe position of the offending path or leaf, never the value
The request logmethod, route template, status, latency and request id; no body, AQL text, header value or request path
Metrics labelsclosed enums and registry endpoint ids only
A node’s error message quoted in meta.federationthe message with each consumed value masked
The stored-query storeparameterised AQL; a literal patient identifier is refused
Integrity incidentsthe 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 toThe 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 objectthe creating_system_id inside the version’s uid: only the CDR that created it may take the write (§12.4, N23)
create a new EHRthe endpoint you name, and nothing else (§12.4, N23)
know which node gave you a rowthe 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 in openEHR-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_ttl after the last resolution that returned it, and up to federation.binding_capacity bindings 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_id index, 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 whose system_id is that uid’s creating_system_id, as the registry maps it (a member’s own system_id or 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 your ehr_id to 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_id that is a bare UUID (§5.4.1). A member that fails or times out during the probe fails the read 424 or 504, 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.6 template-fanout).
  • The fan-out answer carries meta.federation with 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 redb for 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.json and declared as federation.auth.jwks_uri in OPTIONS {base}/, so a node finds it without an out-of-band arrangement (§13.1 jwks-discovery). A rotation keeps the current and the previous kid in 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-client header 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 SIGHUP with no restart, and every credential and the signing key is a file named by a _file key (Configuration).
  • The Step 1 services. The PIX Manager resolves each patient to an ehr_id per 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 oauth section 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 an onward table beside it the token is issued per verified caller, and with a DPoP key it is bound to the gateway’s key. A nuts section, or an oauth section 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 FHIR AuditEvents 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 target ferrofed::audit instead, without the patient or the caller; a production gateway with a registry refuses log for [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] listen is 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
  1. The façade. The client sends POST {base}/v1/query/aql and may identify the patient the openEHR way, with WHERE e/ehr_status/subject/external_ref/id/value = <patientId> (§4.1, §7).
  2. 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).
  3. Fan-out. Each node with a resolved ehr_id receives the node AQL: the subject predicate replaced by e/ehr_id/value = '<resolved>', every other patient identifier removed or the query refused, and everything else forwarded unchanged (§7.1).
  4. Combine. The gateway concatenates the rows, applies DISTINCT and ORDER BY across nodes, and reports every node it asked under meta.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_id to 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 path ehr_id is 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, OFFSET paging, undirected aggregates), it says so in OPTIONS or 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_id index 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/aql answers one ITS-REST RESULT_SET over 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 with Prefer: wait, and the §11.2 statuses with stable error codes (§9.5, §11.1 to §11.5).
  • ORDER BY with LIMIT re-applied at the Tier, bounded OFFSET pages, SELECT DISTINCT, recombined COUNT, SUM, MIN, MAX and AVG, and opt-in version-identity de-duplication (§10, §11.6).
  • Routing of the EHR resources under a path ehr_id to the one node that holds the EHR, byte for byte, through the targeting headers, the ehr_id index and the ask-all probe; the creating_system_id map learned from every answer (§7a.1, §12.2, §12.5).
  • The FROM ENDPOINT and ORGANISATION directive, 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_id collisions refused and raised as integrity incidents (§12.4, §12.5.2, §12a.1, N23, N42).
  • GET {base}/v1/ehr by subject, the GET forms of query execution, both ehr_id forms 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 check for the identifier-integrity conditions of §12b.2 (N42a), the registry reload on SIGHUP, the stored-query registry on redb, 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-its 0.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_jwt assertion, DPoP-bound tokens, and the healthcare attributes in an RFC 9396 authorization_details object (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’s ehrId resolved 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’s ehr_id there; a bare ehrId is 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 as consent-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-denied and 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-localized and never asked, and a localizer that does not answer fails closed, with its error on every member and in meta.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 AuditEvent on 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 https outside 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 traceparent of 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-ihe and binding-nl, wired through one Binding trait, with the RFC 8414 checks from the family’s ferrohealth-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-logging as 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 eehrxf crate 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 image ghcr.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 CONTRIBUTION in XML answered 415 before 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-smart 0.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, --set on 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 eehrxf from FerroBRIDGE, and the access record of a routed openEHR read or write naming its object as what.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 a Bundle-eu-lab and 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, the DPoP proofs and a contact point’s dispensations (#1636, #1637).
  • Every issuer an [[auth.issuer]] of its own kind on ferrohealth-smart 0.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 its tls sub-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 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 (#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-smart 0.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-contained compose.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 persons on 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, by ferrohealth-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-smart 0.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" in OPTIONS {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 recorded ehds-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], and config check names 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 query 424 until 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 COMPOSITION reaches its node byte for byte, a DV_IDENTIFIER inside 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-validator cannot evaluate is listed in the outcome of the write as a warning, not refused, and a required binding 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 501 there unless you name one member to serve it, and 501 for 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:

  1. Does a release ship it, and does a test hold it? A statement that ascribes more is rewritten, (a).
  2. Does it leave out a limitation a professional user would need? The limitation goes on the Limitations list and next to the statement, (b).
  3. 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

ReleaseReviewed onTexts readFindingsFixed in
v0.0.102026-10-06see below16, below#674, except finding 16
v0.0.102026-10-15see below17 to 30, belowthe v0.0.10 version bump, except finding 16, which stands
v0.0.112026-10-15this page and the clinical safety risk file31 and 32, below#962
v0.0.112026-10-16see below33 to 43, belowthe v0.0.11 version bump, except finding 16, which stands
v0.0.122026-10-24see below44 to 55, belowthe v0.0.12 version bump
v0.0.132026-10-25see below56 to 63, belowthe v0.0.13 version bump
v0.0.142026-10-28see below64 to 69, belowthe v0.0.14 version bump
v0.0.152026-10-30see below70 to 74, belowthe v0.0.15 version bump
v0.0.162026-11-05see below75 to 81, belowthe v0.0.16 version bump
v0.0.182026-11-10see below82 to 91, belowthe version bump of v0.0.17, tagged and never released, re-cut as v0.0.18
v0.0.192026-11-11see below92 to 95, belowthe v0.0.19 version bump
v0.0.202026-11-12see below96 to 100, belowthe 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:

  1. README.md, the book’s introduction, the landing page and llms.txt said a client “never learns it was federated”. A client reads every member with its status in meta.federation. Now: a client sends an ordinary query with no federation syntax and gets back an ordinary result set whose meta.federation names each node.
  2. README.md and 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.
  3. 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.
  4. The landing page credited mutual TLS to a node to v0.0.8. It shipped in v0.0.9 (#492).
  5. 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.
  6. 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.
  7. The README’s quickstart sent the body 5820 in place of the query. It now reads the query from standard input.

Against Art 28(b), a limitation left out:

  1. Nothing listed the limitations a professional user needs before deploying FerroFED. The claims page now carries them: Limitations.
  2. README.md claimed ITS-REST 1.1.0 “on both faces” without saying that the DEMOGRAPHIC area is not federated and an unserved path answers 501. Now said beside the claim.

Stale or wrong statements, which read as claims about the release a user runs:

  1. README.md named v0.0.7 as current and v0.0.8 as unreleased.
  2. llms.txt named v0.0.8 as on main.
  3. The claims page listed v0.0.9 as unreleased and its query console as planned.
  4. The engine’s and the console’s READMEs listed follow-up forwarding and the query console as planned. Both shipped.
  5. The client contract said OPTIONS {base}/ declares no auth.jwks_uri because the gateway publishes no JWK Set. Every federating gateway publishes one and declares it.
  6. CITATION.cff dated v0.0.9 2026-10-04; its tag and its changelog section are of 2026-10-05.

Not fixed here:

  1. The openehr-federation README 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:

  1. 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:

  1. README.md described v0.0.10 as being built on main and planned the reporting procedure and the support period, which ship in it (#762, #763).
  2. llms.txt said retention by origin and category is planned and the gateway does not serve the interoperability component; both ship (#795, #809).
  3. The landing page named v0.0.9 as current. It now names v0.0.10 and has a card for it.
  4. 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.
  5. The instructions for use listed the professional’s identification and the assurance level as missing from the access record (#742).
  6. 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.
  7. The information sheet planned the support period of each release (#763). It now states the end date of 0.0.10.
  8. 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.
  9. 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).
  10. 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.
  11. The patient summary page planned the FHIR Validator lane, which CI runs (#688).
  12. This page said the harmonised components are not built; the logging component’s access record and the interoperability component’s FHIR face are.
  13. 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:

  1. 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.
  2. 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:

  1. 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.
  2. 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:

  1. 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:

  1. README.md named 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.
  2. 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.
  3. llms.txt said every priority category other than the patient summary is planned. Prescriptions and dispensations are passed through from the members that serve them.
  4. 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.
  5. The configuration page planned the export and import of a revision (#579), which ships.
  6. 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.
  7. 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.
  8. The release notes named fhirconnect 0.1.111 on openehr-* 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:

  1. 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’s FERROFED_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.
  2. 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:

  1. The instructions for use and the configuration revisions page left [trust] out of the bootstrap core that stays in the file under config.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:

  1. README.md named 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).
  2. The landing page named v0.0.11 as current. It now names v0.0.12 and has a card for it.
  3. llms.txt named 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.
  4. 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.
  5. The release notes described the console as each FerroUI step from ferrohealth-ui 0.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 under app/ferrofed-viewer/. The console entries now describe the console this release ships, on FerroUI 0.3.6.
  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 from ferrofed_viewer_theme to ferrofed_console_theme, so each operator chooses the theme again. The note now says so.
  7. The release notes’ Removed entry for the Tailwind CSS pin named scripts/release/viewer-tools.sh, which the rename made scripts/release/console-tools.sh. The entry now names it.
  8. The cybersecurity risk assessment said deny.toml holds 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.
  9. The record held finding 16 as standing at the v0.0.10 and v0.0.11 reviews. The openehr-federation README lost the “follows in build order” sentence in the v0.0.10 version bump (commit ef7e3084), 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:

  1. 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 required binding the packages cannot decide needs the terminology server.

Stale or wrong statements, which read as claims about the release a user runs:

  1. README.md named 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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’s egress component restricts egress; the row and both residual risks now say so.
  6. 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.
  7. llms.txt did 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:

  1. README.md named v0.0.13 as current. It now names v0.0.14.
  2. 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.
  3. 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.
  4. 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.
  5. llms.txt planned 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:

  1. 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:

  1. README.md named v0.0.14 as current. It now names v0.0.15.
  2. The landing page named v0.0.14 as current. It now names v0.0.15 and has a card for what it ships.
  3. 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).
  4. 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:

  1. 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:

  1. README.md named v0.0.15 as current. It now names v0.0.16.
  2. The landing page named v0.0.15 as current. It now names v0.0.16 and has a card for what it ships.
  3. 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.
  4. The information sheet named eehrxf =0.1.142, and the pin matrix named eehrxf, fhirconnect, openehr-mapping-core and ferrobridge-validator at the versions before #1551 and #1590, behind the root Cargo.toml. Both now name the pins the release builds with.
  5. 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:

  1. The instructions for use did not say that a console whose [oidc] names issuer alone 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).
  2. 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:

  1. README.md and 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.
  2. 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.
  3. 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.
  4. 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.
  5. The MyHealth@EU self-assessment rows 08 and 08.01 said the face serves no laboratory, discharge or imaging document. They are marked not-assessed until they are read again against the catalogue (#1795).
  6. The pin matrix named ihe-atna and ehds-logging at 0.4.0, behind the root Cargo.toml at 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.
  7. 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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).
  2. The claims page had no section for the release. It now has one for v0.0.19.
  3. 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:

  1. 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:

  1. The information sheet named the interoperability component as eehrxf 0.3.2, and seven rows of docs/VERSIONS.md named the component and audit crates of releases ago. Both now name the root Cargo.toml requirements, eehrxf 0.7.0 among them, and scripts/checks/versions.sh fails when one drifts again (#1874).
  2. 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).
  3. The release notes said a deployment adds a FerroEHR node’s persons issuers “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).
  4. 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:

  1. 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 = false withdraws the partial answer, so one silent member blocks every answer. Keep the default, true.
  • A node’s consent refusal fails the query 424 until its codes are listed in consent_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 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 e2e module runs in the CI e2e job, with FERROFED_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

ProfileFederation-Gateway (§16.2)
SpecificationFederation Tier with AQL 1.0.1, approved by the openEHR SEC, 2026-11-01
Sourcesyntaric/openehr-federation-spec at b4242c291b08990df718bbfaa1188e4865478e5a
Rendered texthttps://syntaric.github.io/openehr-federation-spec/federation-aql/1.0/
Gateway points35 of 35 scored by a test, 0 deferred, 0 planned
Node points3, scored against the member CDR
Operator points3, scored against the federation operator
Test tracks10 of 11 scored by a test, 1 deferred, 0 planned
Requirements46: 46 reached by a point, 0 by a track only, 0 by neither
Marked tests1058, carrying 1361 point and track markers

The nodes

ProductPinnedScores
FerroEHR 5.7.0ghcr.io/ferrohealth/ferroehr:5.7.0@sha256:44c9268cb8a154df118a10ffe84329633f22923ea2cc54261ff0cea57a2ee7e7the Connectathon tracks, the differential run and the node profile
EHRbase 2.38.0ehrbase/ehrbase:2.38.0@sha256:b265855956270f028b7e457340d87c96290a007252ac5a52dffed3321a6ce93athe node profile
The reference implementationsyntaric/openehr-federation-ref at 9cb8606d1fce457b6b7f3a42f703d7069686a413the differential run, as evidence and never as an oracle

The scored matrix

PointActorRequirementsTracksStatusScored by
CP-1GatewayN11covered10 tests
CP-2GatewayN2, N51, 2covered4 tests
CP-38GatewayN332, 10covered5 tests
CP-3GatewayN32covered13 tests
CP-4GatewayN72covered3 tests
CP-5GatewayN4, N103, 4covered50 tests
CP-6GatewayN113covered22 tests
CP-7GatewayN52covered2 tests
CP-8GatewayN135covered55 tests
CP-9GatewayN155covered46 tests
CP-10GatewayN145covered45 tests
CP-11GatewayN164covered5 tests
CP-12GatewayN6, N164covered17 tests
CP-13GatewayN19, N20, N216covered19 tests
CP-14GatewayN226covered5 tests
CP-15GatewayN236covered33 tests
CP-16GatewayN247covered29 tests
CP-17GatewayN257covered161 tests
CP-18NodeN267node-profilethe member CDR, by 4 harness checks
CP-19NodeN27, N27a7node-profilethe member CDR, by 2 harness checks
CP-20OperatorN196operatorthe federation operator
CP-21GatewayN289covered10 tests
CP-22GatewayN299covered12 tests
CP-23GatewayN309covered39 tests
CP-24GatewayN319covered23 tests
CP-25GatewayN329covered14 tests
CP-26GatewayN3310covered165 tests
CP-27NodeN3410node-profilethe member CDR, by 6 harness checks
CP-28GatewayN353covered18 tests
CP-29GatewayN365, 6covered5 tests
CP-30GatewayN374covered34 tests
CP-31GatewayN38, N404covered19 tests
CP-32GatewayN39, N14, N95covered159 tests
CP-33GatewayN41, N426, 11covered53 tests
CP-33aOperatorN42a11operatorthe federation operator, assisted by 3 harness checks
CP-34GatewayN439covered30 tests
CP-35GatewayN17, N181covered13 tests
CP-36GatewayN82, 7covered43 tests
CP-37GatewayN123covered29 tests
CP-39OperatorN257operatorthe federation operator
CP-40GatewayN449covered62 tests
TrackTitlePointsStatusScored by
1TransparencyCP-1, CP-2, CP-35covered3 tests
2subject → ehrId resolutionCP-2, CP-38, CP-3, CP-4, CP-7, CP-36covered7 tests
3Directed / endpoint pinCP-5, CP-6, CP-28, CP-37covered6 tests
4Partial resultsCP-5, CP-11, CP-12, CP-30, CP-31covered7 tests
5Dedup + DISTINCTCP-8, CP-9, CP-10, CP-29, CP-32covered4 tests
6Follow-up read/write routingCP-13, CP-14, CP-15, CP-20, CP-29, CP-33covered4 tests
7Auth conveyance + consent-denyCP-16, CP-17, CP-18, CP-19, CP-36, CP-39covered6 tests
8PMIR merge/split (ITI-93/94) (provisional)-deferreddeferred
9REST surface & self-descriptionCP-21, CP-22, CP-23, CP-24, CP-25, CP-34, CP-40covered7 tests
10Identifier leakage (adversarial)CP-38, CP-26, CP-27covered45 tests
11Integrity: ehr_id collision & admission conditionsCP-33, CP-33acovered5 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-profile records the CP-18 access check and the CP-19 consent check as not-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-observable for 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_wire
  • app/ferrofed-server/tests/it/e2e/track1.rs (e2e): an_unmodified_client_gets_one_single_cdr_shaped_result_set
  • app/ferrofed-server/tests/it/facade/query.rs: a_patient_query_is_one_result_set_over_both_nodes
  • app/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_wire
  • app/ferrofed-server/tests/it/e2e/track1.rs (e2e): an_unmodified_client_gets_one_single_cdr_shaped_result_set
  • app/ferrofed-server/tests/it/e2e/track2.rs (e2e): the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_id
  • app/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_nodes
  • app/ferrofed-server/tests/it/e2e/track2.rs (e2e): the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_id
  • app/ferrofed-server/tests/it/outbound.rs: a_clinician_predicate_reaches_the_node_unchanged
  • crates/openehr-federation/tests/it/aql/entry.rs: both_carriers_rewrite_the_same_patient_query_to_the_same_node_query
  • crates/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_domain
  • app/ferrofed-server/tests/it/ask_all.rs: an_undirected_patient_query_over_three_members_dispatches_to_the_two_that_resolve
  • app/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_patient
  • app/ferrofed-server/tests/it/e2e/santempi.rs (e2e): a_patient_each_member_fed_to_santempi_is_answered_by_both
  • app/ferrofed-server/tests/it/e2e/track2.rs (e2e): the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_id
  • app/ferrofed-server/tests/it/identity_grant.rs: the_manager_is_asked_with_a_token_the_grant_obtained_and_refreshed
  • app/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_resolves
  • app/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_wire
  • app/ferrofed-server/tests/it/e2e/track2.rs (e2e): the_pix_manager_resolves_both_carriers_to_each_nodes_own_ehr_id
  • app/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_member
  • app/ferrofed-identity/tests/it/atna.rs: a_full_spool_fails_the_discovery_closed
  • app/ferrofed-identity/tests/it/nvi_directory.rs: the_directory_maps_each_custodian_to_its_members
  • app/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_candidates
  • app/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_call
  • app/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_candidates
  • app/ferrofed-server/tests/it/audit_repository.rs: a_full_spool_fails_the_query_closed_and_asks_no_member
  • app/ferrofed-server/tests/it/e2e/track3.rs (e2e): an_undirected_query_asks_only_the_members_the_localizer_names
  • app/ferrofed-server/tests/it/e2e/xcpd.rs (e2e): an_undirected_query_asks_only_the_member_ferropix_names_over_iti_55
  • app/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_all
  • app/ferrofed-server/tests/it/localizer_audit.rs: a_discovery_still_recording_its_audit_at_the_budget_fails_closed_under_ask_all
  • app/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_answer
  • app/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_pseudonym
  • app/ferrofed-server/tests/it/nvi_nuts.rs: a_refused_nuts_grant_fails_the_localization_closed, the_nvi_is_asked_with_the_dpop_bound_nuts_token
  • app/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_call
  • app/ferrofed-server/tests/it/registry_mcsd/lrza.rs: the_custodians_come_from_the_directory_without_a_table
  • app/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_manages
  • app/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
  • app/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_400
  • app/ferrofed-server/tests/it/localization.rs: a_directed_query_is_never_localized
  • app/ferrofed-server/tests/it/localizer_surface.rs: a_targeted_read_by_subject_is_never_localized
  • app/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_does
  • crates/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_node
  • crates/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_node
  • app/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_limit
  • app/ferrofed-engine/tests/it/fanout/order.rs: interleaved_node_answers_merge_into_the_global_top_n
  • app/ferrofed-server/tests/it/distinct.rs: a_duplicate_straddling_the_page_edge_is_answered_once, without_distinct_the_duplicate_is_answered_twice
  • app/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_deterministically
  • app/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_apart
  • app/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_column
  • crates/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_key
  • crates/openehr-federation/tests/it/aql/dedup.rs: under_distinct_a_selected_uid_is_the_dedup_key, under_distinct_an_unselected_uid_is_not_added
  • crates/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_compared
  • crates/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_answered
  • crates/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_values
  • crates/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_value
  • crates/openehr-federation/tests/it/dedup/mod.rs: dedup_runs_before_distinct_so_the_distinct_row_is_the_originating_copy
  • crates/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_copy
  • crates/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_named
  • app/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_default
  • app/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_recorded
  • app/ferrofed-server/tests/it/e2e/track5.rs (e2e): duplicates_pass_through_by_default_and_distinct_folds_them_at_the_tier
  • crates/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_key
  • crates/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_value
  • crates/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_copy
  • crates/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
  • crates/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_row
  • app/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_400
  • app/ferrofed-server/tests/it/dedup.rs: a_recombined_aggregate_under_version_identity_is_refused
  • app/ferrofed-server/tests/it/e2e/aggregate.rs (e2e): an_undirected_avg_is_the_mean_weighted_by_the_counts_each_node_returns
  • app/ferrofed-server/tests/it/e2e/track5.rs (e2e): an_undirected_aggregate_is_recombined_or_refused_and_a_directed_one_answered
  • crates/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_refused
  • crates/openehr-federation/tests/it/aql/dedup.rs: a_recombined_aggregate_under_dedup_is_refused
  • crates/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_alternatives
  • crates/openehr-federation/tests/it/aql/golden.rs: golden_case_06_is_refused_with_nothing_declared_and_recombined_under_the_default_set
  • crates/openehr-federation/tests/it/aql/hygiene.rs: no_recombined_aggregate_query_carries_the_patient_identifier, the_avg_rewrite_reaches_the_node_without_the_patient
  • crates/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_reason
  • crates/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_names
  • app/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_offline
  • app/ferrofed-server/tests/it/e2e/xcpd.rs (e2e): an_undirected_query_asks_only_the_member_ferropix_names_over_iti_55
  • app/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_mode
  • app/ferrofed-server/tests/it/completeness.rs: incompleteness_is_the_complete_flag_and_never_an_operation_outcome
  • app/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
  • app/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_envelope
  • app/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_empty
  • app/ferrofed-server/tests/it/pdqm/flow.rs: an_identifier_the_supplier_knows_no_patient_for_is_not_resolved_anywhere
  • app/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_at
  • app/ferrofed-registry/tests/it/creating_system/load.rs: a_registered_mapping_loads_and_is_listed
  • app/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_id
  • app/ferrofed-server/tests/it/contribution_write.rs: a_contribution_amending_a_registered_mapping_s_version_reaches_its_node
  • app/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
  • app/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_incident
  • app/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_document
  • app/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_4
  • app/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_409
  • app/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_there
  • app/ferrofed-server/tests/it/e2e/commit.rs (e2e): a_versioned_write_reaches_its_controlling_node_and_never_another
  • app/ferrofed-server/tests/it/e2e/track6.rs (e2e): a_new_ehr_is_created_at_the_one_node_the_client_names
  • app/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_step
  • app/ferrofed-server/tests/it/its_rest_areas/ehr.rs: the_ehr_declaration_states_that_a_new_ehr_goes_only_to_the_named_endpoint
  • app/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_gateway
  • app/ferrofed-engine/tests/it/exchange.rs: the_node_receives_a_token_exchanged_for_the_verified_caller
  • app/ferrofed-engine/tests/it/mtls.rs: token_exchange_composes_with_mutual_tls
  • app/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_algorithm
  • app/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_scope
  • app/ferrofed-server/tests/it/auth/professional.rs: the_node_is_told_who_acts_and_the_level_reached
  • app/ferrofed-server/tests/it/contact_point/relayed.rs: each_node_is_told_the_asserted_attributes_and_no_name
  • app/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_member
  • app/ferrofed-server/tests/it/e2e/national/served.rs (e2e): the_national_connector_reads_the_summary_and_prescriptions_and_dispenses
  • app/ferrofed-server/tests/it/e2e/track7.rs (e2e): each_node_is_reached_with_its_onward_token_and_told_the_caller
  • app/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_asked
  • app/ferrofed-engine/tests/it/audience.rs: the_issuer_audience_names_the_issuer, the_token_endpoint_is_the_default_audience
  • app/ferrofed-engine/tests/it/conveyance.rs: a_query_conveys_the_caller_in_a_token_the_published_key_verifies
  • app/ferrofed-engine/tests/it/dispatch/mod.rs: a_missing_credential_is_a_node_error_and_sends_nothing
  • app/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_once
  • app/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_nonces
  • app/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_caller
  • app/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_details
  • app/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_taken
  • app/ferrofed-engine/tests/it/fapi2/rotation.rs: only_the_current_client_key_signs_while_the_previous_one_is_published
  • app/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_tls
  • app/ferrofed-engine/tests/it/nuts.rs: a_refused_nuts_grant_fails_the_node, the_nuts_token_reaches_the_node_dpop_bound
  • app/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_written
  • app/ferrofed-engine/tests/it/signing.rs: an_es256_signing_key_signs_the_client_assertion_es256
  • app/ferrofed-server/tests/it/access/emergency.rs: the_emergency_purpose_is_conveyed_to_the_node
  • app/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_grant
  • app/ferrofed-server/tests/it/auth/attributes.rs: an_issuer_without_an_attributes_table_states_no_purpose
  • app/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_recorded
  • app/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_503
  • app/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_shown
  • app/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_503
  • app/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_prefiltered
  • app/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_nothing
  • app/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_none
  • app/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_client
  • app/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_400
  • app/ferrofed-server/tests/it/conveyance.rs: a_query_conveys_the_verified_caller_to_each_node_signed_for_it
  • app/ferrofed-server/tests/it/e2e/onward_cdr.rs (e2e): a_persons_query_reaches_the_cdr_under_the_exchanged_token_alone
  • app/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_caller
  • app/ferrofed-server/tests/it/next_key.rs: the_next_key_is_published_and_never_signs
  • app/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_jwks
  • app/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_form
  • app/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_token
  • app/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_token
  • app/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_node
  • app/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_through
  • tools/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_composition
  • tools/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_it
  • app/ferrofed-server/tests/it/e2e/track9.rs (e2e): an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefix
  • app/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_prefix
  • app/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_queried
  • crates/openehr-federation/tests/it/aql/rules.rs: a_query_already_scoped_by_ehr_id_is_accepted_as_written
  • crates/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_gate
  • app/ferrofed-server/tests/it/consent_withheld/mod.rs: by_default_the_exclusion_is_disclosed_and_declared, the_configured_setting_withholds_the_exclusion_and_is_declared
  • app/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_effect
  • app/ferrofed-server/tests/it/e2e/track9_surface.rs (e2e): the_self_description_lists_the_members_and_demographic_answers_as_declared
  • app/ferrofed-server/tests/it/fhir/native.rs: the_native_endpoint_is_absent_from_the_self_description
  • app/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_held
  • app/ferrofed-server/tests/it/its_rest_areas/demographic.rs: the_demographic_declaration_matches_the_demographic_area_in_each_mode
  • app/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_subject
  • app/ferrofed-server/tests/it/its_rest_areas/modes.rs: every_mode_declares_all_four_areas_and_each_follows_only_its_own_settings
  • app/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_out
  • app/ferrofed-server/tests/it/onward.rs: options_declares_the_jwks_uri_and_validates_against_the_schema
  • app/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_configuration
  • app/ferrofed-server/tests/it/stored/declaration.rs: options_declares_the_registry_and_the_methods_it_serves
  • crates/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_travel
  • app/ferrofed-server/tests/it/contribution_write.rs: a_contribution_its_path_node_controls_reaches_it_once_byte_identical
  • app/ferrofed-server/tests/it/declared.rs: a_body_sent_without_a_content_type_travels_with_the_one_its_operation_declares
  • 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_another
  • app/ferrofed-server/tests/it/e2e/track9.rs (e2e): an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefix
  • app/ferrofed-server/tests/it/ehr_by_subject.rs: a_subject_one_member_holds_is_that_members_ehr_read_by_its_ehr_id
  • app/ferrofed-server/tests/it/ehr_scope.rs: a_scoped_query_reaches_only_the_member_that_holds_the_ehr_id
  • app/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_listed
  • app/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_them
  • app/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_alone
  • app/ferrofed-server/tests/it/e2e/track9_surface.rs (e2e): the_self_description_lists_the_members_and_demographic_answers_as_declared
  • app/ferrofed-server/tests/it/errors.rs: an_unexposed_its_rest_area_is_a_501_not_implemented
  • app/ferrofed-server/tests/it/its_rest_areas/demographic.rs: the_demographic_declaration_matches_the_demographic_area_in_each_mode
  • app/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_only
  • app/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_held
  • app/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_id
  • app/ferrofed-engine/src/hygiene/forwarded.rs: every_federation_header_is_withheld_even_where_an_operation_declares_it
  • app/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_authority
  • app/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_sent
  • app/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_definition
  • app/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_byte
  • app/ferrofed-engine/tests/it/exchange.rs: a_caller_token_carrying_the_withheld_identifier_is_never_exchanged
  • app/ferrofed-engine/tests/it/fanout/all_or_nothing.rs: the_one_outbound_id_reaches_every_node
  • 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_patient
  • app/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_withheld
  • app/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_sent
  • app/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_found
  • app/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_unchanged
  • app/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_patient
  • app/ferrofed-engine/tests/it/nuts_service.rs: the_pseudonym_reaches_neither_the_authorization_server_nor_a_proof
  • app/ferrofed-engine/tests/it/onward.rs: a_token_endpoint_description_never_reaches_the_answer
  • app/ferrofed-identity/tests/it/mitz.rs: a_patient_named_by_a_pseudonym_is_never_sent_to_mitz
  • app/ferrofed-identity/tests/it/nvi_localizer.rs: a_bsn_system_never_stands_for_the_pseudonym
  • app/ferrofed-server/tests/it/collision_clear.rs: the_clear_is_recorded_with_its_operator_listed_and_names_no_patient_identifier
  • app/ferrofed-server/tests/it/contact_point/relayed.rs: no_iua_person_id_reaches_a_node
  • app/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_identical
  • app/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_node
  • app/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_text
  • app/ferrofed-server/tests/it/definition.rs: a_definition_request_carries_only_what_its_operation_declares, a_malformed_declared_value_is_refused_before_the_missing_target
  • app/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_manages
  • app/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_answer
  • app/ferrofed-server/tests/it/e2e/national/served.rs (e2e): the_national_connector_reads_the_summary_and_prescriptions_and_dispenses
  • app/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_path
  • app/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_answers
  • app/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_identifier
  • app/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_node
  • app/ferrofed-server/tests/it/fhir/binary.rs: a_read_asks_the_member_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/header.rs: the_header_is_the_supplier_s_and_no_header_value_reaches_a_node
  • app/ferrofed-server/tests/it/fhir/list/mod.rs: a_native_member_is_asked_for_the_list_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/list/own_laboratory.rs: a_members_own_report_is_listed_unchanged_but_for_its_url_and_read_unchanged
  • app/ferrofed-server/tests/it/fhir/native.rs: a_native_member_is_asked_by_its_ehr_id_alone_and_left_out_of_the_mapped_document
  • app/ferrofed-server/tests/it/fhir/patient_grant.rs: a_persons_own_grant_gets_their_summary_and_no_identifier_reaches_a_node_or_a_log
  • app/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_unchanged
  • app/ferrofed-server/tests/it/fhir/prescriptions/dispensed.rs: the_dispensation_a_receipt_located_is_read_at_its_member_by_its_ehr_id
  • app/ferrofed-server/tests/it/fhir/prescriptions/mod.rs: each_member_is_asked_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/prescriptions/read.rs: the_member_is_asked_by_its_ehr_id_and_its_own_id_alone
  • app/ferrofed-server/tests/it/fhir/prescriptions/vread.rs: the_location_a_receipt_answered_reads_back_the_dispensation_it_located
  • app/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs: a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_id
  • 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/representative.rs: a_representatives_document_names_them_as_every_entrys_provider
  • 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/summary.rs: the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node
  • app/ferrofed-server/tests/it/follow_up.rs: a_version_read_reaches_the_node_byte_identical_and_carries_no_identifier
  • app/ferrofed-server/tests/it/identity_export.rs: the_export_names_no_patient_identifier_while_bindings_exist_and_is_recorded
  • app/ferrofed-server/tests/it/mitz/config.rs: the_pseudonym_listed_as_the_bsn_is_refused
  • app/ferrofed-server/tests/it/mitz/decision.rs: the_bsn_reaches_mitz_only_and_no_node_log_or_error
  • app/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_pseudonym
  • app/ferrofed-server/tests/it/nvi_nuts.rs: the_nuts_token_reaches_the_nvi_alone
  • app/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_nobody
  • app/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_id
  • app/ferrofed-server/tests/it/path_ehr_id/probe.rs: neither_the_probe_nor_the_routed_read_carries_an_identifier_or_the_clients_credential
  • app/ferrofed-server/tests/it/pdqm/flow.rs: neither_identifier_reaches_a_log_line_a_metric_or_a_node
  • app/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_indexed
  • app/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_dispatch
  • app/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_sent
  • app/ferrofed-server/tests/it/routing_log.rs: no_event_of_a_routed_request_names_the_clients_id
  • app/ferrofed-server/tests/it/traces/traceparent.rs: an_identifier_hidden_in_the_clients_trace_id_reaches_no_node_and_no_span
  • app/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
  • app/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
  • crates/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_node
  • crates/openehr-federation/tests/it/aql/golden.rs: golden_case_02_keeps_the_directive_and_its_projections_out_of_the_node_query
  • crates/openehr-federation/tests/it/aql/guard.rs: a_clinician_or_facility_predicate_is_dispatched_unchanged, the_same_path_compared_with_the_patient_identifier_is_refused
  • crates/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_patient
  • crates/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_alone
  • tools/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_operation
  • app/ferrofed-server/src/facade/target.rs: a_conflict_names_both_sets_and_answers_its_own_code
  • app/ferrofed-server/tests/it/e2e/track3.rs (e2e): the_endpoint_header_selects_what_the_directive_selects
  • app/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_sets
  • app/ferrofed-server/tests/it/stored/invocation.rs: a_stored_query_is_targeted_by_the_header_as_by_the_directive
  • app/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_400
  • app/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_named
  • app/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_envelope
  • app/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_mode
  • app/ferrofed-engine/tests/it/fanout/consent.rs: a_403_carrying_a_listed_code_is_consent_denied_and_fails_nothing
  • app/ferrofed-engine/tests/it/oversized.rs: an_answer_past_the_bound_is_node_error_and_fails_the_query_424
  • app/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_rows
  • app/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_succeeds
  • app/ferrofed-server/tests/it/e2e/track3.rs (e2e): an_undirected_query_asks_only_the_members_the_localizer_names
  • app/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_offered
  • app/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_succeeds
  • app/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_envelope
  • app/ferrofed-server/tests/it/facade/query.rs: a_node_error_carries_the_nodes_message_with_the_subject_masked
  • app/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_envelope
  • app/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_budget
  • app/ferrofed-engine/tests/it/fanout/decision.rs: a_client_wait_shortens_the_budget_and_never_extends_it
  • app/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_out
  • app/ferrofed-server/tests/it/endpoint_report.rs: latency_is_reported_exactly_for_the_endpoints_dispatched_to
  • app/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_row
  • app/ferrofed-engine/tests/it/fanout/distinct.rs: a_row_two_nodes_return_takes_one_slot_of_the_limit
  • app/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_n
  • app/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_400
  • app/ferrofed-server/tests/it/distinct.rs: a_duplicate_straddling_the_page_edge_is_answered_once
  • app/ferrofed-server/tests/it/e2e/aggregate.rs (e2e): an_undirected_avg_is_the_mean_weighted_by_the_counts_each_node_returns
  • app/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_deterministically
  • app/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_offset
  • app/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_400
  • app/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_repeat
  • crates/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_refused
  • crates/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_tier
  • crates/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_key
  • crates/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_out
  • crates/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_set
  • crates/openehr-federation/tests/it/aql/hygiene.rs: no_pushed_row_key_carries_the_patient
  • crates/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_zero
  • crates/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_key
  • crates/openehr-federation/tests/it/aql/rules.rs: an_undirected_aggregate_is_refused_with_its_reason
  • crates/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_row
  • crates/openehr-federation/tests/it/dedup/distinct.rs: under_dedup_a_node_cut_on_two_case_variants_is_refused
  • crates/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_sent
  • crates/openehr-federation/tests/it/dedup/properties.rs: dedup_with_order_by_limit_and_a_bounded_page_is_the_page_of_the_deduplicated_union
  • crates/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_union
  • crates/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_first
  • app/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_identifier
  • app/ferrofed-server/tests/it/contribution_write.rs: a_contribution_without_a_target_is_never_probed_for
  • app/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_alarm
  • 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_node
  • app/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_probed
  • app/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_neither
  • app/ferrofed-server/tests/it/ehr_scope.rs: two_members_holding_the_ehr_id_are_a_409_and_neither_is_queried
  • app/ferrofed-server/tests/it/fhir/prescriptions/read.rs: an_ehr_id_two_members_claim_refuses_the_read_with_one_incident
  • app/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_4
  • app/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_does
  • app/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_unprobed
  • app/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_read
  • app/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_admitted
  • tools/ferrofed-testkit/tests/it/e2e/node_profile.rs (e2e): ferroehr_is_checked_against_the_identifier_integrity_conditions
  • tools/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_success
  • app/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_nodes
  • app/ferrofed-server/tests/it/e2e/track9_surface.rs (e2e): a_definition_request_routes_to_the_one_named_node
  • app/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_held
  • app/ferrofed-server/tests/it/options.rs: the_definition_area_declares_nothing_offered, the_template_fan_out_upload_follows_the_configuration
  • app/ferrofed-server/tests/it/stored/declaration.rs: without_the_registry_a_definition_is_routed_and_no_name_is_invoked
  • app/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_nodes
  • app/ferrofed-server/tests/it/e2e/crossref.rs (e2e): one_result_set_over_two_cdr_nodes_and_no_identifier_on_the_wire
  • app/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
  • app/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_rows
  • app/ferrofed-server/tests/it/facade/query.rs: a_patient_query_is_one_result_set_over_both_nodes
  • crates/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_succeeds
  • app/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_denied
  • app/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_denied
  • app/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_denied
  • app/ferrofed-server/tests/it/consent_withheld/not_asked.rs: a_withheld_deployment_answers_a_prefilter_that_did_not_ask_as_one_that_found_nothing
  • app/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_answers
  • app/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_disclosed
  • app/ferrofed-server/tests/it/e2e/pixm.rs (e2e): a_pix_resolved_query_asks_only_the_member_that_knows_the_patient
  • app/ferrofed-server/tests/it/e2e/restriction.rs (e2e): a_restriction_at_one_node_is_honoured_by_the_federated_query
  • app/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_succeeds
  • app/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_decides
  • app/ferrofed-server/tests/it/mitz/grant.rs: mitz_is_asked_with_the_token_the_grant_obtained_and_reused
  • app/ferrofed-server/tests/it/pdqm/flow.rs: an_identifier_the_supplier_knows_no_patient_for_is_not_resolved_anywhere
  • app/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_node
  • app/ferrofed-server/tests/it/e2e/attributes.rs (e2e): the_example_query_adds_each_nodes_attributes_over_two_cdr_nodes
  • app/ferrofed-server/tests/it/e2e/track3.rs (e2e): a_directive_selects_the_node_set_and_leaves_the_row_shape_alone
  • app/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_apart
  • crates/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_columns
  • crates/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_directive
  • crates/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_attributes
  • crates/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_node
  • app/ferrofed-server/tests/it/e2e/store.rs (e2e): a_stored_query_database_of_the_earlier_release_boots_with_every_definition_and_refusal_kept
  • app/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_one
  • app/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_members
  • app/ferrofed-server/tests/it/its_rest_areas/definition.rs: the_definition_declaration_states_the_distribution_only_where_offered
  • app/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_version
  • app/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_replaced
  • app/ferrofed-server/tests/it/stored/declaration.rs: options_declares_the_registry_and_the_methods_it_serves
  • app/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_nothing
  • app/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_served
  • app/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_inline
  • app/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_version
  • app/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_invoked
  • app/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_nothing
  • app/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_location
  • app/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_wire
  • app/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_wire
  • app/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_patient
  • app/ferrofed-server/tests/it/e2e/santempi.rs (e2e): a_patient_each_member_fed_to_santempi_is_answered_by_both
  • app/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_nodes
  • app/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_selects
  • app/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_names
  • app/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_returns
  • app/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_another
  • app/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_another
  • app/ferrofed-server/tests/it/e2e/track9.rs (e2e): an_unmodified_client_given_only_the_base_url_reads_and_writes_at_a_prefix
  • app/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_patient
  • app/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_unchanged
  • app/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_patient
  • app/ferrofed-server/tests/it/access/accessor.rs: the_professional_and_the_patient_reach_no_log_or_metric
  • app/ferrofed-server/tests/it/access/emergency.rs: the_mark_carries_no_patient_identifier_to_a_log_or_a_node
  • app/ferrofed-server/tests/it/access/query.rs: no_patient_template_or_caller_reaches_the_log_a_metric_or_a_node
  • app/ferrofed-server/tests/it/contact_point/relayed.rs: no_iua_person_id_reaches_a_node
  • app/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_path
  • app/ferrofed-server/tests/it/fhir/binary.rs: a_read_asks_the_member_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/binary_trc.rs: a_document_read_under_another_patients_trc_is_no_data_recorded_and_fetched_nowhere
  • app/ferrofed-server/tests/it/fhir/header.rs: the_header_is_the_supplier_s_and_no_header_value_reaches_a_node
  • app/ferrofed-server/tests/it/fhir/list/mod.rs: a_native_member_is_asked_for_the_list_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/list/own_laboratory.rs: a_members_own_report_is_listed_unchanged_but_for_its_url_and_read_unchanged
  • app/ferrofed-server/tests/it/fhir/native.rs: a_native_member_is_asked_by_its_ehr_id_alone_and_left_out_of_the_mapped_document
  • app/ferrofed-server/tests/it/fhir/patient_grant.rs: a_persons_own_grant_gets_their_summary_and_no_identifier_reaches_a_node_or_a_log
  • app/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_unchanged
  • app/ferrofed-server/tests/it/fhir/prescriptions/dispensed.rs: the_dispensation_a_receipt_located_is_read_at_its_member_by_its_ehr_id
  • app/ferrofed-server/tests/it/fhir/prescriptions/mod.rs: each_member_is_asked_by_its_ehr_id_alone
  • app/ferrofed-server/tests/it/fhir/prescriptions/read.rs: the_member_is_asked_by_its_ehr_id_and_its_own_id_alone
  • app/ferrofed-server/tests/it/fhir/prescriptions/trc.rs: a_read_by_id_of_another_patients_prescription_is_no_data_recorded_and_sent_nowhere
  • app/ferrofed-server/tests/it/fhir/prescriptions/vread.rs: the_location_a_receipt_answered_reads_back_the_dispensation_it_located
  • app/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs: a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_id
  • 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/representative.rs: a_representatives_document_names_them_as_every_entrys_provider
  • 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/summary.rs: the_summary_is_an_eps_document_from_both_members_and_no_identifier_reaches_a_node
  • app/ferrofed-server/tests/it/pdqm/flow.rs: neither_identifier_reaches_a_log_line_a_metric_or_a_node
  • app/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_indexed
  • app/ferrofed-server/tests/it/stored/reserved.rs: each_section_query_runs_by_name_and_no_patient_identifier_reaches_a_node
  • app/ferrofed-server/tests/it/traces/traceparent.rs: an_identifier_hidden_in_the_clients_trace_id_reaches_no_node_and_no_span
  • app/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_node
  • app/ferrofed-server/tests/it/fhir/prescriptions/read.rs: an_ehr_id_two_members_claim_refuses_the_read_with_one_incident
  • tools/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

PointActorRequirementsTracksStatusIssuesReason
CP-1GatewayN11covered#38, #287scored 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-2GatewayN2, N51, 2covered#35, #38-
CP-38GatewayN332, 10covered#44-
CP-3GatewayN32covered#42, #43-
CP-4GatewayN72covered#35, #38-
CP-5GatewayN4, N103, 4covered#46, #85scored 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-6GatewayN113covered#70-
CP-7GatewayN52covered#35, #38-
CP-8GatewayN135covered#52, #55-
CP-9GatewayN155covered#56-
CP-10GatewayN145covered#54, #187-
CP-11GatewayN164covered#49, #85-
CP-12GatewayN6, N164covered#43, #50, #58the 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-13GatewayN19, N20, N216covered#36, #64, #67, #74-
CP-14GatewayN226covered#64, #1478scored 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-15GatewayN236covered#65, #286, #289scored 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-16GatewayN247covered#82scored 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-17GatewayN257covered#80, #81, #439the 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-18NodeN267node-profile#83, #93, #549, #573a 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-19NodeN27, N27a7node-profile#83, #93, #549, #573, #1389a 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-20OperatorN196operator#74verified 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-21GatewayN289covered#69-
CP-22GatewayN299covered#69-
CP-23GatewayN309covered#73, #290, #80the 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-24GatewayN319covered#61, #319, #288scored 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-25GatewayN329covered#68, #311scored 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-26GatewayN3310covered#45, #90, #217-
CP-27NodeN3410node-profile#93, #549, #573a 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-28GatewayN353covered#70, #71, #77the 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-29GatewayN365, 6covered#56, #66, #1478scored 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-30GatewayN374covered#37, #50, #343-
CP-31GatewayN38, N404covered#49, #51-
CP-32GatewayN39, N14, N95covered#52, #53, #54, #55, #157, #187-
CP-33GatewayN41, N426, 11covered#62, #63, #91, #289, #1478the 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-33aOperatorN42a11operator#79, #91, #93, #549verified 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-34GatewayN439covered#75, #76, #298, #343scored 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-35GatewayN17, N181covered#33, #38, #72-
CP-36GatewayN82, 7covered#43, #83-
CP-37GatewayN123covered#72-
CP-39OperatorN257operator#84verified 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-40GatewayN449covered#77, #78, #268, #342, #343the 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

TrackTitleRequirementsPointsStatusIssuesReason
1TransparencyN1, N2, N17, N18CP-1, CP-2, CP-35covered#38, #72, #92scenario 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
2subject → ehrId resolutionN3, N5, N7, N33CP-2, CP-38, CP-3, CP-4, CP-7, CP-36covered#42, #44, #45, #92scenario 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
3Directed / endpoint pinN10, N11CP-5, CP-6, CP-28, CP-37covered#70, #71, #72, #92scenario 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
4Partial resultsN6, N16CP-5, CP-11, CP-12, CP-30, CP-31covered#49, #50, #51, #92scenario 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
5Dedup + DISTINCTN13, N15CP-8, CP-9, CP-10, CP-29, CP-32covered#52, #53, #54, #55, #56, #92, #187scenario 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
6Follow-up read/write routingN21, N22, N23, N36, N41CP-13, CP-14, CP-15, CP-20, CP-29, CP-33covered#64, #65, #66, #67, #92scenario 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
7Auth conveyance + consent-denyN24, N25, N26, N27, N27aCP-16, CP-17, CP-18, CP-19, CP-36, CP-39covered#80, #81, #82, #83, #92scenario 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
8PMIR merge/split (ITI-93/94) (provisional)N3-deferred#48, #147provisional 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
9REST surface & self-descriptionN28, N43, N44CP-21, CP-22, CP-23, CP-24, CP-25, CP-34, CP-40covered#61, #68, #73, #75, #77, #92scenario 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
10Identifier leakage (adversarial)N33, N34, N5CP-38, CP-26, CP-27covered#45, #61, #90the 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)
11Integrity: ehr_id collision & admission conditionsN41, N42, N42aCP-33, CP-33acovered#63, #79, #91the 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.

RequirementPointsTracksReachability
N1CP-11direct
N2CP-21direct
N3CP-32, 8direct
N4CP-5-direct
N5CP-2, CP-72, 10direct
N6CP-124direct
N7CP-42direct
N8CP-36-direct
N9CP-32-direct
N10CP-53direct
N11CP-63direct
N12CP-37-direct
N13CP-85direct
N14CP-10, CP-32-direct
N15CP-95direct
N16CP-11, CP-124direct
N17CP-351direct
N18CP-351direct
N19CP-13, CP-20-direct
N20CP-13-direct
N21CP-136direct
N22CP-146direct
N23CP-156direct
N24CP-167direct
N25CP-17, CP-397direct
N26CP-187direct
N27CP-197direct
N27aCP-197direct
N28CP-219direct
N29CP-22-direct
N30CP-23-direct
N31CP-24-direct
N32CP-25-direct
N33CP-38, CP-262, 10direct
N34CP-2710direct
N35CP-28-direct
N36CP-296direct
N37CP-30-direct
N38CP-31-direct
N39CP-32-direct
N40CP-31-direct
N41CP-336, 11direct
N42CP-3311direct
N42aCP-33a11direct
N43CP-349direct
N44CP-409direct

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

StatusStatementsGateway statementsMeaning
tested353348a test asserts it
built-untested00the code does it and no test asserts it yet
planned00not built yet; an open issue holds the work
missing00not built, found missing by the audit; an issue holds the work
deferred54not built, by a decision of the owner
new-gap77the text contradicts itself or is silent, found by the audit and reported on #212
contradiction54the text contradicts itself, reported on #212 before the audit
node150a member node must meet it
operator240the federation operator must meet it
n/a3611no 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

StatementActorKeywordRequirementPointStatement textEvidence
requirements#n23.3GatewayMUSTN23CP-15New-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.3GatewayMUST NOTN43CP-34It 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.2GatewayRECOMMENDEDN43, N35CP-34a 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/endpointsGatewaySHOULDN16CP-11Every 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/timeoutGatewaySHOULDN38CP-31The 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/dedupGatewayMUSTN15CP-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_endpointsGatewaySHOULDN36CP-29SHOULD, 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

StatementActorKeywordRequirementPointStatement textEvidence
requirements#n19.3OperatorMUSTN19CP-20implementers MUST register a real code such as openehr-rest-query, or bind a specified system valueno 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.2GatewayMUSTN27aCP-36MUST 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.1GatewayMUSTN38CP-31A gateway MUST apply both a per-node timeout and an overall query budget, abandoning and marking time-out instead of failing the queryboth 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.1GatewaySHOULDN15CP-9The Tier SHOULD offer an opt-in dedup mode keyed on the openEHR object_idoffered 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/authGatewayMAYN25, N30CP-23OPTIONAL as a member: a gateway with none configured omits the key rather than inventing a valueschema 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

StatementActorKeywordRequirementPointStatement textEvidence
partial-results#11.6.4.1GatewayMAYN39CP-32A gateway MAY hold an ordered, merged result set for a bounded period and serve OFFSET-based pages from itowner 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.1GatewayMAY--the Tier MAY support the Exchange-Routing async patternowner 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.1GatewayMAYN3-Hooks reserved; full propagation MAY be deferredowner decision on #16; conformance/tracks.tsv track 8 deferred; hooks built (#48), intake #147
testing#track-11.1GatewayMAYN3-Its subject matter, full propagation of PMIR identity merges and splits, MAY be deferred to a future releaseowner decision on #16; track 8 deferred
future#18.4EditorMAYN3-full propagation of identity merges/splits across the federation MAY be deferred to a later releaseowner 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.

ItemPinWhy
Federation Tier with AQL1.0.1, the erratum to the release the openEHR SEC approvedthe governing specification
Federation Tier with AQL specificationcommit b4242c2, untagged upstreamthe vendored source of that release
openEHR ITS-REST1.1.0the façade a client sees and the API each node exposes
openEHR AQL1.1.0the query language on both sides of the gateway
openehr-query, openehr-its, openehr-base, openehr-rm0.0.136the published crates the gateway builds on, moved together as one family
IHE PIXm FHIR packageihe.iti.pixm, version 3.1.0the identifier cross-reference binding (ITI-83)
IHE PDQm FHIR packageihe.iti.pdqm, version 3.2.0the demographics query capability of ihe-iti (ITI-78)
IHE mCSD FHIR packageihe.iti.mcsd, version 4.0.0the addressing binding: the registry read from a care services directory (ITI-90, ITI-91)
IHE PMIR FHIR packageihe.iti.pmir, version 1.6.0the identity lifecycle: the subscription and the identity feed (ITI-94, ITI-93)
IHE IUA supplementtag 2.5the access token claims and the bearer presentation client authentication reads (ITI-71, ITI-72), Revision 2.5, Trial Implementation
Rust toolchain1.99.1, edition 2024the 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.md beside 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)).

ObligationArticleApplies to FerroFED from
Include the two harmonised software componentsArt 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 environmentArt 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 specificationsArt 30(1)(a), Art 3626 March 2027 (see below)
Technical documentation with at least the elements of Annex III and the results of the European digital testing environmentArt 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 useArt 38, Art 30(1)(d)26 March 2027 (see below)
The EU declaration of conformity, with the content of Annex IVArt 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 systemsArt 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 itArt 44(7)26 March 2027 (see below)
Identifying the economic operators supplied, on request, “for 10 years”Art 3526 March 2027 (see below)
No claim that ascribes “functions and properties to the EHR system which it does not have”Art 2826 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:

ActAdopted underApplies 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 main branch 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.md and 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 by ehr_id names 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 each ehr_id its 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 eehrxf crate, 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 R4 Bundle through 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.

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:

  1. 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.
  2. That the intended purpose covers all six priority categories, given that FerroFED passes through whatever the member CDRs hold.
  3. 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.
  4. Whether the deployment changes FerroFED in a way that makes its operator a manufacturer under Art 34.
  5. 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.
  6. The dates for the Chapter III articles Art 105 does not name, read together with Art 26(1).
  7. 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.1What it asksWhat the gateway does
§9.1a summary compiled from several sources is generated by a system connected to every relevant EHR systemthe section queries go to every member that holds the patient, and a member that does not answer makes the summary incomplete (Completeness)
§9.1.2results from different sources say where and when they were documentedone Provenance per mapped composition names the composition and the member, and each member authors the sections it feeds
§9.3.2every 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 ownevery 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.1every 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 flavoura section with no entry carries emptyReason, nilknown only when every member answered and none holds content for it, and unavailable otherwise
§9.2.1.2no code system or value set is mandatory; profiles may impose somethe 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-intolerances
  • eu.ferrofed.eehrxf::patient-summary-problems
  • eu.ferrofed.eehrxf::patient-summary-medication-summary
  • eu.ferrofed.eehrxf::patient-summary-medical-devices-and-implants
  • eu.ferrofed.eehrxf::patient-summary-procedures
  • eu.ferrofed.eehrxf::patient-summary-immunisations
  • eu.ferrofed.eehrxf::patient-summary-social-history
  • eu.ferrofed.eehrxf::patient-summary-pregnancy-history
  • eu.ferrofed.eehrxf::patient-summary-advance-directives
  • eu.ferrofed.eehrxf::patient-summary-observation-results
  • eu.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:

StatusWhat it means
metthe gateway’s share is done, and the evidence shows it
partly-metpart of the gateway’s share is done; the conclusion names the rest and its issue
waits-art-15-1the share depends on the exchange format the Art 15(1) implementing act will fix
not-metthe gateway does not do its share yet; the issue tracks it where one is planned
not-assessedthe repository holds no page or test to judge the share by
not-applicablethe 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.

IdSectionStatusFerroFED’s conclusionEvidenceIssue
01§1.9metThe 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 points
website/book/src/integrate/national-contact-point.md::What the connector calls
app/ferrofed-server/tests/it/contact_point/admission.rs::a_token_with_what_a_conforming_assertion_provides_is_admitted
app/ferrofed-server/tests/it/contact_point/admission.rs::a_token_missing_the_point_of_care_is_refused
-
01.01§1.9.1not-applicableThe 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.2metThe 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.10metThe 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 up
app/ferrofed-server/tests/it/fhir/lookup.rs::a_patient_a_member_holds_is_found_as_the_supplier_holds_it_and_no_member_is_asked
app/ferrofed-server/tests/it/fhir/lookup.rs::an_identifier_no_member_holds_and_a_restricted_patient_answer_alike
-
02.01§1.10.1metSame 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 up
app/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.1not-applicableIdentification 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 up
website/book/src/integrate/national-contact-point.md::What stays with the contact point
-
02.01.02§1.10.1.2not-applicableThe national configuration file belongs to the contact point.website/book/src/integrate/patient-summary.md::Looking a patient up
website/book/src/integrate/national-contact-point.md::What stays with the contact point
-
02.01.03§1.10.1.3not-applicableIdentification through the EU digital identity wallet belongs to the contact point.website/book/src/integrate/patient-summary.md::Looking a patient up
website/book/src/integrate/national-contact-point.md::What stays with the contact point
-
03§1.11not-applicableThis 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 point
website/book/src/evaluate/post-market.md
-
03.01§1.11.1not-applicableThis 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.2not-applicableThis 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.3not-applicableThis 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.4not-applicableThis is the contact point’s own operation.website/book/src/integrate/national-contact-point.md::What stays with the contact point-
04§1.12not-applicableData 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 point
website/book/src/evaluate/data-protection.md
-
04.01§1.12.1not-applicableThis 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.2not-applicableThis 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.3not-applicableThis 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.4not-applicableThis 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.13metThe 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 summary
website/book/src/integrate/national-contact-point.md::What stays with the contact point
app/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses
-
05.01§1.13.1metThe gateway builds the document with its sections, empty reasons, nature and author.website/book/src/integrate/patient-summary.md::The document
app/ferrofed-server/tests/it/fhir/summary.rs::an_imported_copy_is_listed_once_from_the_member_that_created_it
-
05.02§1.13.2metThe 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.3not-applicableDisplay 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.14partly-metThe 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 point
website/book/src/integrate/prescriptions.md::Searching a patient's prescriptions
app/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses
#1403
06.01§1.14.1partly-metA 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 contract
app/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.2partly-metThe 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 prescriptions
website/book/src/integrate/national-contact-point.md::What stays with the contact point
-
06.03§1.14.3not-applicableDisplay 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.15metThe 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 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/prescriptions/dispense.rs::the_owners_refusal_is_a_conflict_naming_it
app/ferrofed-server/tests/it/e2e/national/served.rs::the_national_connector_reads_the_summary_and_prescriptions_and_dispenses
-
07.01§1.15.1metThe 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_member
app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::a_dispensation_with_no_business_identifier_is_refused_naming_the_element
-
07.02§1.15.2not-applicableTranscoding 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.3metThe 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_unknown
website/book/src/integrate/prescriptions.md::The member contract
-
07.04§1.15.4metThe 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 dispensation
app/ferrofed-server/tests/it/fhir/prescriptions/withdraw.rs::a_withdrawal_reaches_its_member_alone_with_its_body_unchanged_but_for_its_id
app/ferrofed-server/tests/it/fhir/prescriptions/dispense/duplicate.rs::a_withdrawal_sent_again_is_refused_as_a_duplicate_with_nothing_written
-
08§1.16partly-metThe 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 reports
website/book/src/integrate/discharge-reports.md::A member's own discharge reports
website/book/src/integrate/imaging-reports.md::A member's own imaging documents
app/ferrofed-server/tests/it/fhir/list/own_laboratory.rs::a_members_original_report_in_pdf_is_read_byte_for_byte_with_its_media_type
app/ferrofed-server/tests/it/fhir/list/own_imaging.rs::a_members_original_image_in_png_is_read_byte_for_byte_with_its_media_type
app/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.1partly-metA 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 point
app/ferrofed-server/tests/it/fhir/list/own_laboratory.rs::a_members_original_report_in_pdf_is_read_byte_for_byte_with_its_media_type
app/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.2not-applicableThe 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.3not-applicableDisplay 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.17not-applicablePatient 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.1not-applicablePatient 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.1not-applicablePatient 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.2not-applicablePatient access is the Member State’s access service, which FerroFED is not.website/book/src/evaluate/natural-person-rights.md::Summary-
10§1.18metThe 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::Terminology
website/book/src/integrate/patient-summary.md::The document
-
10.01§1.18.1metThe 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::Terminology
website/book/src/integrate/national-contact-point.md::What stays with the contact point
website/book/src/integrate/patient-summary.md::The document
-
10.02§1.18.2metThe 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.3metThe gateway lists an imported copy once and keeps two compositions of one fact.website/book/src/integrate/patient-summary.md::The document
app/ferrofed-server/tests/it/fhir/summary.rs::an_imported_copy_is_listed_once_from_the_member_that_created_it
app/ferrofed-server/tests/it/fhir/summary.rs::two_different_compositions_are_both_listed
-
11§1.19not-applicableSecurity, 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.1not-applicableThis is the contact point’s security management; FerroFED documents its own hardening.website/book/src/operate/hardening.md-
11.01.01§1.19.1.1not-applicableThis belongs to the contact point; the gateway authenticates every caller and serves over TLS.website/book/src/operate/hardening.md::TLS
website/book/src/operate/hardening.md::Callers and issuers
-
11.01.02§1.19.1.2not-applicableEnd-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.3not-applicableThis 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.4not-applicableThe 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 relays
website/book/src/integrate/national-contact-point.md::The emergency access limit
app/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.1not-applicableThe 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 relays
website/book/src/integrate/national-contact-point.md::The emergency access limit
app/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.2not-applicableAnomaly 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.2not-applicablePerformance monitoring belongs to the contact point; the gateway’s metrics feed it.website/book/src/operate/metrics.md::The metrics-
11.03§1.19.3metThe 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 relays
app/ferrofed-server/tests/it/access/relayed.rs::the_record_carries_the_correlation_identifier
-
11.04§1.19.4metThe 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 relays
website/book/src/operate/access-log.md::Reading the log
website/book/src/operate/access-log.md::How long a record is kept
website/book/src/operate/access-log.md::Emergency access
app/ferrofed-server/tests/it/access/accessor.rs::a_contact_point_caller_names_the_foreign_professional_and_provider
-
12§1.20not-applicableNon-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.1not-applicableAs 12.website/book/src/operate/access-log.md::A request a national contact point relays-
13§1.21not-metThe 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.1not-metAs 13.website/book/src/evaluate/information-sheet.md::The categories received#1404
13.02§1.21.2not-applicableTranscoding, 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.3not-applicableDisplay 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.22not-metThe 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.1not-metAs 14.website/book/src/evaluate/information-sheet.md::The categories received#1405
14.02§1.22.2not-applicableTranscoding, 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.3not-applicableDisplay 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.23not-metThe 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.1not-metAs 15.website/book/src/evaluate/information-sheet.md::The categories received#1406
15.02§1.23.2not-applicableTranscoding, 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.3not-applicableDisplay 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.24not-metThe 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.1not-metAs 16.website/book/src/evaluate/information-sheet.md::The categories received-
16.02§1.24.2not-applicableTranscoding, 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.3not-applicableDisplay 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.25metEvery 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 relays
app/ferrofed-server/tests/it/fhir/prescriptions/dispense.rs::the_owners_own_issues_follow_the_gateways_on_a_refusal_and_an_error
app/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”.

ManufacturerCadasto B.V.
Postal addressComeniusstraat 2d, 1817 MS Alkmaar, The Netherlands
Single point of contactinfo@cadasto.com
Websitehttps://www.cadasto.com/contact/
Authorised representativenone: 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 on x86_64 and aarch64;
  • the gateway binary ferrofed, for Linux on x86_64 and aarch64, 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.

ReleaseDate
0.0.202026-11-12
0.0.192026-11-11
0.0.182026-11-10
0.0.162026-11-05
0.0.152026-10-30
0.0.142026-10-28
0.0.132026-10-25
0.0.122026-10-24
0.0.112026-10-16
0.0.102026-10-15
0.0.92026-10-05
0.0.82026-10-04
0.0.72026-10-03
0.0.62026-10-03
0.0.32026-10-02
0.0.12026-10-01
0.0.1-rc.1, a pre-release2026-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)CategoryCode in the access record
(a)patient summariesPatient-Summaries
(b)electronic prescriptionsElectronic-Prescriptions
(c)electronic dispensationsElectronic-Dispensations
(d)medical imaging studies and related imaging reportsMedical-Imaging
(e)medical test results, including laboratory and other diagnostic results and related reportsLaboratory-Reports
(f)discharge reportsDischarge-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)CodeReceived in the exchange format
(a)Patient-Summariesyes
(b)Electronic-Prescriptionsno
(c)Electronic-Dispensationsno
(d)Medical-Imagingyes
(e)Laboratory-Reportsyes
(f)Discharge-Reportsyes

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.

ItemPinWhat FerroFED does with it
Product version0.0.20the FerroFED version this sheet describes
Federation Tier with AQL1.0.1the 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-REST1.1.0the API a client calls and the API it calls at each member
openEHR AQL1.1.0the query language a client sends and each member answers
openEHR Reference Model specification sourcetag Release-1.1.0the identifiers and versioning model routing reads, RM 1.1.0
IHE PIXm FHIR packageihe.iti.pixm, version 3.1.0identity resolution, ITI-83
IHE PDQm FHIR packageihe.iti.pdqm, version 3.2.0the demographics step ahead of resolution, ITI-78 and ITI-119
IHE PMIR FHIR packageihe.iti.pmir, version 1.6.0the identity feed, ITI-93 and ITI-94
IHE mCSD FHIR packageihe.iti.mcsd, version 4.0.0the registry read from a care services directory, ITI-90 and ITI-91
IHE IUA supplementtag 2.5the claims of a caller’s access token, ITI-71 and ITI-72
IHE ITI-20 Record Audit EventRevision 20.2the audit trail sent to an Audit Record Repository
IHE RESTful ATNA supplementRev. 3.6the FHIR Feed of ITI-20 the audit and access records travel by
IHE BALP FHIR packageihe.iti.balp, version 1.1.4the AuditEvent patterns of the audit trail and of the access record of Annex II, point 3.2
Netherlands Generic Functions IG sourcetag v0.3.0, version 0.3.0the Dutch binding: NVI localization, Mitz consent, LRZa addressing, Annex B of the Federation Tier
Nuts specificationscommit 7c0de53the Nuts grant of the Dutch binding, RFC003 and RFC021
HL7 Europe Patient Summaryhl7.fhir.eu.eps 1.0.0-ballotthe patient summary document the FHIR face writes and receives, Annex II, points 2.1 to 2.3
HL7 Europe Medication Prescription and Dispensehl7.fhir.eu.mpd 1.0.0the 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 Reporthl7.fhir.eu.laboratory 2.1.0with 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 Reporthl7.fhir.eu.imaging 1.0.0-ballotthe medical imaging report document the FHIR face writes from a member’s composition and lists, Art 14(1)(d)
HL7 Europe Hospital Discharge Reporthl7.fhir.eu.hdr 1.0.0the discharge report document the FHIR face receives, writes from a member’s composition and lists, Art 14(1)(f)
HL7 Europe Base and Corehl7.fhir.eu.base 2.0.1the EU core profiles each mapped entry of the patient summary is written to
HL7 Europe EU Health Data APIhl7.fhir.eu.health-data-api 1.0.0-ballotthe 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
eehrxf0.8.0the 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 tag vX.Y.Z. ferrofed --version, the startup banner and GET {base}/ name the version a gateway runs. An image is identified by its digest, and a tarball by its .sha256sum and 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:

ReleaseReleasedFixes published until
v0.0.12026-10-012031-10-01
v0.0.32026-10-022031-10-02
v0.0.62026-10-032031-10-03
v0.0.72026-10-032031-10-03
v0.0.82026-10-042031-10-04
v0.0.92026-10-052031-10-05
v0.0.102026-10-152031-10-15
v0.0.112026-10-162031-10-16
v0.0.122026-10-242031-10-24
v0.0.132026-10-252031-10-25
v0.0.142026-10-282031-10-28
v0.0.152026-10-302031-10-30
v0.0.162026-11-052031-11-05
v0.0.182026-11-102031-11-10
v0.0.192026-11-112031-11-11
v0.0.202026-11-122031-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. Run ferrofed config check before 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.

ItemWhat Annex III asksWhere FerroFED documents itStatus
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 releasedocumented
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 datadocumented
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 FHIRdocumented
1(d)“the versions of relevant software or firmware and any requirement related to version update”Pinned versions and docs/VERSIONS.md; the compatibility policydocumented
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 marketdocumented
1(f)“the description of hardware on which the EHR system is intended to run”The hardware it runs on; Supported platformsdocumented
1(g)“a description of the system architecture explaining how software components build on or feed into each other”, with labelled diagramsThe parts of the gateway, with its diagrams; the design of record, docs/architecture.mddocumented
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; Sizingdocumented
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 tabledocumented
1(j)“the instructions for use for the user and, where applicable, installation instructions”Instructions for use; A production deploymentdocumented
2“A detailed description of the system in place to evaluate the EHR system performance, where applicable”Metrics; Scoring a deployment; the clinical safety methoddocumented
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 releasedocumented
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.shpartial, #940
37(2)“a reference to the results obtained from a European digital testing environment referred to in Article 40”The European digital testing environmentplanned, #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 VIIWhat it asksWhere FerroFED documents itStatus
point 1(a)“its intended purpose”Intended purposedocumented
point 1(b)“versions of software affecting compliance with essential cybersecurity requirements”Pinned versions and docs/VERSIONS.mddocumented
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 userdocumented
point 2(a)“necessary information on the design and development of the product with digital elements”, with the system architectureThe parts of the gateway; the design of record, docs/architecture.mddocumented
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 updatesSECURITY.md, with the family book’s Security; The release binaries; The image; the CRA Art 14 procedure of docs/post-market.mddocumented
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.mddocumented
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 assessmentdocumented
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) namesdocumented
point 5“a list of the harmonised standards applied in full or in part”, or the solutions adopted where none is appliedthe solutions, point by point, in the cybersecurity risk assessment; the specifications applied, (e) Standards, formats and specifications; no list of harmonised standardspartial, #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 Ipartial, #525
point 7“a copy of the EU declaration of conformity”The EU declaration of conformity; none is issued yetpartial, #940
point 8“where applicable, the software bill of materials”, on a reasoned request of a market surveillance authorityThe release binaries: every release attaches its SBOMsdocumented

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-gnu and aarch64-unknown-linux-musl. Each carries its checksum, two SBOMs and its signed provenance (The release binaries).
  • The gateway image ghcr.io/ferrohealth/ferrofed, for linux/amd64 and linux/arm64, tagged with the version and, for a release that is not a pre-release, <major>.<minor> and latest (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.toml and registry.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 root Cargo.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 root Cargo.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).

ReleaseDateWhat changed in the harmonised componentsReview
0.0.202026-11-12the 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]] tablethe clinical safety review rows dated before 0.0.20 and the release cut row of 2026-11-12
0.0.192026-11-11the 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 timethe clinical safety review rows dated before 0.0.19 and the release cut row of 2026-11-11
0.0.182026-11-10the 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 relaythe clinical safety review rows of 2026-11-05 to 2026-11-09 and the claims review findings of 2026-11-09
0.0.162026-11-05the 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 categorythe clinical safety review rows of 2026-11-04 and 2026-11-05 and the claims review findings of 2026-11-05
0.0.152026-10-30the 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 OperationOutcomethe clinical safety reviews of 2026-10-28 and 2026-10-30 and the claims review findings of 2026-10-30
0.0.142026-10-28the 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.140the 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.132026-10-25the 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 facethe clinical safety review of 2026-10-25 and the claims review findings of 2026-10-25
0.0.122026-10-24the 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.173the clinical safety review of 2026-10-24 and the claims review findings of 2026-10-24
0.0.112026-10-16the 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 templatethe clinical safety review of 2026-10-16 and the claims review findings of 2026-10-16
0.0.102026-10-15first 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 rulesthe 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-tags ruleset blocks the deletion and the moving of a v* tag, so the source, this book’s pages, docs/, conformance/ and CHANGELOG.md stay 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 IVWhat it asksWhere 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
4the 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 = true in 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

StatusMeaning
metthe repository holds the record, the procedure or the code the provision asks for, for what this release does
openwork is still to be done in this repository or a sibling’s; the issue holds it
awaits-actthe 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
manufactureronly Cadasto B.V. can do it, as a decision or a legal act; the repository has prepared what it can
not-applicablethe 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

StatusRows
met29
open7
awaits-act8
manufacturer14
not-applicable4
all62

The obligations

ProvisionRequirementApplies fromStatusEvidenceIssueNote
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)openChanges to the harmonised components; app/ferrofed-engine/tests/it/architecture.rs (the_two_harmonised_components_are_independent_of_each_other); What is missing#1794Both 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-applicableThe 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)openThe obligations and their dates; docs/release.md, Before the tag#525The 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 2031not-applicableA 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 2027metHow 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 2027metLimitations; 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 2027metIntended 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 2027openWhat is missing; conformance/annex-ii.tsv#1405, #1406, #675, #799Points 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 2027metShared 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 2027openThe Annex III index; conformance/technical-documentation.tsv; scripts/checks/technical-documentation.sh#525, #675, #940The 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 2027metHow 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 2027manufacturerThe EU declaration of conformity; scripts/release/declaration-of-conformity.sh; conformance/eu-declaration-of-conformity.toml#940, #675The 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 2027manufacturerdocs/release.md, Before the tag#940Written 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 2027metThe 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 2027awaits-actdocs/release.md, Before the tag#525See 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 2027metCorrective 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 2027metdocs/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 2027metMaintenance 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 2027metCooperation 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 2027metCooperation 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 2027metMaking 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 2027metThe 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 2027metChanges 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 2027manufacturerKeeping it for ten years; docs/release.md, After the tag#525The 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 2027metKeeping 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 2027not-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 2027metCooperation 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-actThe obligations and their dates; scripts/checks/ehds-acts.sh#799Not 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 2027metThe 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 2027openThe Annex III index; What is missing#1405, #1406, #799Annex 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 2027awaits-actThe European digital testing environment#675See 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 2027metLanguage 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 2027metLanguage 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 2027metHow the sheet is kept--
Art 38(2)(a)“the identity, registered trade name or registered trademark, and contact details of the manufacturer”26 March 2027met(a) The manufacturer--
Art 38(2)(b)“the name and version of the EHR system and date of its release”26 March 2027met(b) Name, version and date of release--
Art 38(2)(c)“the intended purpose of the EHR system”26 March 2027met(c) Intended purpose--
Art 38(2)(d)“the categories of electronic health data that the EHR system has been designed to process”26 March 2027met(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 2027met(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 2027not-applicableInformation 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 2027openWhat is missing; The EU declaration of conformity#1405, #1406, #675, #799A 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 2027manufacturerconformance/eu-declaration-of-conformity.toml; The Cyber Resilience Act#940Which 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 2027manufacturerscripts/release/declaration-of-conformity.sh; The EU declaration of conformity#940The 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 2027manufacturerDecisions open to the manufacturer#940The 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 2027manufacturerThe EU declaration of conformity; Decisions open to the manufacturer#940The 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 2027manufacturerDecisions open to the manufacturer; conformance/eu-declaration-of-conformity.toml#940Only 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 2027awaits-actThe EU declaration of conformity; scripts/checks/ehds-acts.sh#940Not 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 2027awaits-actThe European digital testing environment; scripts/checks/ehds-acts.sh#675No 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 2027manufacturerdocs/release.md, Before the tag#940Planned 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 2027manufacturerdocs/release.md, Before the tag#940No 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 2027manufacturerdocs/release.md, Before the tag#940Regulation (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-actdocs/release.md, Before the tag; scripts/checks/ehds-acts.sh#525The 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

ProvisionRequirementApplies fromStatusEvidenceIssueNote
Annex IV 1“The name of the EHR system, version and any additional unambiguous reference allowing identification of the EHR system”26 March 2027metscripts/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 2027metscripts/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 2027metscripts/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 2027openscripts/release/declaration-of-conformity.sh; The EU declaration of conformity#525The 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 2027manufacturerconformance/eu-declaration-of-conformity.toml#940The 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 2027awaits-actconformance/eu-declaration-of-conformity.toml; The European digital testing environment#675The 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 2027manufacturerconformance/eu-declaration-of-conformity.toml#940The 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 2027awaits-actscripts/release/declaration-of-conformity.sh; The Annex III index#799None 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 2027manufacturerconformance/eu-declaration-of-conformity.toml; scripts/release/declaration-of-conformity.sh#940The 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 2027metscripts/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 = true in conformance/eu-declaration-of-conformity.toml only 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.md describes. 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

StatusMeaning
metthe evidence shows the requirement met for what this release does
partialpart of the requirement is met; the note says what is missing and the issues close it
plannednothing is built yet; the issues hold the work
not-applicablethe 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

PointRequirementEvidenceStatusIssue
1.1“shall achieve the performance intended by its manufacturer”The logging component; The interoperability component; Overall residual risk; Review and changepartialFerroHEALTH/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 policymet-
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

PointRequirementEvidenceStatusIssue
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)partialFerroHEALTH/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)partialFerroHEALTH/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 hindrancenot-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

PointRequirementEvidenceStatusIssue
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

RightWho actsFerroFEDStatus
Art 3(1), accessthe access servicethe 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 interfacepartial
Art 3(2), a download in the exchange formatthe access servicepatient summaries onlypartial
Art 4(2), a proxy or a legal representativethe proxy serviceadmits the token its issuer grants, records who acted, and marks what a declared representative inserts as theirsnot the gateway’s
Art 5, inserting informationthe access service and the member CDRa 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 entrymet
Art 6, rectificationthe access service and the controllerroutes the controller’s correctionnot the gateway’s
Art 7(1), transmission to another providerthe healthcare provideradds no fee, limit or formatmet
Art 7(2), cross-border transmissionthe national contact pointserves the requests a national connector relays: patient summaries, and prescriptions and dispensations where members serve thempartial
Art 7(3), transmission to social securitythe healthcare provideroutside the intended purposenot the gateway’s
Art 7(4), sending a downloaded copythe receiving providerreceives patient summaries and laboratory reports, and dispensations where members serve thempartial
Art 8, restricting accessthe person, the consent service and the membercarries the restriction out and hides its factmet
Art 9, information on accessthe access servicerecords every access for at least three years and serves the searchmet
Art 10, opting outthe Member Statean opt-out is carried out as a restriction isnot the gateway’s
Art 11(5), an emergency access logged for the personthe healthcare provider and the membermarks the recordmet
Art 16(1), electronic identificationthe access servicenot an access servicenot the gateway’s
Art 18, no feeevery provider and third partycharges a person nothingmet
Art 21, a complaintthe digital health authoritynot a complaint bodynot 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)(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 ferrofed tarballs, 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_64 or aarch64, 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

PointAppliesHow FerroFED implements itWhere
(a) no known exploitable vulnerabilitiesyescargo 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-256Checks and gates; deny.toml
(b) secure by default, and resetyesprofile 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 ResetConfiguration; What must travel encrypted; B8
(c) security updatesyes, automatic installation excepteda 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 updatesSECURITY.md; Upgrading
(d) protection from unauthorised access, and reporting ityesevery 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::securityB1, B5; Client authentication
(e) confidentialityyesin 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 suppliesB2, B6; Hardening
(f) integrity, and reporting corruptionsyeseach 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 alertedIntegrity incidents; The image
(g) data minimisationyesthe 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 identifierWhere the patient identifier stops; Processing inventory
(h) availability, also after an incidentyesa 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 holdsOverload protection; Running several replicas; Health probes
(i) minimal impact on other devices and networksyesone 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_secondsThe per-member cap; The answer bound; B7
(j) limited attack surfaceyesan 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 bindingB1; The bindings and their features
(k) exploitation mitigationyesRust 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 closedThe container; What the outbound gate reads
(l) recording and monitoring, with an opt-outyes, the opt-out in partevery 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-outThe access log; The audit trail; Metrics
(m) secure removal of data and settings, and secure transferyesthe 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 productWhat 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 check verifies 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

RevisionDateReleaseChange
12026-10-150.0.10, and main before 0.0.11first assessment
22026-10-15main before 0.0.11Annex I, Part II, point (2): a security fix ships as its own patch release on each supported line, with no feature changes in it
32026-10-160.0.11every 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
42026-10-19main before 0.0.12Annex 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
52026-10-23main before 0.0.12Annex 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
62026-10-240.0.12every 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
72026-10-250.0.13every 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
82026-10-280.0.14every 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
92026-10-300.0.15every 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
102026-11-050.0.16every 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
112026-11-100.0.18every 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
122026-11-110.0.19every 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
132026-11-120.0.20every 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, in dependencies, every package of its dependency closure, which the validator refuses to load short of (decision A106);
  • an issue of severity error or fatal refuses the document with 422 and an OperationOutcome of every such issue, before any member is asked, and each warning, and every constraint the validator met and did not evaluate as a warning of type not-supported, is returned in the outcome of the write;
  • a required binding the packages cannot decide is asked of the [eehrxf.terminology] server, and a server that refuses or does not answer refuses the document with 502; 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

ComponentWhere it isWhat 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:2019Where this file answers it
4.4 Risk management plan, 4.5 Risk management filethis page, Method and Review and change
5.2 Intended use and reasonably foreseeable misuseScope
5.4 Identification of hazards and hazardous situationsthe hazard tables
5.5 Risk estimationthe severity and occurrence scales below, the “Before” column
6 Risk evaluationthe acceptability rule below
7.1 Risk control option analysisthe order of controls below
7.2 Implementation of risk control measuresthe “Control” and “Implemented by” columns
7.3 Residual risk evaluationthe “After” column
7.5 Risks arising from risk control measuresthe hazards a control itself creates, L-02, L-05 and L-10
8 Evaluation of overall residual riskOverall residual risk
9 Risk management review, 10 Production and post-production activitiesReview 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”.

LevelHarm
S1none to care or rights
S2care is delayed, or a record of access is incomplete in a way the deployment can see and correct
S3a 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
S4a 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.

LevelThe hazardous situation arises
P1only through two independent faults, or a misconfiguration config check refuses
P2through one fault in another system, or one misconfiguration nothing refuses
P3in 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

IdHazardous situationHarmBeforeControlImplemented byAfter
L-01An access to patient data leaves no access record: the sink cannot store it, or a path of the gateway hands out data without onethe person cannot learn who read their data (Art 9), and misuse goes unseenS3, P2the 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 recordapp/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-02Failing 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 delayedS2, P2the spool is bounded on disk, so a repository outage alone blocks nothing; alerts fire on the backlog and on refusals; the instructions name the alertapp/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-03The record names the wrong person or provideran access is attributed to someone who did not make it, and the one who did is not foundS3, P2the 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 operatorapp/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-04The record omits the professional’s identification and assurance level, or the client’s address behind a proxya reviewer cannot tell which professional acted, or from whereS2, P3the 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 nothingapp/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-05An 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 timeS3, P2none 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 maptests/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-06The 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 anotherthe record misstates which CDRs released which dataS2, P3an 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 rowapp/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-07The 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 timeS3, P2the 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 nameapp/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-08The record discloses the patient or the caller where it must not: to a node, the operator log, a span or a metrica patient identifier leaks (§5.4, N33)S3, P2no record content reaches a node or a log; Debug shows no identifierapp/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-09Emergency access to restricted data is not markedthe person cannot see that a restriction was overridden (Art 8)S3, P2a 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 recordapp/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-10A 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 recordS3, P2the 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 valueapp/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.rsS3, 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.

IdHazardous situationHarmBeforeControlImplemented byAfter
I-01A silent node: a member that holds the patient’s data does not answer, and the summary reads as the whole recorda missed allergy or medication leads to a wrong treatmentS4, P3all-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 otherwisebuilt 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-02Duplicate entries across nodes: two members hold the same medication or problem, and the summary lists it twice, or merges two different entries as onea dose counted twice, or a distinct entry lostS4, P3entries 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 mergedcrates/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-03An unmapped code or element: content the mapping does not cover is dropped, so an entry reaches the document without its code or not at allan allergy or a result is missing from the summaryS4, P3a 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 entriescrates/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-04A code translated to one that is not equivalent: a broader or narrower code presented as the samea clinician reads a more or less specific diagnosis than was recordedS3, P2the 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-05A demographic mismatch: the summary carries another patient’s data, or a header naming another patienta decision for one patient made on another’s recordS4, P2the 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 bindingapp/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-06A restriction shows through the document: a section marked withheld, or a member’s absence that only a restriction explainsthe patient’s restriction is visible to the provider (Art 8)S3, P2emptyReason 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 = falsebuilt 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-07A section query carries the patient identifier to a nodea patient identifier leaks (§5.4, N33)S3, P2each section is a stored query run through the rewrite and the outbound gate, so a node is asked by its ehr_id alonebuilt 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-08The mapping gives different output for the same input, or reads a dataset model other than the pinned onetwo requests for one patient disagree, or a profile element is mapped against the wrong definitionS3, P2the 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 refusedcrates/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.rsS3, P1
I-09A generated summary is read as one a clinician wrote and attested, or as exhaustivea clinician trusts it as a complete, attested recordS3, P3the author is the gateway’s Device and the operator’s Organization, with no attester, and every section’s narrative says it is not exhaustiveapp/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-10A received document is written to the wrong member or under the wrong patienta record is filed in another patient’s EHRS4, P2a 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 EHRapp/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-11A received document is stored with content lost: the mapping carries only part of it, or a document that breaks its profiles is taken ina later reader misses an allergy or a medication the sender recordedS4, P3the 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-12Double 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 elsewherea patient receives more than was prescribedS4, P2the 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-13A missed prescription: a member that holds it is silent or declared for no category, the lookup does not find its identifier, or it is restricteda valid prescription is not dispensed, or a dispensation report is lostS3, P3the 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 successthe 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-14A 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 patientsa dispensation recorded on another prescription or in another patient’s recordS4, P2two 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-15A prescription read as signed when it is nota medicine dispensed on a prescription its prescriber never signedS3, P2the 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-16A 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 ordera clinician reads a result against no range, or as the answer to another order, and treats on itS4, P3the 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 lostcrates/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-17A 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 patienta pharmacist dispenses on a prescription the member does not own, or on a dose or product the order does not state, or for another patientS4, P3only 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-18A 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 twicea clinician reads a result against no range, misses a report a silent member holds, or reads one result as twoS4, P3the 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-19A 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 twicea clinician continues care without a diagnosis or a discharge medicine the stay recorded, or reads the gateway’s sentence as the hospital’s courseS4, P3the 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-20An 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 twicea clinician acts on an imaging report without a finding the member recorded, or on the wrong studyS4, P3the 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-21A 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 documenta 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 leftS4, P3the 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 nameopen 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-22A 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 documenta 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 performerS4, P3the 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 nameopen 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)eHNTemplate sectionArchetypes
allergiesAndIntolerancesA.2.1.1Allergies & IntolerancesEVALUATION.adverse_reaction_risk.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2
problemsA.2.3.1Problem List, Past History of IllnessesEVALUATION.problem_diagnosis.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2
medicationSummaryA.2.4Medication SummaryACTION.medication.v1, EVALUATION.exclusion_global.v1, EVALUATION.absence.v2, and beyond the template INSTRUCTION.medication_order.v3
medicalDevicesAndImplantsA.2.3.2Medical DevicesEVALUATION.device_summary.v0
proceduresA.2.3.3History of ProceduresACTION.procedure.v1, EVALUATION.absence.v2, EVALUATION.exclusion_global.v1
immunisationsA.2.2.1ImmunizationsACTION.medication.v1, EVALUATION.absence.v2
socialHistoryA.2.5Social HistoryEVALUATION.tobacco_smoking_summary.v1, EVALUATION.alcohol_consumption_summary.v1
pregnancyHistoryA.2.6PregnancyEVALUATION.pregnancy_summary.v0, EVALUATION.estimated_date_delivery.v0, OBSERVATION.exclusion_pregnancy.v0
advanceDirectivesA.2.7.2Advanced DirectivesEVALUATION.advance_care_directive.v1, EVALUATION.limitation_of_treatment.v0
observationResultsA.2.8Diagnostic Results, Vital SignsOBSERVATION.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
carePlansA.2.9Plan of CareACTION.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.v1 and EVALUATION.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

IdHazardous situationHarmBeforeControlImplemented byAfter
X-01One component changes the other’s output or behaviour (Art 30(1)(b))an access goes unrecorded, or a document changes, because of the other componentS3, P2two 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 documentapp/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.basedOn and 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 under other_reference_ranges with 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 onReleaseChangeHazards touched
2026-10-06before 0.0.10file openedall
2026-10-06before 0.0.10the patient summary section catalogue: the crosswalk of eehrxf 0.0.4 (#777), with the medical alert and the functional status openI-03; I-01, I-02, I-04 and I-05 unchanged, because no document is built yet
2026-10-06before 0.0.10the 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 unselectedI-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-150.0.10the 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-15before 0.0.11the 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 doesX-01 controlled at the crate and the server level, S3, P1
2026-10-15before 0.0.11a 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-unrecordedL-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-15before 0.0.11the 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-15before 0.0.11the 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-15before 0.0.11the 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 yetI-12 to I-15 opened, their controls planned in #1040, #1041 and #1042; the clinical review of #1327 takes all four
2026-10-16before 0.0.11the 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-16before 0.0.11the 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 unknownI-12 to I-15: every control the gateway holds is built; each stays open until the clinical review of #1327
2026-10-160.0.11the 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-210.0.12the 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-240.0.12the 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-24before 0.0.13the 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-24before 0.0.13a 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-250.0.13the 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-25before 0.0.14safety 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 gapsI-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-25before 0.0.14the 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-25before 0.0.14the 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 writtenI-12 closed at S4, P1, its control tested; A102 holds, a read with no lock
2026-10-25before 0.0.14a 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-280.0.14the 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-28before 0.0.15the 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 fromeach 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-300.0.15the 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-04before 0.0.16the 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 compositionI-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-04before 0.0.16the 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 byteI-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-04before 0.0.16the 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-mpdI-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-04before 0.0.16a 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 ownerI-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-04before 0.0.16the 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-04before 0.0.16the 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 immunisationsI-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-04before 0.0.16a 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-04before 0.0.16the 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 fileI-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-050.0.16the 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.2every 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-05before 0.0.17the 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 buildI-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-05before 0.0.17the 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 loadI-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-06before 0.0.17the 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-06before 0.0.17the 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 projectsI-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-06before 0.0.17a 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 recordL-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-08before 0.0.17the 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 P3I-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-08before 0.0.17I-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-08before 0.0.17the 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 removedI-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-08before 0.0.17I-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-08before 0.0.17the 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-08before 0.0.17the 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-09before 0.0.17the 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-100.0.18the 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-10before 0.0.19the 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 gatewayI-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-10before 0.0.19the 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 oneI-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-10before 0.0.19the 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-10before 0.0.19I-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-10before 0.0.19a 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 findingI-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-11before 0.0.19I-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-110.0.19the 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-11before 0.0.20I-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-12before 0.0.20the 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-120.0.20the 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:

NameCadasto B.V.
Postal addressComeniusstraat 2d, 1817 MS Alkmaar, The Netherlands
Single point of contactinfo@cadasto.com
Websitehttps://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:

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:

  1. The version enters the register of non-conforming versions, with the Annex II item or the obligation it misses.
  2. 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> and latest image 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.
  3. 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)).
  4. 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 14EHDS Art 44(7)
Eventan actively exploited vulnerability, or a severe incident having an impact on securitya 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
Tothe Dutch CSIRT designated as coordinator, through the single reporting platform, and ENISAthe market surveillance authorities of the Member States concerned
By24 hours, 72 hours, then the final reportthree 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/:

FileWhat it holds
manifest.jsonformat (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.jsonthe 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.jsonthe 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.tomlthe effective configuration (the file with every FERROFED__ override applied), redacted
health/readiness.jsonGET {base}/health/readiness, as the gateway answered it
health/dependencies.jsonGET {base}/operator/dependencies: the last observed state of every dependency, read with the operator token
incidents.jsonGET {base}/operator/incidents: every integrity incident kind’s count and the most recent of each, read with the operator token
metrics.txtGET /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 url or issuer, 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, format or node_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_id or the creating_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 missing without 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:

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 channelPersonal data it carriesWhereHow longProtectionSettings
The client’s requestthe patient identifier in the AQL or a parameter; the caller’s access token; for a write, the clinical contentprocess memoryone 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 servicesthe 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 typein transit to each service you configureone exchangehttps 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 nodesthe 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 bytein transit to each member askedone requestno 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 answersclinical rows and documents from each memberprocess memory, then the clientone requesteach node decides what it releases (§13.2, N26, N27); a node error quoted in meta.federation has each consumed value maskednone
Resolution bindingsthe caller (issuer, subject, client) with the {node, ehr_id} pairs its resolutions foundprocess memory, per replicafederation.binding_ttl after the last resolution that returned the pair (15 minutes by default); lost on restartkeyed by ehr_id, never by a patient identifier; seen by no other caller; bounded by binding_capacity (100 000)Resolution bindings
The ehr_id indexwhich member holds which ehr_idprocess memory, per replicaleast recently used past ehr_index_capacity (100 000); lost on restartno caller and no patient identifierThe ehr_id index
Integrity incidentsan ehr_id, only when it is a bare UUID, and the endpoints that claim itprocess memory (the last 20 of each kind) and one log lineuntil restart; the log line as long as your log pipeline keeps itno patient identifier; read on the operator surface with the operator scope (Integrity incidents)none
Per-caller rate bucketsthe caller’s issuer and client_idprocess memory, per replicauntil the bucket refills; at most 10 000 callersnone read outside the process[server.caller_rate]
IHE audit spoolsthe 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 callera directory on disk, one per replicauntil the Audit Record Repository accepts the record; a quarantined record until you remove itdirectory 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 Repositorythe same recordsyour repositoryyour repository’s retentionITI-20 over TLS: the FHIR Feed over https, syslog over TLSthe repository’s own
The access recordsfor 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 datathe [audit] spool, then the Audit Record Repositoryas the IHE audit records abovestored 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 destinationthe profile, the action, the outcome, the server of a request the gateway sent, counts of entities, and on_behalf as caller or gatewaythe logyour log pipeline’s retentionno 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 linethe 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 subjectstdout, then your log pipelineyour log pipeline’s retentionno request body, AQL text, header value, request path or patient identifier (What the log records)[telemetry] filter, format
Metricsnone: every label value comes from a closed set or the registrythe admin listener, the OTLP pushyour metrics store’s retentionMetrics[metrics]
Trace spansnone: route templates, registry ids, operations, statuses and counts; the trace id is the gateway’s own, never the client’syour OpenTelemetry collectoryour collector’s retentionTracing[telemetry] otlp_endpoint, trace_sample_ratio
The usage reportnone 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 countryFerroPULSE, which Cadasto B.V. runs, at each start and once a day13 months at the collectoron 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 definitionsnone: parameterised AQL, and a definition that names a patient by a literal is refusedredb, PostgreSQL or read-only filesdurable; a stored version is immutable (§12.7, N44)the bound values of an invocation are never written (Stored queries)[stored_queries]
The PMIR identity feedthe Patient Master Identities each ITI-93 message names, with their identifiers and demographicsprocess memory for one message, then the audit spoolone message; the audit record as abovehttps 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 sessionsthe operator’s ID Token claims, access token and refresh token, and the name (name, else preferred_username) the top bar showsthe console’s process memoryidle_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 restartheld 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 choicenone: the word light, dark or systeman HttpOnly cookie, ferrofed_console_theme, in the operator’s browser400 days, or until the operator clears itthe 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] destination records nothing, and log writes counts that name no one. Outside development, a gateway with a registry refuses every destination but repository.
  • 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.

DataDefaultSettingWhat decides the rest
A resolution binding15 minutes after the last resolution that returned itfederation.binding_ttl, binding_capacitya restart forgets it
An ehr_id index entryuntil 100 000 newer entries push it outfederation.ehr_index_capacitya restart forgets it
An operator console session30 minutes idle, 12 hours at mostsession.idle_timeout_s, absolute_timeout_sthe 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 spooluntil the repository accepts itspool_max_size, spool_max_eventsa quarantined record waits for you to remove it
An audit record at the repositorynone from the gatewaynonethe repository’s retention policy
Log lines, metrics, spansnone from the gatewaynoneyour log, metrics and trace stores
Stored-query definitionskeptnonethey 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

PartyWhat it does with personal data in a federation
Each member CDR’s operatorholds the clinical record and decides what it releases to the gateway
The PIX Manager, PDQm Supplier, XCPD gateways, NVI and Mitzreceive the patient identifier, and Mitz the professional’s identity, to answer the gateway
The caller’s issuerissues the token that names the professional
The Audit Record Repositorystores the IHE audit records and the access records, which name the patient and the professional
The log, metrics and trace storesreceive 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 officeryours
(b) the purposes of the processingpatient 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 datapatients 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 recipientsthe member CDRs; the identity services; the Audit Record Repository; your log, metrics and trace stores (The other parties)
(e) transfers to a third countrynone by the gateway; each URL in your configuration decides where data goes
(f) the time limits for erasureRetention, with your repository’s and log pipeline’s periods
(g) the technical and organisational measuresthe 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:

NIS2What the gateway offers
Art 21(2)(b) incident handlingthe 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 continuityhealth 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 securitysigned release provenance, a CycloneDX and an SPDX SBOM per binary, image attestations (The container image)
Art 21(2)(e) vulnerability handling and disclosureprivate 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) cryptographythe 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 controlevery 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 authenticationnone 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:

  1. 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.
  2. The lawful basis under GDPR Art 6 and the condition under Art 9(2) for each purpose of use your issuers’ tokens declare.
  3. Whether the deployment needs a DPIA under Art 35, and its outcome.
  4. The retention of the audit repository and the log pipeline, and the access rights to each.
  5. Whether the deployment’s operator is an essential or important entity under NIS2, and which national transposition applies.
  6. 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.
  7. 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

AssetWhy it matters
The patient identifierit names a person; it may reach the identity services and nothing else
Clinical data in transitthe answers a node releases and the writes a client sends
The caller’s identity and tokenthe token admits its holder at the gateway; the identity is what each node audits and decides on
The onward credentials and the signing keythey make the gateway’s requests acceptable to every node; the signing key vouches for every caller
Routing statethe 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 recordsthe IHE audit trail and the access records name patients and callers, and are evidence after the fact
The configuration and the registrythey decide whom the gateway trusts and where it sends data
Availabilityone 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
BoundaryBetweenWhat crosses it
B1a client and the gatewaythe access token, the AQL with the patient identifier, write bodies, answers
B2the gateway and each member nodethe node’s ehr_id, the onward credential, the signed caller token, write bodies, answers
B3the gateway and the identity servicesthe patient identifier, the gateway’s credentials, the identities in a PMIR feed message
B4the operator’s browser, the console and the gatewaythe operator’s sign-in, the operator’s token, query answers
B5a scraper or an operator and the admin listenermetrics; the stored-query distribution
B6the gateway and its audit and telemetry sinksaudit records that name patients and callers; log lines, metrics and spans that name neither
B7the gateway and the issuers it truststhe key sets that decide which tokens are genuine
B8the host and the gatewaythe configuration, the registry document, the credentials and the signing key

Attacker positions

PositionWho
P1anyone who can reach the gateway’s listener without a valid token
P2an authenticated caller that reaches for more than it was granted: another patient, another operation, the operator surface
P3someone on the network path of a hop: between the proxy and the gateway, the gateway and a node, the gateway and a service
P4a member node, or an identity service, that is compromised or misbehaves
P5an issuer that is compromised, or a party that can substitute an issuer’s keys
P6someone with read access to the logs, metrics or traces
P7a workload in the same pod network or on the same host as the gateway
P8a website the operator visits while signed in to the console
P9an insider with access to the host, the volumes or the secrets

B1: client to gateway

ThreatFromMitigationProvided by
Sa request without a valid token, a token signed with none or an HMAC key, a token for another audience or from an untrusted issuerP1every 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
Sa proxy header that claims a callerP1, P3the 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
Ea caller reads another patient, or an operation its scopes do not coverP2SMART 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 requiredScopes per route, Patient grants; app/ferrofed-server/tests/it/auth/scope.rs, patient.rs, other_patient.rs, purpose.rs
Sa client application with no user, or a user authenticated below the level the deployment requires, reading patient dataP1a 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
Ethe ADMIN API, or the operator surface without the operator scopeP2the ADMIN API is refused to every caller; the operator routes need auth.product_scope as one whole scope tokenauth.product_scope; app/ferrofed-server/tests/it/operator.rs
Tan AQL query that smuggles the patient identifier, a second patient, or text around the subjectP2the 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
Tan undeclared header or query parameter, or a declared value of the wrong kind, forwarded to a nodeP2a 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 sentDeclared values; app/ferrofed-server/tests/it/declared.rs
Ra caller denies a requestP2the 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
Ithe patient identifier in a response, an error or a refusalP1, P2errors name the position of the offending part, never the value; a node’s error quoted in meta.federation has each consumed value maskedapp/ferrofed-server/tests/it/errors.rs, facade/query.rs; app/ferrofed-engine/tests/it/dispatch/
Da flood of requests, a large body, a slow requestP1, P2a 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
Ithe token or the patient identifier read on the hop between the proxy and the gatewayP3the 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 aloneTLS 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

ThreatFromMitigationProvided by
Ithe patient identifier reaches a node in the query, the path, the query string, a header or the caller claimsP4the 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/
Sa node replays the caller’s token at another nodeP4the 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 secondsWhat a node is told about the caller; app/ferrofed-server/tests/it/auth/token.rs
Ssomeone obtains an onward token and replays it at its nodeP3, P4onward 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
Ia credential sent to a node in cleartextP3outside the development profile, a node URL that receives a credential must be https, refused at start and on reloadWhat must travel encrypted; app/ferrofed-server/tests/it/transport/
Ttwo nodes claim one ehr_id, or a node claims another’s creating_system_idP4a 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
Ea node releases more than the caller may seeP4each node gets the caller’s scopes and purposes of use and decides what to release (§13.2, N26, N27)the node’s own enforcement
Da slow or failing node holds the gatewayP4per-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
Da node answers without end, or with a body too large to hold, so the gateway buffers it on every requestP4the 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

ThreatFromMitigationProvided by
Ithe patient identifier read in transitP3the URL of every PIX Manager, PDQm Supplier, XCPD gateway and NVI must be https outside development; optional mutual TLSWhat must travel encrypted, Mutual TLS to the identity services
Ta wrong or forged cross-reference routes a query to another patient’s EHRP4none in the gateway beyond the transport: the identity service is trusted for its linkscarried by the deployment: the service’s own controls; record the trust in your §13.4 decisions
Sa forged ITI-93 message drops bindingsP1the 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 anythingpmir.feed_clients, [[auth.issuer]]; app/ferrofed-server/tests/it/pmir/feed.rs
Ta stale binding after a merge at the identity sourceP4each 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 itfederation.binding_ttl, [pmir]
Dan identity service that does not answerP4budgets 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
Ra transaction with no audit recordP9every record is on disk before the transaction’s answer is used; a record that cannot be stored fails the transaction closedThe spool and the failure policy; app/ferrofed-server/tests/it/feed_audit/, audit_repository.rs

B4: the operator console

ThreatFromMitigationProvided by
Sa forged sign-in, a replayed ID TokenP8authorization code with PKCE and a nonce; the ID Token verified against the provider’s key set, issuer, audience and expiryThe operator console; app/ferrofed-console/tests/it/sign_in.rs
Tanother site signs the operator out or spends the sign-in on a queryP8every unsafe request must be same-origin by fetch metadata, Origin or Referer; SameSite=Lax; every action is a POST to its own pageapp/ferrofed-console/tests/it/sign_out.rs, query.rs, configure_refusals.rs
Ia node’s value runs as script in the console, or the page is framedP4, P8every value is escaped on the server; a nonce CSP with frame-ancestors 'none', X-Frame-Options: DENY, nosniff, no-referrerapp/ferrofed-console/tests/it/server.rs, query.rs
Ithe operator’s token or an answer kept by the browserP8, P9the 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
Da flood of GET /loginP1pending sign-ins and sessions are separate, bounded poolsmax_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

ThreatFromMitigationProvided by
Ea remote peer runs a write action, such as the stored-query distributionP7every 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
Sa local process on the host or in the pod runs a write actionP7, P9the same token is needed from a loopback peer; only profile = "development" admits a loopback peer without oneprofile; app/ferrofed-server/tests/it/admin_peer.rs
Ran operator denies running a write actionP7each 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 targetapp/ferrofed-server/tests/it/admin_record.rs, stored_redistribution.rs; carried by the deployment: keeping the security log
Ianyone who reaches the port reads the metricsP7no 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

ThreatFromMitigationProvided by
Ia patient identifier or a caller in a log line, a metric or a spanP6the 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 ownapp/ferrofed-server/tests/it/request_log.rs, metrics/hygiene.rs, traces/; track10 asserts the logs clean
Ithe audit spool read on diskP9the spool is 0700 with files 0600, and the gateway refuses to start when it is open to other usersapp/ferrofed-server/tests/it/audit_repository.rs; carried by the deployment: an encrypted volume
T, Raudit records lost with a spoolP9records are delivered in order from disk; a lost spool loses them for good, and audit_feed or audit_repository reads degraded while records waitLosing a spool; carried by the deployment: durable storage per replica
Imetrics or spans pushed in cleartextP3an 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 developmentapp/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)
Rno record of which caller read which EHRP2every 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

ThreatFromMitigationProvided by
Sa key set fetched in the clear and substitutedP3every issuer, the key set its metadata names, and every introspection endpoint is https, or http to loopback, under every profileWhat must travel encrypted
Dtokens naming unknown keys make the gateway flood the issuerP1an unknown kid reads the set again at most once per jwks_refresh_interval_seconds; an unavailable key set is 503, never a passauth.jwks_refresh_interval_seconds; app/ferrofed-server/tests/it/auth/keys.rs
S, Ean issuer compromised: any token it signs is admittedP5none in the gateway: a trusted issuer is trusted for every caller it vouches forcarried 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

ThreatFromMitigationProvided by
Ia secret in a log, an error, the banner or a Debug printP6secrets come from _file keys, are redacted in Debug and zeroised after reading; a refused configuration names the key, never the valueConfiguration; app/ferrofed-server/tests/it/config/redaction.rs, config/secrets.rs, credentials.rs, banner.rs
Ta configuration that weakens transport or resolves from a static tableP9the 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 fileprofile; app/ferrofed-server/tests/it/transport/
Sthe signing key leaksP9the 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:

  1. 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).
  2. 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).
  3. 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).
  4. 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.
  5. A compromised issuer is admitted for every caller it vouches for until you remove it from [auth] and reload (Reloading the registry).
  6. 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).
  7. The identity service is trusted for its links. A wrong link routes a query, or a bound patient grant, to another patient’s EHR.
  8. 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).
  9. The audit spool names patients; its encryption at rest and its survival are the volume’s.
  10. 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.
  11. Telemetry export is plain gRPC to an http:// collector; name an https:// one for a collector off the host (A remote collector over TLS).
  12. Egress. The Kubernetes package’s base leaves egress open so the gateway reaches its members and services wherever they are; its egress component 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, offset and fetch are the body’s members of the same name, and every other pair is one member of query_parameters: patient=P-12345 binds $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 the POST form, 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 POST form unchanged: each node receives the same request, and you get the same RESULT_SET, status and headers. The headers of pinning a query, completeness and dedup apply as they do to a POST.
  • ehr_id is dropped, as it is from a POST body: the gateway scopes each node by its own ehr_id (§5.4.1, N33).
  • A query string the ITS-REST decoder refuses is a 400 (body-invalid), the answer to a malformed POST body, and no node is asked: no q, a q, offset or fetch given twice, a query parameter given twice, an offset or fetch that is no integer, or a pair that does not percent-decode to UTF-8 text. A parameter of null is a 400 (parameter-invalid), as in a body.
  • The request log never records q or a parameter value, only offset and fetch when 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_id for the patient (N7, N11). A listed endpoint where the patient is not known is reported not-resolved, and the query still answers 200 (§8.1, §11.3).
  • Every registry endpoint the directive does not list is reported excluded. It is not asked and does not clear meta.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 400 with endpoint-unknown or organisation-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, 404 with no-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 (p above) may be selected or ordered on, as an ENDPOINT attribute (§9.3). Using it in WHERE or inside a function, or binding its name again in FROM, is refused 400 with endpoint-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-resolved for a listed endpoint that does not know the patient, excluded for every other endpoint, 400 with endpoint-unknown or organisation-unknown for an identifier the registry does not know or a header with no identifier in it, and 404 with no-destination when 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 400 with targeting-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'
PathValue in the row
p/id, p/endpoint_idthe endpoint id, as meta.federation.endpoints[].id reports it
p/organisation, p/organization_idthe endpoint’s managing organisation (N20)
p/system_idthe openEHR system_id of the endpoint’s node, the follow-up routing key (§12)
p/urlthe 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/id for p/id and c/uid/value for c/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_id returns both (N18). Giving the attribute and an EHR-derived column the same alias is refused 400 with endpoint-name-collision, so no column is shadowed (CP-35). Any other path through the variable, such as p/name or p/id/value, is refused 400 with endpoint-attribute-unknown.
  • Under SELECT DISTINCT the 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 400 with indecomposable-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 your LIMIT, 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 a LIMIT, ordering on an attribute is refused 400 with endpoint-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.error carries its error, which every member also carries (§14.1). Under the default fail-closed policy no member is asked, every member is not-localized, and complete stays true, 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.error carries its error. Every candidate was then asked and each node applied its own consent check, so complete and 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: partial asks for the rows of the nodes that answered when another node failed, with 200 and complete: 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, and Preference-Applied names 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 424 or a 504 included (N31). That is a query you directed at one endpoint, with the header or FROM ENDPOINT, a query whose patient resolves at one member alone, and a query scoped to one ehr_id, which names the node it was routed to. A query that asked no node, because the patient is not-resolved wherever 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 active with a row_count above 0, the count §9.5 takes before DISTINCT, dedup and LIMIT. An endpoint that answered no rows, failed, timed out, or was skipped, excluded or not-resolved is 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 offline or time-out in 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-endpoint with the endpoint_id your 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 then 400 again.

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:

MemberWhat FerroFED declares
federation.idthe deployment’s federation.id
federation.spec_version1.0, the major.minor of the pinned specification release
aql.fan_outtrue: an undirected query asks every member (§4.3, N4)
dedupdefault: "none", modes: ["none", "version-identity"], and the request header openEHR-federation-dedup (§10, N15)
timeoutthe 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)
completenessdefault: "all-or-nothing"; best_effort and, when it is offered, opt_in naming openEHR-federation-completeness: partial (§11.4, N37)
pagingoffset_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.decomposablethe configured functions, of COUNT, SUM, MIN, MAX and AVG; an empty list means none (§11.6.3)
definitionfan_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.modepresent with a localizer configured: the binding that localizes: "xcpd", "nl-gf-nvi" for the NVI of the Dutch Generic Functions, or "pixm"
localization.auditpresent 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_mspresent with a localizer configured: the localizer’s own budget, a part of overall_ms
timeout.demographics_mspresent with [pdqm] configured: the demographics step’s own budget, a part of overall_ms
timeout.consent_mspresent with a consent pre-filter that asks a service, Mitz: the pre-filter’s own budget, a part of overall_ms
consentpresent 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_urithe 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_restquery 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)
manufacturerFerroFED’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_p95 is FerroFED’s own member inside the open endpoint object, named for its statistic as §7a.2 asks.
  • paging.max_window is FerroFED’s own member inside the open paging object: the specification names no member for the bound of the bounded strategy.

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):

  1. The targeting headers. Name the node yourself in the openEHR-federation-endpoint header, with the endpoint_id the result row carried (§8.4), or in the openEHR-federation-organisation header, 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 a 400, and an organisation that manages no endpoint is a 404 (no-destination). A query parameter such as ?endpoint= names no node here either; it is one the operation does not declare, so it is a 400 (query-parameter-refused) and nothing is sent.
  2. A resolution binding of your session: the node one of your earlier queries, or a read of an EHR by subject, resolved that ehr_id at. 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 for federation.binding_ttl after 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 an ehr_id was resolved, never a consent decision: the node still decides what it releases.
  3. The gateway’s ehr_id index, which it learns from resolutions and from the nodes’ successful answers.
  4. 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 answers 404, gets your read; a read of the EHR itself is answered from that probe. When every member answers 404, the read is a 404 (no-destination). When two members hold the ehr_id, the read is a 409 (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) or 424 (node-error, node-refused), naming the member. The probe is sent only for an ehr_id that is a bare UUID. Any other HIER_OBJECT_ID form, such as an ISO OID, could be a patient identifier, and the probe would carry it to every member, so the read is a 400 (probe-requires-uuid) and no member is asked (§5.4.1, N33). Such an ehr_id is 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_IDENTIFIER in a committed COMPOSITION included, 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, Prefer and the openehr-* commit headers, the ones that operation lists), and no other header. If-Match reaches the node on a PUT, for example, and never on a GET. Your Authorization never reaches a node: the gateway authenticates to each node with that node’s own credentials, and tells the node who you are in an openEHR-federation-client token it signs itself (§13.1, N24, What a node is told about the caller). Neither does your X-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_time on a read, or path on a directory read). Any other parameter is a 400 (query-parameter-refused) and nothing is sent, because the gateway cannot tell an identifying value from any other (§5.4.1, N33);
  • Accept, Content-Type and Prefer as the gateway composes them from yours: the media type the operation lists that your Accept prefers (the first listed for */* or no Accept), the listed media type your Content-Type names, with a charset=utf-8 dropped, and only the listed preferences of your Prefer. An Accept that admits no listed media type is a 406 (media-type-not-acceptable), and a Content-Type that names none is a 415 (media-type-unsupported), as a node would answer. A body never reaches a node without a Content-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 a 415;
  • each path identifier and each other declared value only when it is what the operation declares: a version_uid that is an OBJECT_VERSION_ID, a versioned_object_uid that is a UUID, a version_at_time in the extended ISO 8601 format. Any other value is a 400 (parameter-value-invalid) and nothing is sent. A value of free text, such as If-Match or a directory path, 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 ETag and Location.
  • 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’s creating_system_id as 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’s creating_system_id as 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 a CONTRIBUTION by its position). It quotes no other part of your request. The path ehr_id belongs 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 own ehr_id for the patient.
  • The write names no single version: If-Match is absent, repeated, a list, *, a weak tag, unquoted, or no OBJECT_VERSION_ID, or a composition delete’s path is no OBJECT_VERSION_ID. That is a 400 (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 no Content-Type: canonical JSON;
  • application/openehr.wt.flat+json or application/openehr.wt.structured+json: a canonical envelope whose versions’ data is FLAT or STRUCTURED (ITS-REST 1.1.0 contribution_create). The gateway reads the envelope for each preceding_version_uid and leaves data to the node, so such a CONTRIBUTION routes 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 in openEHR-federation-endpoint to 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 404 for a subject with no EHR (no-destination). This request reads one EHR resource, so the 200 with 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 a 404, 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 the 404. A localizer that does not answer is a 424 (localization-unavailable) with no member asked, under the default fail-closed policy; under on_failure = "ask-all" every member is a candidate. A read with openEHR-federation-endpoint is 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 a 404, because a denied member may hold the EHR. A pre-filter that cannot answer leaves every member to its own consent check (Consent).
  • subject_id and subject_namespace are each given once; anything else in the query string is a 400, 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 a 400 (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, Location and ETag as the node sent them, with openEHR-federation-endpoint and openEHR-federation-system-id naming 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 404 for a template the node does not hold, or a 400 for 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.4 or .../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 a 400 (target-required). Every other definition request routes to one node, and * stays endpoint-unknown there. A list naming a suspended endpoint, or * with no active member, is a 404 (no-destination) and nothing is sent; * leaves a suspended member out and reports it excluded.
  • 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, the timeout in force, and one endpoints[] entry per registry member. A member that accepted is active, one that failed is node-error with the node’s HTTP status and an excerpt of its message in error (see A node’s error in endpoints[]), or time-out or offline, and one you did not name is excluded. No other part of a node’s body, and no Location, is copied into it.
  • The status is 200 when every member you named accepted, and 207 with complete: false when some accepted and others failed: a partial success is never reported as success (§12.6). When none accepted, the status is 504 if a member timed out or could not be reached, and 424 otherwise (§11.2).
  • openEHR-federation-endpoint and openEHR-federation-system-id list 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/ answers 501 (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 in openEHR-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, with openEHR-federation-endpoint and openEHR-federation-system-id naming who acted (§7a.3, N31). A header naming another endpoint is a 400 (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} over a-z, A-Z, 0-9, _, . and -, and the query name is never aql (ITS-REST). The version is major.minor.patch (ITS-REST’s semver path segment, §12.7). Anything else is a 400 (query-name-invalid, query-version-invalid). The body is text/plain: send that Content-Type, with charset=utf-8 if you like, or none. Any other is a 415 (media-type-unsupported), and nothing is stored. query_type, when sent, is AQL in any case.
  • A PUT with no version, PUT {base}/v1/definition/query/{name}, stores the query as a new version the registry assigns: 1.0.0 for a name it holds no version of, else the next patch of the newest version it holds, newest by number, so after 1.9.0 and 1.10.0 it assigns 1.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”). Location names the assigned version (the Location of a stored query). Every other rule of this list applies to it as to a PUT at a version, the literal-patient refusal first, so a refused definition takes no version.
  • A stored version is immutable. A second PUT to a name and version the registry holds is a 409 (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 second PUT, 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 $parameter standing in for a value you will bind. A text it would refuse whatever you bind is refused now, 400 with 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 refused 400 (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 200 with Location naming 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). A PUT there is a 409 (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 PUT there is a 405 (stored-query-read-only) with Allow: GET, OPTIONS, and OPTIONS on the path lists no PUT; 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 a 409 (stored-query-held). Two PUTs 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 answers 409 (stored-query-held) and stores nothing; send the PUT again.

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 exact major.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 a 404 (stored-query-unknown).
  • Every rule of an inline query applies unchanged: offset and fetch, the completeness and dedup headers, Prefer: wait, and the targeting headers. A definition that carries FROM ENDPOINT or ORGANISATION is targeted by it, and a header that selects other endpoints is a 400 (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 Query body is JSON, under the same Content-Type rule as an inline query: application/json or none, and any other is a 415 (media-type-unsupported) that asks no node.
  • The answer is the ordinary federated RESULT_SET, with name naming the gateway’s stored query and q its 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: offset and fetch by name, and every other pair a query parameter. The stored GET forms declare no q, so q=… binds $q. ehr_id is dropped, and a query string the decoder refuses is a 400 (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_url set (the public base URL), it is that absolute URL, such as https://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 a PUT at a version, and ./{name}/{version} after a PUT with 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; saved is 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 PUT stores 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, and ferrofed config check refuses 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. A PUT that names none is stored at the registry alone, as above. A list naming a suspended endpoint, or * with no active member, is a 404 (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} and query_type=AQL, on its own. A PUT with 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) with meta.federation beside it, one endpoints[] entry per registry member in the shape of a federated result set’s (§9.5), and Location naming the stored version, and meta.registry: "stored" says the request stored it. The statuses are those of the template fan-out: 200 when every member you named accepted, 207 with complete: false when some did, and 504 or 424 when none did. Whatever the status, the registry holds the definition. A member that failed carries its HTTP status and an excerpt of its message in error, as in a query’s answer (A node’s error in endpoints[]); no other part of a node’s body is copied into the answer.
  • A definition whose AQL carries a FROM ENDPOINT or ORGANISATION directive is refused 400 (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 StoredQuery with meta.federation. A member whose copy is the same query is active; layout and comments do not count, because both sides are compared as their canonical prints. A member whose copy differs is node-error with error.code: "definition-differs", and one that holds none is node-error with error.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 200 when every member you named matches, and 207 with complete: false otherwise. openEHR-federation-endpoint and openEHR-federation-system-id list the matching members.
  • Without a header the GET answers 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 saved time included, does not change.
  • The answer has the shape and the statuses of a first distribution (200, 207 with complete: false, 504 or 424), and meta.registry: "held" says the registry held the version and stored nothing.
  • With gateway.public_url set, Location names the stored version’s URL under it. Without it, the answer carries no Location: 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; 400 for a request naming no member (target-required), a body (body-invalid), a deployment without federation.fan_out_stored_queries (stored-query-fan-out-unsupported), or a definition carrying a FROM ENDPOINT or ORGANISATION directive (definition-endpoint-targeted).
  • A read-only registry answers it 405 (stored-query-read-only) with an empty Allow: 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:

RequestAnswer
GET {fhir-base}/metadatathe face’s CapabilityStatement
GET {fhir-base}/Patient?identifier=system|valuethe patient lookup: the Patient the identity binding holds, when a member holds the patient (Looking a patient up)
GET or POST {fhir-base}/Patient/$summarythe patient summary, an HL7 Europe Patient Summary document Bundle
POST {fhir-base}/Bundlea document received in the exchange format, written to the member its category is declared for
GET {fhir-base}/DocumentReferencethe 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"}]}
  • identifier is required, once, as system|value. The system is the namespace the gateway resolves the patient in (§5.2), as it is for an AQL query’s external_ref/namespace.
  • profile, when sent, is http://hl7.eu/fhir/eps/StructureDefinition/composition-eu-eps, with or without |1.0.0-ballot.
  • _format, when sent, is json, application/json or application/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 no Patient resource.

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):

  1. 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.
  2. Every section query is read as the façade reads a stored query, the patient bound through $patient and $namespace alone.
  3. 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.
  4. 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).
  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.
  6. The document is written: the composition, the patient, the gateway’s Device and the operator’s Organization as its authors, each member whose data a section holds as an author of that section, one Provenance per 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 unavailable and 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 (eehrxf crosswalk::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 under docs/specs/ferrobridge-medication/, records them so, and a [[fhir.mapping]] may name it as an ADL 2 template with the archetypes it 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 nilknown only when every member answered and none holds content for it, and unavailable otherwise.
  • 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_id is the version’s creating_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 Patient carries 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 EPS Patient requires, then carries a data-absent-reason of unknown. 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.identifier is required, once, as system|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-5 or http://loinc.org|. A type that 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 empty searchset, and nothing is resolved or sent.
  • _format, when sent, is json, application/json or application/fhir+json. Any other parameter or modifier is a 400.

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 native mode whose own endpoint answered with a document, its custodian the member’s organisation. Its attachment.url is Binary/{token}, relative to {fhir-base}, where the gateway serves the document (Reading a member’s document), never a URL of the member’s. contentType is the media type the member answered with (application/octet-stream when it named none), hash the base64 of the SHA-1 digest of the bytes the list fetched, and size their number. Its masterIdentifier is the document’s own Bundle.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 bindingThe answer
knows no patient for the identifier404 not-found
matches several patients422 multiple-matches, none of them picked
holds the patient with no family name, given name or text422 required: the EPS Patient requires a name (ips-pat-1)
is not asked about identifiers of the system you sent422 not-supported
does not answer within its budget504 timeout
fails, or its exchange cannot be audited502 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:

StatusIssue type
400invalid
401login
403forbidden
404not-found
405, 406, 415, 501not-supported
408, 504timeout
424incomplete
429throttled
503transient
any otherexception

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):

  1. 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: LOINC 60591-5 for a patient summary. A category no member receives is a 422 that names it.
  2. It is validated in full by FerroBRIDGE’s validator against the category’s Bundle profile, 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 a 422 with one issue per error, located by its FHIRPath. A warning, and each constraint the validator cannot evaluate, is listed as a warning in the answer, never passed silently.
  3. Every coding that carries no display is 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 a 422 naming where it is.
  4. 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 openEHR COMPOSITION.category, so the composition takes the one the category’s template decides: its assumed value, or its one admitted code, or event (openehr 433) 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’s category an assumed value (The template’s category).
  5. The patient is resolved from every identifier of the document’s Patient in a namespace fhir.receive_namespaces names, at the receiving member alone (§5.2). Every one must name the same ehr_id there. A patient the member holds no EHR for, or identifiers that name two patients, are a 422, and nothing is sent to any member.
  6. The composition is committed with ITS-REST composition_create to the member, located by its own ehr_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 and gateway.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 example allergyIntolerance-eu-core for the allergies. A mapping that does not compile, or maps elsewhere, refuses the start and ferrofed config check. Its template is an OPT 1.4 .opt, or an ADL 2 template beside archetypes, 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-results and care-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 and ferrofed config check are refused. The package is the HL7 Europe package whose profiles the category’s documents are held to, and dependencies lists 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 and ferrofed config check refuse a set that lacks one, naming it, and one that does not carry the category’s profiles. Each is the .tgz the FHIR package registry serves (https://packages.fhir.org/<name>/<version>) or an unpacked directory; the list above is the closure of hl7.fhir.eu.eps 1.0.0-ballot, which scripts/vendor/eehrxf.sh and scripts/vendor/fhir-packages.sh fetch, each pinned by sha256. template is an OPT 1.4 .opt, or an ADL 2 template beside archetypes, 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 of Electronic-Dispensations that is not also declared for Electronic-Prescriptions, because a dispensation is written only at the member that owns its prescription. The start and ferrofed config check are refused for a member that is no node of the registry, one with no hl7-fhir-rest endpoint of its own whose categories name 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:

  1. GET {endpoint}/Patient?identifier={system}|{ehr_id} for the logical id of the member’s Patient. An empty searchset means the member holds no report of the patient, and nothing more is asked.
  2. GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|11502-2 for its laboratory reports, each next page 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
]
  • template is an OPT 1.4 .opt, or an ADL 2 template beside the archetypes it specialises or fills a slot with. The shipped HDR mapping of eehrxf maps 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-Reports entry takes the place of the template of the category’s [[fhir.receive]] entry, and a Medical-Imaging entry declares the imaging reports the gateway writes.
  • fhir.receive_namespaces names 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.models 1.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.type is LOINC 34105-7 is held to every constraint of bundle-eu-hdr and composition-eu-hdr, and of the profiles its entries claim, before anything reaches a member. A document that breaks one is refused 422 and 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 own ehr_id alone, as a received patient summary is. Each receipt is recorded once under Discharge-Reports in 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:

  1. GET {endpoint}/Patient?identifier={system}|{ehr_id} for the logical id of the member’s Patient. An empty searchset means the member holds no report of the patient, and nothing more is asked.
  2. GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|18842-5,http://loinc.org|100719-4 for its discharge reports, each next page followed under its own base. The type names 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.category is Medical-Imaging is held to every constraint of BundleReportEuImaging and CompositionEuImaging, and of the profiles its entries claim, before anything reaches a member. A document that breaks one is refused 422 and 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 own ehr_id alone, as a received patient summary is. Each receipt is recorded once under Medical-Imaging in 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:

  1. GET {endpoint}/Patient?identifier={system}|{ehr_id} for the logical id of the member’s Patient. An empty searchset means the member holds no imaging document of the patient, and nothing more is asked.
  2. GET {endpoint}/DocumentReference?patient={id}&type=http://loinc.org|85430-7,http://loinc.org|18748-4 for its imaging documents, each next page followed under its own base. The type names 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_MAPPING whose match is =, an equivalent term, adds a coding after the original.
  • A mapping whose match is > (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, an at or ac code) 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 as SNOMED-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 is equivalent or equal becomes a coding; a wider, narrower, inexact or other match never does. In the patient summary, each CodeableConcept of 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 with 500, and is never taken as no translation. A bare Coding holds one code, so it is kept as recorded.
  • CodeSystem/$lookup, for the display of a received coding that carries none.
  • ValueSet/$validate-code, for a required binding 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:

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-Prescriptions and Electronic-Dispensations, a category declared twice, an entry with no member, and a member named twice;
  • a member of Electronic-Dispensations that is not also declared for Electronic-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):

RouteCategory
GET {fhir-base}/MedicationRequest?patient.identifier=Electronic-Prescriptions
GET {fhir-base}/MedicationRequest/{id}Electronic-Prescriptions
POST {fhir-base}/MedicationDispenseElectronic-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 a searchset of MedicationRequest-eu-mpd resources, each with a logical id, and with next links 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}/MedicationDispense and answers 201 with a Location of the created resource, or with the resource and its id in the body;
  • answers GET {own}/MedicationDispense?patient.identifier={ehr_id_system}|{ehr_id}&_id={its id} with that one dispensation;
  • refuses, with a 4xx status other than 401, 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 MedicationRequest as the member sent it: every element and extension kept, and every number in the text the member wrote, such as 1.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. Its id is the locator id, and its fullUrl the read, {fhir-base}/MedicationRequest/{id};
  • one Provenance per prescription, whose agent is the member’s Organization; 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 warning outcome entry 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 a self link 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).

RouteStatusWhen
search200The searchset, empty for a patient no declared member holds or none may say anything about.
read200The member’s prescription or dispensation, with the headers Reading one prescription names.
receipt201 CreatedThe 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.
search400A parameter other than patient.identifier, _count, -offset and _format, no patient, or two.
receipt400A 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 route403No granted scope covers the route, or a patient/ grant reaches beyond its patient (patient-confinement).
every route404 not-supportedNo member is declared for the route’s category.
read404 not-foundThe 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 read404 not-foundAs a read; or the member holds the resource at another version, or with no meta.versionId, and the gateway keeps no history.
receipt404 not-foundNo member holds the patient, none may say so, or no member holds the prescription. Every case gets the same text.
read409 conflictTwo 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.
receipt409 conflictTwo 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.
receipt409 duplicateThe owner already holds a dispensation with the received one’s business identifier, named by its locator id. Nothing was written.
receipt415Another media type than application/fhir+json.
receipt422The 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, read424A 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.
receipt500The 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).
receipt502The 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 route503 access-unrecordedThe 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.
receipt503A 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, read504A member is offline or did not answer in time.
receipt504A 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):

RequestRecorded asTest
searcha read under Electronic-Prescriptions, naming every member askedfhir/prescriptions/restricted.rs: a_search_is_recorded_once_under_electronic_prescriptions_naming_each_member
prescription reada read under Electronic-Prescriptions, naming the member and the ehr_id it was asked byfhir/prescriptions/read.rs: a_read_is_recorded_once_under_electronic_prescriptions_naming_its_member
receipta creation under Electronic-Dispensations and Electronic-Prescriptions, naming every member the lookup asked and the ownerfhir/prescriptions/dispense.rs: a_dispensation_is_recorded_as_a_creation_of_both_categories
dispensation reada read under Electronic-Dispensationsfhir/prescriptions/dispense.rs: a_dispensation_read_is_recorded_as_a_read_of_dispensations
withdrawalan update under Electronic-Dispensations, naming the member and the ehr_id it was asked byfhir/prescriptions/withdraw.rs: a_withdrawal_is_recorded_as_an_update_of_dispensations
version reada read as the read of the same resource records itfhir/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:

  1. 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).
  2. Trust the issuer of the connector’s tokens as an [[auth.issuer]] of kind = "federation" with sender_constraint = "required", and give it an [[auth.federation]] table that sets contact_point = true (Authentication).
  3. Name that issuer in a [[federation.contact_point]], with the header the connector sends its correlation identifier in (National contact points).
  4. 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_type urn:ietf:params:oauth:grant-type:token-exchange, with the identity assertion, base64url, as subject_token of subject_token_type urn:ietf:params:oauth:token-type:saml2. Once the patient is identified, it adds the TRC, base64url, as trc_token of trc_token_type urn:ietf:params:oauth:token-type:saml2. A discovery-phase exchange sends no trc_token. The names trc_token and trc_token_type are 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-vouches and 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 client invalid_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’s federation::assertion::Validator::exchange does:

    • 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, AuthnInstant and SessionNotOnOrAfter held to the clock;
    • a TRC whose Advice/AssertionIDRef names 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’s replay::ReplayGuard describes. 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 answers server_error when the replay record cannot be read.

  • The token. It carries the ehdsi claim Exchange::claim writes, is bound to the connector’s certificate, and lives no longer than Exchange::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:

RouteWhat it answers
GET {fhir-base}/Patient?identifier=system|valuethe patient lookup: the Patient the identity binding holds, or the empty searchset
GET {fhir-base}/Patient/$summary?identifier=system|valuethe patient summary built from every member that holds the patient
GET {fhir-base}/DocumentReference?patient.identifier=system|valuethe document list
GET {fhir-base}/MedicationRequest?patient.identifier=system|valuethe patient’s prescriptions at the serving members
GET {fhir-base}/MedicationRequest/{id}one prescription
POST {fhir-base}/MedicationDispensea 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 not system|value, is refused 403 forbidden with the diagnostics trc-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 subject must 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-found on these routes all get one answer: 404 not-found, whose issue.details carries the refusal trc-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_FOUND for the summary and its documents, ERROR_ORCD_NOT_FOUND for a laboratory report, and ERROR_EP_NOT_FOUND for 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::security as trc-patient-mismatch and 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’s organization-id) and the identifier of the Organization the dispensation names on performer.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-id and name the same facility on performer.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 refused 403 forbidden with discard-refused and ERROR_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 424 patient-context-unavailable with nothing sent, and logged under ferrofed::security as trc-patient-unresolved with 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.id is refused 403 with the code trc-missing on 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 POST or GET, 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 one ehr_id (N29) must be scoped to one of that patient’s own ehr_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 that ehr_id is the TRC patient’s own EHR, located through the TRC patient’s pairs: the ehr_id index is not read and no member is probed. GET {base}/v1/ehr by 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: 404 with the code subject-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 refused 403 with the code trc-unbound, with nothing sent.
  • Every request outside the TRC’s patient is the same trc-patient-mismatch security 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] disclose says (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
KeyDefaultWhat it sets
threshold5how many emergency accesses one professional is granted in any window, 1 to 5
window1dthe 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 and hp_identifier, the assertion’s NameID (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 window seconds holds at most threshold granted 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 happenedStatus and issue typeTemporary?
A member, the identity binding, the access record or the store that counts emergency accesses could not answer in time or at all503 transient, 504 timeoutyes, the connector may ask again
A member answered with an error, named in the outcome424 incompletethe member’s, which the outcome names
The owner of a prescription failed on a dispensation or could not be reached502 exception, the member’s own issues after the gateway’s when it sent someunknown: read the prescription before sending again
The request is malformed or misses a parameter400 invalidno
The token falls short of the MyHealth@EU contact-point profile403 forbiddenno, until the contact point sends what the assertion carries
The token names no TRC patient403 forbidden, trc-missingno, until the contact point sends it
The professional reached the emergency access limit403 forbidden, emergency-limit-reached and ERROR_SEC_GENERICyes, once the oldest emergency access leaves the window
No member holds the patient, or the prescription, or the request is outside the TRC’s patient404 not-found, trc-patient-mismatch and the service’s *_NOT_FOUND codeno
The member that owns the prescription refused the dispensation409 conflict, the member’s own issues after the gateway’sno
The gateway failed on its own500 exceptionno

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 refusesthe 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 includedERROR_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 limitERROR_SEC_GENERIC
A member, the identity binding or a service it needs cannot answerERROR_REGISTRY_NOT_AVAILABLE
Outside the TRC’s patient, or a patient no member holdsthe service’s *_NOT_FOUND
The gateway’s own defect, or its own record that failedERROR_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 as https://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 a urn:uuid: URI, when the request id is a UUID.
  • code: the stable code, from the tables below. Branch on this member or on type.
  • request_id: the request id, the value of the X-Request-Id response header. It is your own X-Request-Id when 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. A 500 internal for a request whose handler failed, a 503 overloaded and a 429 rate-limited always 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):

StatusWhenThe cause, in meta.federation.endpoints[]
504a node did not answer in time, or could not be reachedtime-out or offline, with the node’s error
424a 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
424the cross-reference service could not answer for a membernot-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):

StatusWhen
200every member the request named accepted the template, so complete is true
207some 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)
424no member accepted, and every one answered with a failure (node-error, the node’s HTTP status in error)
504no 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

CodeStatusWhen
body-invalid400The 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-invalid400The openEHR-federation-completeness header is repeated, or carries neither all nor partial (§11.4).
partial-unsupported400The request asks for partial, and this gateway does not offer best-effort (§11.4, N37).
dedup-invalid400The openEHR-federation-dedup header is repeated, or names neither none nor version-identity (§10, §7a.2).
parameter-invalid400A query parameter is null, an array, an object, or an integer outside 64 bits.
patient-invalid400The 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-destination404The 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-collision409The 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-unreachable409A 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).
internal500The gateway failed on its own side. The operator’s log records the failure under the gateway’s request id.
not-found404The path is outside every surface the gateway serves.
not-implemented501The 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-unknown400The 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-unknown400The 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-required400A 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-several400A 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-refused400A 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-timeout504The 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-unreachable504The node a request was routed to, or a member the ask-all probe asked, could not be reached (§11.2).
node-refused424The node a request was routed to, or a member the ask-all probe asked, refused the gateway’s onward credentials (§11.2).
targeting-conflict400The 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-invalid400The 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-error424A 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-uuid400A 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-invalid400A 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-invalid400A 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-requiredretiredRetired 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-unsupported400A definition’s query_type is not AQL; the registry stores AQL only.
subject-literal400A 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-held409The 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-unknown404The registry holds no stored query at this name, or none at the version or version prefix the path names.
preceding-version-invalid400A 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-invalid400A 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-acceptable406The 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-unsupported415The 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-several400The 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-unavailable424The 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-held409PUT {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-targeted400A 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-unsupported400A 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-only405A 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-reserved409A 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-denied403The 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-unavailable404Only 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-unavailable424The 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).
unauthenticated401The 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-malformed400The 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-missing403A 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-unbound403A 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-insufficient403The 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-required403The 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-unavailable503The 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-refused403The 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-missing403Only 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-confinement403The 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-unavailable424The 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).
overloaded503The 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-limited429You 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-unrecorded503The 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-required401The 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-required403The 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-reached403The 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-uncounted503A 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-invalid400The 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-insufficient401The 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-invalid422Under 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-stale409A 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-unknown404A configuration revision route on the admin listener names a number the store holds no revision under (Configuration revisions).
rollback-unavailable409A rollback on the admin listener asks for the revision before the running one, and none ran before it (Configuration revisions).
resource-file-owned409A 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-unknown404The 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-required409An 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-revision403The author of a configuration revision approves it on the admin listener; only another operator does (Second-operator approval).
approval-not-required409An approval on the admin listener names a configuration revision that changes no resource config.approval names (Second-operator approval).
operator-unverified403A 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-unavailable409A 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-pending409An apply on the admin listener while the running configuration revision awaits its confirmation: confirm it or roll it back first (Commit-confirm).
secret-store-unavailable409A 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-unmanaged404GET /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 one ehr_id, on a read or a write. The gateway also raises an integrity incident for the federation operator, because two nodes holding one ehr_id is a defect in the federation (§12.5.2, N42).
  • ehr-id-held: a new EHR would take an ehr_id another 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.

CodeStatusWhen
not-aql400The query is not AQL 1.1.0.
parameters400The query parameters cannot be bound: one is used without a value, supplied but unused, supplied twice, or of a form its position cannot hold.
unreducible400The patient predicate cannot be reduced to one ehr_id scope per node: it sits under OR or NOT, is not an =, compares with a non-literal, or uses another subject path (§7.1, §5.4.3).
identifier-not-string400The patient identifier or its namespace is compared with something other than a string.
second-subject400The query names two different patient identifiers (§7.1).
second-namespace400The query names two different issuing namespaces for the patient (§5.2, §7.1).
empty-identifier400The patient identifier is empty (§5.2).
no-namespace400The patient identifier carries no namespace, and the deployment configures no default (§5.2).
subject-projection400A selected subject column is not one the gateway can fill in from the resolution input (N5, §7.1).
subject-without-predicate400The subject column is selected, and the query names no patient (N5).
subject-ordering400A subject path appears in ORDER BY, which would carry it to a node (§5.4.2).
identifier-elsewhere400The patient identifier appears in another position of the query, where it would reach a node (§5.4.1, N33).
unfoldable-function400A string function over a literal is compared with an identifier path and cannot be folded at the gateway (§5.4.1).
undirected-aggregate400An aggregate cannot be computed correctly across nodes; direct the query to one node, or select the rows and aggregate them (N14, §11.6.3). The function is not one the gateway declares decomposable.
indecomposable-aggregate400A declared decomposable aggregate cannot be recombined exactly in this query: it selects DISTINCT, uses COUNT(DISTINCT …), selects a column that is not an aggregate beside it, or the request selects openEHR-federation-dedup: version-identity; direct the query to one node, or select the rows and aggregate them (N14, §11.6.3).
partial-aggregate400The request asks for partial, and the query is an aggregate recombined across nodes, which is exactly correct only over every node (§11.6.3, §11.4).
undefined-function400The query calls a function AQL 1.1.0 does not define, such as a product-specific MEDIAN, and would reach more than one node. The gateway cannot tell whether the function aggregates; direct the query to one node, or select the rows and compute it in the application (N14, §11.6.3). The functions AQL defines (LENGTH, ROUND, NOW, TERMINOLOGY and the rest) are sent to every node as written.
offset-unsupported400Offset-based paging is not supported across a fan-out (§11.6.2, N39).
offset-page400The gateway computes an OFFSET page from k + n rows per node, and this page cannot be computed that way: k + n is past the configured bound, or the query has no LIMIT or no ORDER BY (§11.6.2, N39).
paging-conflictretiredRetired since #868: no longer raised. The ITS-REST offset and fetch members compose with the query’s OFFSET and LIMIT as a window over its result, so they can no longer disagree. Until then it was a 400 for a member that differed from its clause.
negative-paging400An ITS-REST paging member, or a row count of the query, is negative.
top-backward400TOP … BACKWARD is not supported across a fan-out; write ORDER BY … DESC LIMIT n.
top-with-limit400The query uses the deprecated TOP together with a LIMIT clause, which AQL forbids; write ORDER BY … LIMIT n.
top-with-fetch400The query uses TOP and the request carries the ITS-REST fetch member, which cannot be combined with it; write ORDER BY … LIMIT n, or send fetch alone.
order-not-selected400Under DISTINCT, an ORDER BY path is not also selected (N13).
unordered-distinct-cut400Under DISTINCT with ORDER BY and LIMIT, a selected function column reads a path that is not selected, or a value from outside the row (NOW() and the other clock functions, TERMINOLOGY). AQL orders a node only on paths, so a node cut at its LIMIT could keep different rows on each repeat (§11.6.1, AQL §ORDER BY). Select the paths the function reads, or drop the LIMIT.
node-set-undefined400The query names no patient, and neither the directive nor a targeting header names endpoints, so no node set is defined (N4, §8).
endpoint-variable400The variable of the FROM ENDPOINT directive is bound again in FROM, or used anywhere but as a selected column or an ORDER BY key: in WHERE or inside a function. A path through it reads an ENDPOINT attribute (§8.1, §9.3).
endpoint-attribute-unknown400A path through the FROM ENDPOINT variable selects no ENDPOINT attribute: the attributes are p/id or p/endpoint_id, p/organisation or p/organization_id, p/system_id and p/url, each with no predicate and nothing after it (§9.3).
endpoint-name-collision400An ENDPOINT attribute column has the name of an EHR-derived column, so one name would denote two columns. Alias the attribute to a name no other column carries (N18, CP-35).
incomparable-distinct-key400Under DISTINCT with ORDER BY and LIMIT, a selected path names a value AQL defines no order for: a whole RM object (SELECT DISTINCT c), a data value that is not a DV_ORDERED (c/name, a DV_TEXT), the DATA_VALUE of an ELEMENT, a collection, or a path the RM does not resolve. AQL orders only primitives and Ordered types (AQL §ORDER BY), so a node cut at its LIMIT could keep different rows on each repeat (§11.6.1). Select a path to a primitive value, such as c/name/value, or drop the LIMIT.
incomparable-order-key400Outside DISTINCT, with ORDER BY and LIMIT (or TOP, the fetch member, or a bounded OFFSET page), an ORDER BY path names a value AQL defines no order for: a whole RM object (ORDER BY c), a data value that is not a DV_ORDERED (c/name, a DV_TEXT), the DATA_VALUE of an ELEMENT, a collection, or a path the RM does not resolve. AQL orders only primitives and Ordered types (AQL §ORDER BY), so the first rows of a node need not hold the federated first rows, which §11.6.1 shows only under a total order. Order on a path to a primitive value, such as c/name/value, or drop the LIMIT: without one, the gateway orders every row itself.
endpoint-order-dedup400Under version-identity dedup (openEHR-federation-dedup: version-identity) with a LIMIT (or TOP, the fetch member, or a bounded OFFSET page), an ORDER BY key reads an ENDPOINT attribute. The copies of one version at two endpoints differ on it, so a node’s first rows need not hold its share of the federated first rows (§10.2, §11.6.1). Drop the key, the LIMIT or the dedup.

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 areRead
The operator who installs and runs a deploymentevery section below
The developer of a client application clinicians useIntended use, Reading an answer safely, Limitations, and The client contract
A deployment’s data protection officer or counselConfiguration 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

  1. Check the platform. FerroFED runs on Linux, x86_64 or aarch64, 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 to federation.max_node_answer_size each (The answer bound), so size memory from the sizing below.
  2. 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).
  3. Agree the conditions of membership with each CDR’s operator: a unique system_id, version-4 UUID ehr_ids never reused or adopted, each ehr_id fed 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:

  1. Verify the image or binary against its attestation and pin it by digest (Install).
  2. Write the registry with every endpoint suspended (The registry).
  3. Trust your callers’ issuer (Client authentication).
  4. Give each member its onward credential and make the signing key (Onward credentials).
  5. Configure identity resolution (Identity resolution).
  6. Send the access records to your Audit Record Repository (The access log).
  7. Put TLS in front of the gateway, or on its listener (TLS and the public address).
  8. Run config check, start the gateway, and admit each member with ferrofed admission check before you take its endpoint out of suspension (Check, start and admit).
  9. 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 as consent-denied, which the Federation Tier requires (N27a). In the EU set false (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, with minimum = "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 which acr values 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 recorded ehds-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 its ehds-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_namespaces the namespaces your Member State’s access service searches the log by, so the record of a request addressed by ehr_id names the patient (The patient behind an ehr_id), and set max_named_ehrs to the most ehr_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 v3 ActReason code BTG, 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}/metadata names the categories your deployment receives and those it has no receiving member for, and declares no Bundle create 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. Set receive_namespaces under [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_effort at its default, true. With false, 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 424 until its codes are in the registry’s consent_refusal_codes for 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:

SettingDefaultHow to size it
federation.per_node_timeout10sabove the slowest member’s 99th percentile on ferrofed_node_request_duration_seconds, measured under your load (Metrics)
federation.overall_timeout25sabove per_node_timeout plus the resolver’s and the localizer’s time on their duration histograms (Timeouts)
server.request_timeout30smore than one second above overall_timeout; config check refuses less
server.max_concurrent_requests512the queries your members can take at once divided by the number of members each query asks (The concurrency limit)
federation.max_in_flight_per_node64the requests the smallest member can serve at once; past it a member is reported time-out (The per-member cap)
[server.caller_rate]offset 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.complete before the rows. false means 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.endpoints lists 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: partial returns the other members’ rows with complete: 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-endpoint response header names the members that contributed rows, and a query that selects the ENDPOINT attributes 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-resolved as unknown. In a deployment with disclose = 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-unrecorded after 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 504 to a dispensation as an unknown outcome, and a 502 that 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:

MaintenanceWhenReference
Upgrade to the newest releaseat 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 releaseUpgrading, SECURITY.md
Run config checkbefore every start, reload and upgradeRunning it
Re-run ferrofed admission check for a memberwhen the member changes its CDR product, its version, its system_id or how it creates ehr_idsAdmitting a node
Update the category mapwhen a member adds or changes a template[access_log]
Watch the alertscontinuously, on the shipped alert rulesDashboard and alert rules
Clear a quarantined audit recordwhen ferrofed_audit_quarantined rises, after reading why the repository refused itThe audit spool
Rotate the signing keyon your key policy’s schedule, and at once when it may have leakedRotating the signing key
Rotate a machine client’s secreton 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 onemetrics.scrape_clients, pmir.feed_clients
Renew TLS certificates and onward credentialsbefore 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 periodHardening, TLS on the listeners
Clear a held ehr_id collisiononce the member at fault has fixed it, on each replica that holds itClearing a held collision
Move to the newest releasewhen 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 wellSECURITY.md, The startup banner
Confirm an applied change to an onward credential or a binding sectionunder config.ui = "managed", within config.confirm (120000 ms by default) of the apply, once the gateway works with it; unconfirmed, it is rolled backCommit-confirm
Bring a replica back in stepwhen FerroFEDConfigurationDrift fires: fix what the replica refused, then send it SIGHUP or apply a newer revisionSeveral replicas
Back up the configuration revisionsunder 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_dirTurning it on
Confirm a prescription member’s contractat admission, and when a member declared in [[fhir.serve]] changes its CDR product or versionThe member contract
Settle a prescription identifier collisionwhen 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 itEvery answer, Integrity incidents
Export the identity statebefore a system that replaces FerroFED takes over, on each replicaExporting the identity state, Decommissioning
Keep each receiving category’s packages completewhen 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 manifestsReceiving a document
Keep the PIX Manager fedcontinuously: 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 foundIdentity 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_afterUpgrading
Keep the console’s identity provider reachable at its startwhen 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 providerUpgrading
Move the keys 0.0.20 removed, and declare each issuer’s attributesbefore 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 oneUpgrading
Move the keys 0.0.19 renamed or movedbefore 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 oneUpgrading
Move the keys 0.0.18 renamed or removedbefore 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-tableUpgrading
Back up FerroFED’s own storewhen [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 migratedFerroFED’s own store
Rotate a node grant’s private keyon 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 itSigning keys and the JWK Set
Re-read this page and the information sheetat each releasethe 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.basedOn and 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 no ehr_id records 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. A required binding 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 each equivalent or equal translation 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 400 target-required by a replica that did not resolve the patient. Send openEHR-federation-endpoint on 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

WhatWhyReference
Two openEHR CDRs that serve ITS-REST 1.1.0, ad hoc AQL includedthe members; each answers AQL scoped to one ehr_idWhat FerroFED runs beside
An identity provider for your callersevery caller presents an RFC 9068 access token; step 4 configures KeycloakClient authentication
A PIX Managerresolves each patient to the ehr_id each member holdsIdentity resolution
An Audit Record Repository with the FHIR Feed of ITI-20takes the audit and access recordsThe audit trail
A Linux host with Docker Compose, or a Kubernetes clusterruns the published imageThe container image
A DNS name and a TLS certificate for the gateway, and nginx or another reverse proxythe public address clients, nodes and identity services reachstep 8
Prometheus, optionalscrapes the admin listenerMetrics

Decide three values before you start, because several settings repeat them:

  • the public base URL, here https://gateway.example.org/fed: its path is server.base_path, and gateway.public_url names it once, from which each issuer’s audience, the JWK Set’s URL and the PMIR feed’s URL follow (The public base URL);
  • the federation id, federation.id, which OPTIONS {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.toml and registry.toml (The gateway from a release). The gateway’s port is published on 127.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, iat and jti, and aud names the issuer’s audience, which is gateway.public_url unless you set it;
  • its scope holds the SMART on openEHR scopes of each operation, such as user/aql-*.s for a federated query (Scopes per route);
  • it declares a purpose of use, as extensions.ihe_iua.purpose_of_use, an array of FHIR Coding, or as RFC 9396 authorization_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. Add client_auth = "required" and a client_ca_file to 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:

RouteWho reaches it
GET and OPTIONS {base}/your clients
{base}/v1/ and belowyour clients; the gateway authenticates each one
{base}/operator/ and belowthe operator console
GET {base}/.well-known/jwks.jsonevery member’s authorization server, and every member that verifies the caller token
POST {base}/pmir/feedthe PMIR Patient Identity Registry alone; name its address in the allow line
{base}/health and belowstopped at the proxy with 403: the dependency report names every member endpoint, so probe the gateway on its own port
every other path404 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-For to the address the request came from, replacing any a client sent, and drops Forwarded, 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 GET form 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):

  • complete is true when every member in scope answered.
  • endpoints has one entry per member. active with a row_count means the member was asked and answered. not-resolved means the PIX Manager holds no ehr_id for the patient there: the member was not asked, and complete is false. node-error, time-out and offline name a member that failed, with the reason in error.
  • The openEHR-federation-endpoint and openEHR-federation-system-id response 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-stopped event is an incident (Hardening).
  • Restarts. On SIGTERM readiness turns 503, the listener stays open for drain_delay, the requests in flight get shutdown_timeout, and the bindings get bindings_drain_timeout to 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 check over 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 openEHR system_id into 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 e
    

    Expect 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_id is reused, across restores, migrations and test-data resets (§12b.2). The evidence is the member’s documented procedures.

  • No foreign ehr_id is adopted. An imported EHR gets a fresh ehr_id (§12b.2).

  • Each ehr_id reaches the PIX Manager in the member’s ehr_id domain, 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 https whenever 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_id for 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 Error code, write the code down for the registry’s consent_refusal_codes (Consent).

  • The member verifies who asks, recommended: it reads the openEHR-federation-client token 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

RoleWhat it doesBinding
Member CDRsanswer standard AQL scoped to one ehr_id, and the reads and writes routed to themopenEHR 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 knowIHE PDQm ITI-78 or ITI-119 (Demographics first)
Identifier cross-referencemaps a patient identifier to each node’s local ehr_id, or reports it not foundIHE PIXm ITI-83 (Identity resolution)
Localization (optional)returns the candidate communities for a patient; without it the gateway asks every member’s cross-referencenone, 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 gatethe Dutch Mitz closed authorization question (Dutch consent)
Addressingresolves each community to its CDR base URLsthe 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 authorizationauthenticates the client, and the gateway to each nodeclient 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-endpoint on every write, with the endpoint_id of 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 redb file 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 first PUT of the same name and version. Run the registry on the postgres backend: 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 files backend instead, each loading the same directory, with no database. A PUT is then refused 405 on 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_url share 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 a callback_url of 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 it key_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-out or not-resolved (§11.1).
  • Consent is enforced by each node before it releases data (N27). A node’s 403 is reported consent-denied, and fails nothing, only when its ITS-REST Error carries a code the registry lists for that endpoint; every other refusal is node-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.

  1. 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/$f downloads the files of one version. The release attaches the gateway’s compose file as gateway-compose.yaml, since its compose.yaml is the self-contained quickstart; saved as compose.yaml, it is the file Compose reads without -f. ferrofed.conf is the nginx configuration of the production guide; Compose does not read it.

  2. Edit registry.toml, the registry document: replace the two example members with your organisations, nodes and endpoints, as many as the federation has.

  3. Edit ferrofed.toml, the gateway configuration: your federation id, your PIX Manager’s URL with each node’s ehr_id domain 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 marked EDIT.

  4. Put each credential, and the gateway’s signing key, in its own file in secrets/, under the name ferrofed.toml gives 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/*
    
  5. 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:

VariableDefaultWhat it sets
FERROFED_VERSIONthe release’s versionthe image tag
FERROFED_BIND_HOST127.0.0.1the host address the gateway is published on
FERROFED_PORT8080the host port
FERROFED_CPUS1the CPU limit
FERROFED_MEMORY256Mthe 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’s runAsNonRoot accepts 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 through FERROFED__SERVER__LISTEN;
  • starts ferrofed serve as PID 1, so SIGTERM reaches 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/readiness and liveness on GET /health/liveness;
  • mounts the ConfigMap at /etc/ferrofed and the ferrofed-secrets Secret at /run/secrets/ferrofed, readable by the gateway’s group (fsGroup 65532, mode 0440);
  • mounts its audit-spool claim at /var/lib/ferrofed for the audit spool, writable through the same fsGroup, which the kubelet applies only when the volume’s root does not already match (fsGroupChangePolicy: OnRootMismatch); the gateway creates each spool under it with mode 0700. The claim asks for the storage class encrypted, a placeholder: replace it with a class of your cluster that encrypts the volume at rest, because the spool names patients. Its 1Gi holds 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 (persistentVolumeClaimRetentionPolicy Retain). 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 that GET {base}/operator/dependencies reads up for the audit repository;
  • runs as the numeric user 65532 with runAsNonRoot, a read-only root filesystem, allowPrivilegeEscalation: false, every capability dropped and the RuntimeDefault seccomp profile;
  • sets resource requests and limits, a starting point to size from your own load, ephemeral storage among them (a 256Mi limit for the container log, since the spool is on its claim);
  • sets server.drain_delay to 5 seconds in the example configuration, so a pod keeps accepting while it leaves the Service’s endpoints, and server.shutdown_timeout to the 30-second request timeout and gateway.bindings_drain_timeout to 5 seconds;
  • holds SIGTERM with a preStop sleep of 5 seconds, matching the drain delay, while the proxies in front stop sending the pod new requests;
  • gives the pod a terminationGracePeriodSeconds of 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).

ServiceWhat it isOn the host
ferrofedthe gateway127.0.0.1:8080
ferroehr-amember node A, FerroEHR with system_id node-a.quickstart.local127.0.0.1:8081/ferroehr/rest/openehr/v1
ferroehr-bmember node B, FerroEHR with system_id node-b.quickstart.local127.0.0.1:8082/ferroehr/rest/openehr/v1
ferroehr-cmember node C, FerroEHR with system_id node-c.quickstart.local127.0.0.1:8083/ferroehr/rest/openehr/v1
ferroehr-dmember node D, FerroEHR with system_id node-d.quickstart.local127.0.0.1:8084/ferroehr/rest/openehr/v1
ferroehr-definitions-a to -done-shots that load the professional PERSON and ROLE templates into each node’s databasenot published
ferroehr-postgresone FerroEHR PostgreSQL server, a database per nodenot published
issuerthe development token issuer of the callers and the nodes, whose network namespace the gateway and the nodes share127.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.1Node ANode BNode CNode D
ffd-test-0001aaaaaaaa-…-000000000001bbbbbbbb-…-000000000001cccccccc-…-000000000001dddddddd-…-000000000001
ffd-test-0002bbbbbbbb-…-000000000002dddddddd-…-000000000002
ffd-test-0003cccccccc-…-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:

WhenGatewayPostgreSQLEach FerroEHR nodeTotal
right after docker compose up --wait (22 s)1.8 MiB145 MiB23 MiB238 MiB
after the seed and the queries on this page2.2 MiB185 MiB54 to 65 MiB425 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:

PartWhat 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

ComponentWhat it adds
consolethe 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-ingressan Ingress for the gateway and for the console, over TLS
gateway-api, console-routea Gateway API HTTPRoute for each, behind a Gateway whose HTTPS listener terminates TLS; use these or the Ingresses
service-monitora 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)
egressan 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

  1. 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.

  2. Keep a copy of the configuration you run now, ferrofed.toml and the registry document, and note the image digest or binary version. A rollback needs both.

  3. Back up the stored-query store when [stored_queries] is set, and FerroFED’s own store when [store] is set: copy the redb file while no gateway has it open, or dump the ferrofed schema 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.

  4. Run the new binary’s config check over the configuration, with the upgrade notes applied. It reads every file and secret as serve does, binds no socket, and exits 78 naming 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 check
    

    A 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 check over 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 a FERROFED__ 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 with mapping = "iua" and authorization_details_purposes = true for every persons, patients or machines issuer whose callers declare a purpose of use. Without it, every such caller is refused purpose-of-use-required. A patients or machines table names no field but purpose_of_use (Authentication, #1615).
  • Move a [[trust.issuer]] professional_issuing_authority = "<claim>" to that table’s fields."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 CKM CLUSTER.organisation.v1 it 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 under docs/specs/ferrobridge-imaging/ (#1860).
  • Run docker compose down -v once before you start the quickstart on this release. Its FerroEHR 5.7.0 nodes need the professional PERSON and ROLE templates 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 persons on each of the node’s endpoints. Once every FerroEHR endpoint has its onward lane, add the node’s persons issuers, 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-smart 0.15 as durations under keys that name no unit: on each [[auth.issuer]] clock_skew, jwks_max_age, jwks_refresh_interval and timeout; introspection.cache_max_age; [auth.dpop] max_age and clock_skew; on each [credentials."<id>".credentials.oauth] refresh_margin, timeout and previous_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_subjects entries as tables with issuer and sub (#1799).
  • Move [trust.issuer.patient] act_is_representative to act_represents_patient = true on the [[auth.issuer]] of kind = "patients". The old key refuses the start, naming its replacement. A patient token that carries an RFC 8693 act claim on an issuer without it is now refused as an invalid token (#1799).
  • In the operator console, write [oidc] timeout and [session] sign_in_timeout, idle_timeout and absolute_timeout as durations, and move [oidc] ca_file to [oidc.tls] trust_roots_file (#1799).
  • Move the [server] keys of ferrohealth-server 0.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_size and [server.tls] reload_interval, as in request_timeout = "30s". The old keys (request_timeout_ms and the others) are refused as unknown keys (#1799).
  • Move [telemetry.otlp] client_identity_file and trust_roots_file into [telemetry.otlp.tls], which also takes min_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_ttl and max_node_answer_size, federation.localization.timeout, gateway.bindings_drain_timeout, signing.node_jwks_cache and rotation_overlap, emergency.window, config.poll and confirm, store.timeout and sweep_interval, fhir.document_url_lifetime, eehrxf.terminology.endpoint.timeout, pdqm.timeout, pmir.timeout and check_interval, nl_gf.mitz.timeout, registry.mcsd.refresh_interval, deadline and max_size, audit.repository.spool_max_size and spool_write_timeout, audit.repository.endpoint.timeout and retry.max_backoff, and xcpd.audit_repository.spool_max_size, spool_write_timeout, connect_timeout, send_timeout and retry_max, with the FERROFED__ overrides to match. Each old key (per_node_timeout_ms, window_s, spool_max_bytes and the others) refuses the start and config check, naming its replacement. The operator console writes gateway.timeout in place of gateway.timeout_ms (#1800).
  • Move the address, timeouts, TLS and credential of [audit.repository] and [eehrxf.terminology] into their new endpoint table, ferrohealth-smart’s outbound table. [audit.repository] keeps hostname, source_id, enterprise_site and the spool_* keys, and [audit.repository.endpoint] takes url, timeout (from timeout_ms), retry.max_backoff (from retry_max_ms, the wait between two delivery rounds) and tls. [eehrxf.terminology.endpoint] takes url, timeout (from timeout_ms), tls and credentials; one call is tried once unless retry.max_attempts says more. Each old key refuses the start and config check, naming its replacement (#1805).
  • Move a node’s user, password, password_file, oauth and onward from [credentials."<id>"] into its nested [credentials."<id>".credentials] table, ferrohealth-smart’s credentials table, with the FERROFED__CREDENTIALS__<ID>__CREDENTIALS__ overrides to match. tls, nuts and onward_organisations stay in the section. Each old key refuses the start and config 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] is ferrohealth-smart’s credentials table, which refuses nuts as an unknown key (#1806).
  • Move the address, TLS and credential of every identity, localization and consent service into its endpoint table, ferrohealth-smart’s outbound table, as [audit.repository] and [eehrxf.terminology] moved: url, tls and credentials of [[pixm.manager]], [pdqm], [pmir], [registry.mcsd], [nl_gf.nvi] and [nl_gf.mitz] go to [<table>.endpoint], and the timeout of [pdqm], [pmir] and [nl_gf.mitz] (from timeout_ms) to endpoint.timeout. Each [[xcpd.gateway]] takes its own [xcpd.gateway.endpoint] with its url, and the shared [xcpd.tls] moves into the tls of each gateway’s endpoint table. A table you leave partly written keeps its defaults: one try per call, 1s for PDQm and Mitz, 5s for PMIR and 10s for the others. The NVI’s Nuts grant stays in [nl_gf.nvi.nuts]. Each old key refuses the start and config check, naming its replacement (#1815).
  • The release asset compose.yaml is now the self-contained quickstart. The gateway-only compose file of the production guide, which carried that name, is attached as gateway-compose.yaml: an operator who downloads it by name fetches gateway-compose.yaml and saves it as compose.yaml beside ferrofed.toml and registry.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 NameID as ehdsi.assertion.name_id_format, one of the three formats eHDSI SAML Profile 10.0.0 §2.1 allows. ferrohealth-smart 0.9.4 refuses a contact-point token without it, as it refuses one without the NameID (Authentication, #1615).
  • Run docker compose down -v once 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 a ferroehr-pgdata volume 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]] of kind = "federation" with sender_constraint = "required", an [[auth.federation]] table with contact_point = true, mapping = "ehdsi", its countries and the constant professional.country_code, professional.issuing_authority_name and provider.issuing_authority_name, and a [[federation.contact_point]] naming the issuer, with correlation_header and additional_countries moved there. Have the contact point’s authorization server write the assertion’s attributes under ehdsi, the TRC patient as ehdsi.patient.id, and bind the token to the connector’s client certificate (National contact point, #1632).
  • With [stored_queries] backend = "postgres", move url or url_file from [stored_queries] to a new [store] table, pointed at the database the backend uses now, and set sslmode=require in the connection string, with ca_file when the database’s CA is not one of the platform’s roots. This release refuses [stored_queries] url and url_file at start-up and in config check, naming each, and a [store] without sslmode=require under 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, naming federation.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 its kind: persons for your professionals, patients for an issuer whose patient/ grants [trust.issuer.patient] binds, machines for systems under the client credentials grant alone, or federation with its [[auth.federation]] table. Move audience, algorithms, clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds, jwks_file, timeout_ms and role_claims from [auth] onto every issuer table they held for, and name the issuer’s profile when it is not a standards-only server (entra, keycloak, okta or nl). The start refuses a section-level key and an issuer without kind, naming each (#1682).
  • On each persons issuer, list your own applications in first_party_clients, and a third-party client or an RFC 8693 actor in delegates; a person’s token through any other client is refused (#1682).
  • Move an introspected issuer from [[trust.issuer]] to its own [[auth.issuer]] with mode = "introspection" and an [auth.issuer.introspection] table: endpoint, and the client authentication in [auth.issuer.introspection.client] (client_id, client_secret or client_secret_file). Keep its [[trust.issuer]] policy, without the introspection keys (#1682).
  • Rename [metrics] scrape_users to scrape_clients and [pmir] feed_users to feed_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 an oauth2 client-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 a levels map from each claim value to low, substantial or high), move [trust.issuer.requester] to the nl.professional_number, nl.role, nl.ura and nl.organisation_type fields of the caller issuer’s [[auth.federation]] table, and remove client_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’s jwks_file: ferrohealth-smart 0.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’s mtls_alias_hosts into it. This release refuses each removed key at start-up and in config check, naming the oauth key that replaces it (Onward credentials, #1749):
    • The grant’s key moves from [signing] to the oauth table’s own private_key_file, with previous_private_key_file and next_private_key_file for a rotation. The gateway publishes it in its JWK Set beside the [signing] keys. Pointing private_key_file at the [signing] key file keeps the kid the node’s authorization server knows; a new key needs its kid registered there, or the JWK Set fetched again, before you switch.
    • grant = "token_exchange" becomes an onward table beside the oauth table, with kind = "persons", the exchanged tokens’ issuer, audiences and indicators naming the node in place of resource, actor_assertion = true, and [onward.keys] max_onward_lifetime. The node’s authorization server must issue a JWT access token that names that issuer, an aud among audiences, and the gateway as act or client; an opaque exchanged token now fails the node.
    • A fapi2 table becomes an oauth table with security_profile = "fapi2", client_auth = "private_key_jwt", assertion_audience = "issuer", issuer, and client_key_file and previous_client_key_file renamed private_key_file and previous_private_key_file.
    • tls_client_certificate_bound_access_tokens = true becomes certificate_bound = true. A grant that authenticates by, or binds its tokens to, the TLS client certificate presents the node section’s tls identity; client_certificate_file, client_key_file and ca_file in its oauth table are refused.
  • Give every node’s oauth grant client_auth = "private_key_jwt", "tls_client_auth" or "self_signed_tls_client_auth". A node’s grant that names client_secret_basic, client_secret_post, client_secret or client_secret_file, or leaves client_auth unset, 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_s must be at least 300 plus signing.node_jwks_cache_s (#1749).
  • Move each table’s outbound TLS keys, client_identity, client_identity_file and trust_roots_file, into its tls sub-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 in config check, naming the tls key 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 tls sub-table lowers it with min_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_file and trust_roots_file of [xcpd.audit_repository] into its tls sub-table, [xcpd.audit_repository.tls], as every other outbound table took them with #1750; the sub-table also takes min_version, "1.3" unless lowered to "1.2". This release refuses a key left on the table at start-up and in config check, naming the tls key that replaces it (The audit repository, #1766).

To v0.0.16, from v0.0.15

  • Write server.public_url, server.replica and server.bindings_drain_timeout_ms as gateway.public_url, gateway.replica and gateway.bindings_drain_timeout_ms, and server.overload_retry_after_s as server.retry_after_s, with the FERROFED__ overrides to match. This release refuses each old key at start-up and in config check, naming it, as it refuses any unknown key (#1490).
  • Remove [server] site_root and its FERROFED_CONSOLE__SERVER__SITE_ROOT override from the operator console’s configuration: the console serves no site bundle, and this release refuses the key at start-up and in config check, naming it (#1512).
  • Nothing in the operator console’s sign-in configuration has to change (#1504). The [oidc] endpoints are now optional: remove authorization_endpoint, token_endpoint and jwks_uri to have the console read them from issuer’s discovery document when it starts, which then needs the provider reachable at start. A reverse proxy in front of the console passes /session through to it.

To v0.0.15, from v0.0.14

  • Move server.public_url, server.replica and server.bindings_drain_timeout_ms to a new [gateway] table, and rename server.overload_retry_after_s to server.retry_after_s. That release still read each old key and named it in config check and the start-up log; FerroFED 0.0.16 refuses it. The FERROFED__ overrides follow, as FERROFED__GATEWAY__PUBLIC_URL (#1408).
  • Remove base_path = "/", or write base_path = "": the root is the empty path, and a / is refused. Give [server.caller_rate] both requests_per_second and burst, which no longer default (#1408).
  • Move the OTLP keys into one [telemetry.otlp] table: endpoint, sample_ratio, trust_roots_file, client_identity_file, and traces and metrics, both on unless set. The old [telemetry] and [metrics] OTLP keys and [telemetry] logged_query_parameters are refused as unknown, and an inline client_identity is no longer read (#1397).
  • A [server.tls] or [metrics.tls] table with client_ca_file needs client_auth = "required" beside it to keep requiring a client certificate. A listener whose clients speak only TLS 1.2 needs min_version = "1.2" (#1397).
  • Point a liveness probe at {base}/health/liveness. {base}/health follows readiness now and answers 503 while the gateway boots or drains, and {base}/health/readiness lists indicators as an array. The example Kubernetes manifests probe the new routes (#1397, #1445).
  • A probe, log rule or alert that matched the ready or not ready line of ferrofed healthcheck matches healthy and unhealthy instead (#1453).
  • A client that read message from an error body outside the FHIR face reads detail of the RFC 9457 problem details document; code and request_id stay (#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::panic and its file and line fields (#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 the binding-nl feature 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}/MedicationDispense must carry its own business identifier, an identifier with a system and a value; one without it is refused 422 required, naming MedicationDispense.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. Set auth.basic.cache_ttl_seconds = 0 to check every request, as before (#1390).
  • An access record writes its security labels once, on its ehds-categories and origin entities, and no meta.security. A repository rule or search that filtered on _security reads the entities’ securityLabel instead (#1390).
  • The quickstart runs FerroPIX beside the gateway, and its nodes run FerroEHR 5.5.0. If you edited compose.yaml or the quickstart’s FerroPIX file, carry their renames over as the changelog lists them, and run docker compose down -v once before the first start (#1377, #1386).

To v0.0.13, from v0.0.12

  • The Kubernetes example moved to deploy/kubernetes/base/: apply it with kubectl apply -k deploy/kubernetes/base in place of kubectl apply -f deploy/kubernetes/. Its NetworkPolicy now admits only your reverse proxy’s namespace (ingress-nginx until 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’s terminationGracePeriodSeconds is 50 and its governing Service is the new ferrofed-admin. Kubernetes refuses a change of a StatefulSet’s serviceName, so run kubectl delete statefulset ferrofed --cascade=orphan before you apply; the pods and their audit spool claims stay, and the new StatefulSet adopts them (#638).
  • Each [[fhir.receive]] names, in dependencies, every package of its package’s dependency closure (each a .tgz or 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 by config check, naming the missing package. For the HL7 Europe Patient Summary, the book’s receiving page lists the packages (#808).
  • The quickstart compose.yaml now sends the usage report by default, as the release’s compose file has since v0.0.12, with its instance id on the new ferrofed-state volume. Set FERROFED__USAGE_REPORT__ENABLED=false, and FERROEHR__USAGE_REPORT__ENABLED=false for the four nodes, in your shell or .env to keep it off (#1296).
  • A bookmark of the operator console’s /configuration now 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. Set FERROFED__USAGE_REPORT__ENABLED=false before you upgrade to keep it off (#1122).
  • [auth] is now the ferrohealth-smart section 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’s introspection_endpoint, client_id and client_secret_file) to a [[trust.issuer]] entry naming the same issuer, and auth.mode, [auth.edge] and [auth.purpose_of_use] to trust.mode, [trust.edge] and [trust.purpose_of_use]. Drop jwks_uri from each issuer: the key set is read from the issuer’s RFC 8414 metadata, or from jwks_file. Replace each issuer’s operator_scope with the one auth.product_scope, a string starting with __ or a full URI you control. auth.audience is a list, and the clock skew, key set and timeout keys are clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds and timeout_ms.
  • Every static bearer token is gone (#1163). Replace bearer_token and bearer_token_file in a [credentials."<endpoint>"] section or a service’s credentials with user and password_file, or with an oauth table, the client credentials grant of ferrohealth-smart; Mitz, the NVI and the terminology server take user and password_file. Replace [metrics] scrape_token_file with scrape_users, naming users of [[auth.basic.users]] whose Argon2id password_hash_file you write, and give Prometheus basic_auth. Replace [pmir] feed_token_file with feed_users, naming the Registry’s own [[auth.basic.users]] user, and give the Registry its password.
  • An oauth2 section that asks for a plain client-credentials token, with no token exchange, no DPoP key, no binding to or authentication by the TLS client certificate and an assertion naming the token endpoint, is refused: move it to an oauth table, whose private_key_jwt signs with its own private_key_file.
  • A log filter that names ihe_iti::atna (the audit spool and forwarder) names ihe_atna::atna instead; the events are the same.
  • The operator console is now ferrofed-console (#1219). Pull ghcr.io/ferrohealth/ferrofed-console in place of ghcr.io/ferrohealth/ferrofed-viewer, rename every FERROFED_VIEWER__<SECTION>__<KEY> override to FERROFED_CONSOLE__<SECTION>__<KEY> and FERROFED_VIEWER_CONFIG to FERROFED_CONSOLE_CONFIG, and run the binary as ferrofed-console. The session and theme cookies are renamed, so every operator signs in once more after the upgrade.
  • If you edited the ferroehr-config block of the quickstart’s compose.yaml, FerroEHR 5.3.2 refuses the 4.3.9 Basic user keys at boot: rename username to user, give each user its scopes (the quickstart grants user/composition-*.cruds, user/template-*.cruds, user/aql-*.cruds and __ferroehr.platform), and move each user’s roles to [authz.rbac.basic_roles] keyed by the user (#1226).
  • Bookmarks of the operator console’s /members, /routing and /federation pages answer 404: 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_motion and the others); the theme cookie is ferrofed_console_theme in place of 0.0.11’s ferrofed_viewer_theme, so each operator chooses the theme once more. A deployment that builds the site bundle itself runs cargo leptos build --split, as scripts/release/console-site.sh does.

To v0.0.11, from v0.0.10

  • If the operator console’s [oidc] scopes lists offline_access, remove it from the list and set [oidc] offline_access = true instead (#643). The console now refuses the scope in the list, and the new setting asks for it with prompt=consent so 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’s category from the template: its assumed value, its one admitted code, or event when the template leaves the category open. A template that admits several COMPOSITION.category codes and assumes none now refuses every received document of its category with a 422 (MissingRequiredSuffix on <root>/category|code), where event was taken before. Give the template’s category an assumed value (AOM 1.4 assumed_value) and upload it to the member again.
  • ferrofed_node_requests_total carries a new label, class: member for a member’s own answer or failure, client-error for a 4xx it answered a routed request with (#982). Such a 4xx now counts as outcome="node-error", where it was active. The shipped FerroFEDMemberFailing rule leaves class="client-error" out, so it pages on the same member failures as before. A rule or a panel of your own that reads outcome="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, with spool_dir on durable storage, before you upgrade.
  • Set server.shutdown_timeout_ms to at least server.request_timeout_ms, or remove it so it takes the request timeout (#625). config check and serve now 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 than server.drain_delay_ms plus the drain: the examples use stop_grace_period: 40s in Compose and terminationGracePeriodSeconds: 45 in Kubernetes.
  • If you run the Kubernetes example, move from deployment.yaml to statefulset.yaml (#628). Replace the encrypted storage class with your own. Wait until the dependency report, GET {base}/operator/dependencies with an operator token (#751), reads up for the audit repository, so the spool on the old emptyDir is 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] listen names an address other than a loopback one outside profile = "development", set [metrics] scrape_token_file to a file holding a scrape token and give your Prometheus the same token with authorization.credentials_file, or set [metrics.tls] client_ca_file (#635). Without either, serve and config check refuse the configuration. The Kubernetes example reads the token from the metrics-scrape-token key of the ferrofed-secrets Secret.
  • To run the admin listener’s stored-query distribution outside the development profile, from the gateway’s host or another, set operator_scope on the [[auth.issuer]] your operators use and send a token carrying it (#629, #635). Without one it answers 401 unauthenticated, from a loopback peer as from any other, where a loopback peer was served before.
  • If a backend client reads patient data through the gateway with a system/ scope or a token whose sub is its client_id, set client_tokens_act_for_professional = true on its [[auth.issuer]] and have its tokens name the professional it acts for in extensions.ihe_iua.national_provider_identifier (#661). Until both hold, such a request is answered 401 natural-person-required. Then set [auth.issuer.assurance] on each issuer whose tokens reach patient data; without it no assurance level is required, and config check notes the issuer.
  • A document received on the FHIR face whose codings carry no display is now refused with a 422 unless [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/dependencies with a token that carries your issuer’s operator_scope (#751). GET {base}/health/dependencies is no longer served and answers 404, so move every monitor that read it, and give it an operator token. ferrofed report reads the report with --operator-token-file. A proxy rule that kept /health/dependencies inside can go; the health family left open, {base}/health and {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: a redb or PostgreSQL store, or a definition file, holding one there refuses the start, and config check refuses such a file.
  • If you wrote an [access_log] map on a development build, rewrite its categories with the codes of HL7 Europe’s EEHRxFDocumentPriorityCategoryCS (#824), which are case-sensitive: patient-summary becomes Patient-Summaries, electronic-prescription Electronic-Prescriptions, electronic-dispensation Electronic-Dispensations, medical-imaging Medical-Imaging, medical-test-result Laboratory-Reports and discharge-report Discharge-Reports, in [access_log.templates], [access_log.archetypes] and the keys of [access_log.retention.categories]. Declare each national category in national_categories as <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 the ehds-category detail <system>|<code>, never the bare code.
  • The registry document refuses an [[organisation]] that manages no [[endpoint]] (§8.1: “an Organization manages 1..* Endpoints”), so config check and 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 answers 404 with no-destination.

To v0.0.9, from v0.0.8

  • If you run the operator console with [session] secure_cookie = false, either set it to true or make [oidc] redirect_uri an http URL 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-features needs --features binding-ihe or binding-nl for the sections it uses, or refuses them as unknown keys (#489).
  • The configuration resolved log line no longer carries the registry directory, PIX Manager, PMIR and PDQm fields; they are on the IHE binding’s own binding configured line (#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]] and auth.audience), and every request to the ITS-REST surface and OPTIONS {base}/ needs an access token from that issuer with a SMART on openEHR scope and a purpose of use, unless auth.purpose_of_use.required = false (#80; Client authentication). Issue tokens to your clients before you upgrade.
  • [signing] is mandatory whenever a registry is configured, by registry.document or [registry.mcsd]: add a P-384 or P-256 signing key and the jwks_uri it is published at, and make {base}/.well-known/jwks.json reachable from the nodes’ authorization servers (What a node is told about the caller).
  • [audit] destination is 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] audit is mandatory: "log", "repository", or "off" under the development profile alone (#410). With "repository", [xcpd.audit_repository] spool_dir is mandatory outside development and [xcpd] home_community is mandatory.
  • The ADMIN API under {base}/v1/admin/ answers 403 operation-refused, where it answered 501.

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

WhatWhereWhat happens to it
Resolution bindings, the ehr_id index, the learned creating_system_id routeseach replica’s memorylost when the replica stops; export the routes first to move them to a replacement
Stored-query definitionsthe [stored_queries] backend: a redb file, the ferrofed schema of FerroFED’s own PostgreSQL store ([store]), or read-only definition filesparameterised 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_direvery 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 deliveredthe spools under audit.repository.spool_dir and xcpd.audit_repository.spool_dir, with their quarantine subdirectoriesname patients and callers; delivered while a replica runs, then removed with the volume
Access and audit records deliveredyour Audit Record Repositorykept for their retention period, never removed by decommissioning the gateway
Log lines, metrics and spansyour log, metrics and trace storeskept as those stores keep them
The configuration, the registry document and the secretsthe files and the environment you give the gatewayyou remove them, after the keys are retired
Operator console sessionsthe console’s memoryend 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_id index are not exported: a replacement learns them again.
  • The stored queries. Copy the redb file while no gateway has it open, or dump the schema with pg_dump --schema=ferrofed, as before an upgrade (Before you upgrade).
  • The configuration. Keep ferrofed.toml and the registry document. Under config.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).

  1. Stop sending the gateway client traffic, so no new record is written.
  2. Read GET {base}/operator/dependencies on each replica until each of audit_repository and audit_feed that it lists reads up. Each reads degraded while a record waits in its spool or sits in its quarantine (The dependency report).
  3. A record in quarantine was 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).

  1. Once the last replica has stopped, no token is signed. A token it signed lives at most 300 s. Where jwks_uri points somewhere other than the gateway, remove the gateway’s keys there once that time has passed.
  2. Ask each member to remove what it recorded when it admitted the gateway: the gateway’s jwks_uri and the iss its tokens carry (Verifying it at the node). A member that still trusts the keys would accept a token signed with a copy of them.
  3. Ask each party that issued the gateway a credential to revoke it: the client each member’s authorization server registered for an oauth grant, 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).
  4. Destroy the private keys and the secret files: every [signing] key file, each client_identity_file, each private_key_file, previous_private_key_file, next_private_key_file and dpop_key_file, and the files under config.secrets_dir.

6. Remove the stores and the volumes

  • The stored-query store, FerroFED’s own store and the revision store. Delete the redb files. FerroFED’s own store ([store]) and the PostgreSQL revision backend keep their tables in the ferrofed schema; 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 sectionWhat it setsPage
profileproduction, the default, or development, which relaxes the transport rules belowWhat must travel encrypted
[server], [telemetry], [credentials], [signing]the listener, the console, the onward credentials, the signing keysthis page
server.max_concurrent_requests, [server.caller_rate], federation.max_in_flight_per_node, federation.max_node_answer_sizethe overload limits and the answer boundOverload protection
federation.offset_strategy, federation.max_offset_window, federation.max_node_rowspaging 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 collectorTracing, Metrics
[metrics], [config]the admin listener; config.ui, off by default, and the store of the configuration revisions the admin listener serves under managedMetrics, 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 documentThe registry, A member’s own exchange-format endpoint
[pixm]the cross-reference; a [dev] table is refusedIdentity resolution
[pdqm]the PDQm Supplier asked for the master identity of an identifier the cross-reference does not mapDemographics first
[xcpd]the XCPD localizer and its audit repositoryXCPD localization, The audit repository
[nl_gf.nvi]the NVI localizer of the Dutch Generic FunctionsDutch localization
[nl_gf.mitz]the Mitz consent pre-filter of the Dutch Generic FunctionsDutch consent
[pmir]the PMIR identity feed: the subscription and the route the Registry sends toThe 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 facilitiesThe registry, Queries and API areas
[stored_queries]the stored-query registry and its backendQueries 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 boundsFerroFED’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 developmentThe 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 collectorThe 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:

FeatureBindingSections
always builtthe access log, no binding[access_log], [audit]
binding-iheIHE, Annex A[pixm], [pdqm], [xcpd], [registry.mcsd], [pmir], and registry.format = "fhir", which reads the document with the mCSD reader
binding-nlDutch 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"
  • url is 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.
  • timeout bounds one call. Each table keeps its own default, so a key you leave out keeps the value the service had before it moved under endpoint: 1s for [pdqm] and [nl_gf.mitz], 5s for [pmir], and 10s for the others.
  • retry tries a call again when the service could not be reached, the try timed out, or it answered 5xx. Each identity, localization and consent table tries a call once unless you raise max_attempts, so a failure fails the step at once within the query’s budget.
  • tls is the TLS material of Mutual TLS to the identity services.
  • credentials takes a user and a password, or the oauth client-credentials grant authenticated by a client secret or a client assertion (Identity resolution), never a nuts grant. Each grant a node’s section can name is described in Onward credentials. Mitz takes the same: a user and a password, or the oauth grant, 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 refuses oauth; the NVI’s nuts grant 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 its client_identity_file sibling, 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_file names a PEM bundle of trust roots the service’s certificate chains to, added to the platform’s roots.
  • min_version is 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 its oauth grant obtains;
  • the token_endpoint of an oauth section, which receives the client secret or the client assertion;
  • the authorization_server of a nuts section, which receives the gateway’s credentials in a Verifiable Presentation;
  • the issuer of a node’s oauth section, whose token endpoint receives the client assertion and, under an onward table, the callers’ tokens;
  • the endpoint.url of every PIX Manager, which is asked for patient identifiers with or without [pixm.manager.endpoint.credentials];
  • the endpoint.url of [pdqm], the PDQm Supplier, which is sent the patient identifier with or without [pdqm.endpoint.credentials];
  • the endpoint.url of every XCPD responding gateway, which is sent the patient identifier and, when one is configured, the XUA assertion;
  • the endpoint.url of [nl_gf.nvi], which is sent the pseudonymised BSN and, when one is configured, its credentials;
  • telemetry.otlp.endpoint when it carries a user name or a password; an https:// 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 /.

RouteAnswers
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/liveness200 while the process is up
GET {base}/healththe status readiness answers, with the state alone
GET {base}/health/readiness200 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/dependenciesto 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/deploymentto 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/aqlthe 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 belowrouted 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 belowrouted 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 below501, 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 path404

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). Set client_auth = "required" with a client_ca_file to 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_file holds the chain the listener presents, its own certificate first, and key_file its private key, both PEM.
  • client_auth says whether the listener asks a client certificate: none, the default, asks none; optional verifies one a client presents; required refuses a client that presents none.
  • client_ca_file holds the CA certificates a client certificate must chain to. It is required with optional or required, and refused with none, where it would do nothing (a client sends a certificate only when the listener asks for one, RFC 8446 §4.3.2).
  • min_version is 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, beside SIGHUP and an applied configuration revision.
  • healthcheck_identity_file, [server.tls] only, holds a client certificate chain and its key, PEM, which ferrofed healthcheck presents 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:

KeyValue
auth.issuer[].audiencegateway.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_proxies lists 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_header is the header the proxies write: forwarded, the default, reads the for= parameters of Forwarded (RFC 7239); x-forwarded-for reads the comma-separated list of X-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:

  • Accept is 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 no Accept at 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/json in every EHR operation. A range with a parameter other than q or charset=utf-8 covers nothing. An Accept that admits no listed type is a 406 (media-type-not-acceptable), as a node would answer it.
  • Content-Type is read as a media type (RFC 9110 §8.3). When its type and subtype are a listed value, the node receives that value. A charset=utf-8 is 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 a 415 (media-type-unsupported). The listed values are the media types the operation’s request body is declared in (openehr-its’s request_media), which are the values of its Content-Type parameter where it declares one; the versioned stored-query PUT declares no parameter and takes text/plain. A body never travels without a Content-Type: when the client sends none, the node receives the first media type the operation lists. ITS-REST makes Content-Type optional and declares no default, and RFC 9110 §8.3 names none, so the first listed is the gateway’s own choice, as for Accept. Wherever an ITS-REST operation lists several, application/json, the canonical JSON, comes first.
  • Prefer is 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-its table states for it: a version_uid, and the uid_based_id of a delete, an OBJECT_VERSION_ID; any other text uid_based_id an OBJECT_VERSION_ID or a HIER_OBJECT_ID; and a path parameter the table states as a UUID, such as versioned_object_uid or the uid_based_id of a composition update, a UUID in its canonical hyphenated form, each parsed by openehr-base (a composition delete whose path names no OBJECT_VERSION_ID is preceding-version-invalid instead, 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). The ehr_id is 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 the openehr-base parser;
  • 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-rest endpoint with no ehr_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-REST Error carries;
  • an openehr-rest-query endpoint that sets categories, profiles or ehr_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-mpd or MedicationDispense-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 reloaded and counts as an applied reload.
  • When it breaks a rule, it is refused. The running registry stays, the gateway logs registry reload refused with the class of the reload classes, and the refusal counts as a refused reload. A content that breaks a registry rule (an hl7-fhir-rest endpoint without the keys of a native endpoint, a system_id given to two nodes, a registry left with no node) is registry-invalid. A sound registry that the rest of the configuration no longer fits is refused under the class of that part: dev-cross-reference or pixm for a resolver row naming a member the refresh removed, localization, demographic-endpoint, credentials for a [credentials] section naming a removed endpoint, node-clients, http-client, self-description, signing or federation. 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_bytes or max_entries, the refresh is refused the same way with class = "registry-budget"; an answer that does not hold to ITI-91 is refused with class = "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:

ReloadedNeeds 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 againauth.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_id route the new document maps to another node is withdrawn and raises a RegisteredCreatingSystemConflict incident (see Integrity incidents), and stays withdrawn;
  • every ehr_id index 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 refused 400 (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:

classThe fault
configurationthe configuration file does not read or resolve
registry-unreadablethe registry document, or the directory, cannot be read
registry-invalidthe registry document, or the directory’s content, breaks a registry rule
registry-directorythe directory of [registry.mcsd] cannot be asked as configured
registry-budgeta refresh of the directory ran past its deadline or a cap
credentialsa [credentials] section names an endpoint the document does not declare
demographic-endpointfederation.demographic_endpoint names an endpoint the new document does not declare
dev-cross-reference, pixm, resolversthe resolver refuses the new members, or both resolvers are set
localizationthe localizer refuses the new members, or the node selection has none
node-clients, http-client, self-descriptionthe node clients or the OPTIONS {base}/ body cannot be built
federationa registry is configured without federation.node_selection or federation.id
signinga registry is configured without [signing]
registry-presenceregistry.document or [registry.mcsd] was set, unset or swapped for the other, which takes a restart
profileprofile was changed, which takes a restart
cleartexta credential or a patient identifier would travel over a URL that is not https, outside the development profile the process started with
auththe gateway federates and [auth] trusts no issuer
listener-certificatethe 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:

kindWhenFields
EhrIdCollisionA 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)
IndexInsertCollisionThe 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)
PrescriptionIdentifierCollisionA 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)
LearnedCreatingSystemConflictA 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
RegisteredCreatingSystemConflictA 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_id from the ehr_id index and from the resolution bindings of every caller. The request takes the node-local ehr_id from the claimants incident and nothing else: no body and no patient identifier.

  • The next read of the ehr_id that names no node asks every member again. One member holding it is routed to; two or more are refused 409 (ehr-id-collision) again, with a new EhrIdCollision incident (detection ask-all). A write is never probed for, so one that names no node answers 400 (target-required) until a read or a resolution teaches the gateway its owner (§12.5.1, N41).

  • It answers 200 with 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"
    }
    

    held is collision (two members or more), owner (one) or nothing; claimants are member node ids; indexed says whether the index held an entry; sessions counts the callers whose binding was dropped. ehr_id is null for an ehr_id that is no bare UUID.

  • It is refused 400 for a path value that is no HIER_OBJECT_ID (ehr-id-invalid) or a body (body-invalid), 401 and 403 as every admin write action is, and 501 when the gateway runs without a registry. Where [metrics] listen is unset, the action does not exist.

  • scope is replica: 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 INFO under ferrofed::integrity, and a cleared collision is listed under cleared in GET {base}/operator/incidents beside 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 elementRegistry fact
Organization.identifier with system https://ferrofed.eu/fhir/sid/organisation-idthe organisation id, exactly one
Organization.namethe organisation’s display name
Organization.endpointthe endpoints whose node the organisation operates
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/endpoint-idthe stable endpoint id used in directives (N19), exactly one
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/node-idthe node the endpoint belongs to, exactly one
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/system-idthat node’s openEHR system_id, exactly one
Endpoint.identifier with system https://ferrofed.eu/fhir/sid/creating-system-ideach further creating_system_id the endpoint answers for (N21), zero or more
Endpoint.connectionTypeopenehr-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.managingOrganizationthe one managing organisation (N20)
Endpoint.statusactive, or suspended for an endpoint taken out of service
Endpoint.addressthe ITS-REST base URL
Endpoint.extension with url https://ferrofed.eu/fhir/StructureDefinition/consent-refusal-codeone 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 elementNative form key
Endpoint.payloadType.coding with system http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cscategories: 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-profileprofiles: one valueCanonical per profile its documents follow, one or more
Endpoint.extension with url https://ferrofed.eu/fhir/StructureDefinition/ehr-id-systemehr_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 connectionType carries no system (an informal string), or is a system and code other than openehr-rest-query in FerroFED’s system and hl7-fhir-rest in 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 no Organization of 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_id or its operator;
  • an endpoint’s status is neither active nor suspended, 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 a creating_system_id that 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:

URLDefinition
…/StructureDefinition/consent-refusal-codean extension of Endpoint, zero or more, with a valueCode
…/StructureDefinition/payload-profilean extension of Endpoint, zero or more, with a valueCanonical
…/StructureDefinition/ehr-id-systeman extension of Endpoint, at most once, with a valueUri
…/CodeSystem/connection-typea 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-ida 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 in e/ehr_status/subject/external_ref/namespace;
  • an ENTRY-level subject, …/subject/identifiers/id = '…', with the namespace in …/subject/identifiers/issuer or …/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. Its url is the Manager’s FHIR base, https, with no query or fragment. The Manager is asked for patient identifiers, so http is refused naming pixm.manager[N].endpoint.url, with or without credentials, unless the profile is development (What must travel encrypted). A user name or password in it refuses the configuration, naming pixm.manager[N].endpoint.url and never quoting it; the credentials go in [pixm.manager.endpoint.credentials], with the keys of an endpoint’s credentials: user and password, the password with its _file sibling, or an oauth grant (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.
  • method is "get", the default, or "post", which keeps the patient identifier out of the request URL (GET or POST).

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 memberThe member isThe query
exactly one identifier in the domain, which reads as an ehr_idsent its node querygoes on
no identifier in the domain, or the patient is unknownnot-resolvedgoes 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 mappingnot-resolved, with the reason in errorfails 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_auth says 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); or private_key_jwt, a client assertion signed by the key in private_key_file (RFC 7523 §2.2), whose public half the gateway publishes in its JWK Set, with no secret.
  • client_secret or client_secret_file holds the secret. It is required with the two secret methods and refused beside private_key_jwt.
  • scope is required here, each value an RFC 6749 §3.3 scope token.
  • token_endpoint is https outside the development profile: the client secret or assertion travels to it. One discovered from issuer is always https.
  • The oauth2 and fapi2 tables of earlier releases are refused here, naming the oauth table that replaces them, and so is an onward table, which only a node takes (Onward credentials). config check names 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_id as an identifier whose system is the member’s ehr_id domain, 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, StructureDefinition IHE.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.

  1. 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.

  2. 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.1 and 1.3.6.1.4.1.33349.3.1.5.9.2.1.0.0.1.0, and set SDB_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 from 000-OAuthPolicies.dataset after its initialisation script has granted the built-in applications every policy that existed then, so no application holds them out of the box.

  3. Register the identity domains: the patient namespace, open to every source, and one per member, with url set 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’s url as the identifier system in its answers, so it must equal the value in [pixm.manager.members].

  4. Feed each member’s EHRs as its feed application, over ITI-93 to POST {origin}/fhir/Bundle, live and as the bulk load above.

  5. 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/amd64 only 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_auth is client_secret_post.
  • What was verified. Resolution by GET, which is method’s default, and the feed over ITI-93. ITI-83 by POST and 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 fails 424.

Products checked that do not answer as the gateway needs

ProductWhat it isWhy it is not a Manager for FerroFED
HAPI FHIR JPA servera FHIR server with a master data management moduleits MDM operations do not include $ihe-pix (HAPI FHIR: MDM Operations)
OpenCR (IntraHealth client registry)a client registry on a FHIR serverits $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 Managera simulator that plays IHE actors, the PIXm Manager among them, for testinga test tool, not a production MPI (Gazelle Patient Manager)
IPFa Java integration librarya component to build a Manager with, not a server (IPF: ITI-83)
OpenHIMan interoperability layerit 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_id domains, 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 to ehr_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> and identifier=<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 and onlyCertainMatches set (§2:3.119.4.1.2); declare it only where the Supplier offers $match.
  • master is 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 the http rule are those of a PIX Manager: the Supplier is sent the patient identifier, so plain http is refused, naming pdqm.endpoint.url, unless the profile is development. No redirect is followed, and an answer is read up to 8 MiB.
  • A SIGHUP reload applies a change to [pdqm], as it does to [pixm]: the reloaded federation asks the Supplier the new table names.
The Supplier answersThe members areThe query
one active Patient carrying exactly one identifier in the master domain (for ITI-119, graded certain)localized and resolved by the master identifiergoes 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-resolvedgoes on; complete is false (N6)
more than one Patient, a Patient with two master identifiers, or an ITI-119 match not graded certainnot-resolved, with the reason in errorfails 424 under all-or-nothing
a failure, or no answer within timeout_msnot-localized with the error under on_failure = "closed"; not-resolved under "ask-all" and on a directed query200 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 Subscription at endpoint.url with a message channel to callback_url and a FHIR JSON payload. Its criteria is Patient, or Patient?identifier=<system>| with identifier_system set, 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 answers 404 or 410 found the subscription already gone. A Registry that answers that search 400 or 404 does not support it, and the gateway creates. Every check_interval the gateway reads the subscription back. It deletes and recreates one the Registry reports error or off, and recreates one the Registry no longer holds. Each failed check doubles the wait before the next, up to 32 times check_interval, and a check that succeeds resets it. If the Registry answers a create 201 with no Location, or with one outside endpoint.url, the gateway cannot manage that subscription. It creates no other until a restart and reports the fault as unmanageable; delete that subscription at the Registry. On a drain the gateway stops checking and lets the check in flight end. With on_drain = "keep", the default, it leaves the subscription at the Registry, for the other replicas and for its next start, which adopts it. With on_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 its client_id in feed_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 answered 401 with a Bearer challenge, one of another client or of a person 403, 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, /health or /.well-known. A message that does not hold to the PMIR profiles is answered 400 (415 for a media type other than FHIR JSON) with an OperationOutcome, 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’s ehr_id domain, 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 such ehr_id. A create drops nothing. A dropped binding costs one resolution, never a wrong node. The ehr_id index 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_url for 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. Keep on_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 lives federation.binding_ttl after 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. Lower binding_ttl to narrow the window.
  • A callback_url of 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. Set on_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 same callback_url and 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’s url names. It takes no credentials: 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 (or client_identity_file) holds the PEM client certificate chain and private key for mutual TLS, trust_roots_file adds the network’s roots to the platform’s, and min_version is the oldest TLS version the gateway speaks, "1.3" unless you lower it to "1.2". A plain http URL is refused at boot, naming its key, unless the configuration is profile = "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 (or assertion_file) must hold exactly one saml2:Assertion element that declares every namespace prefix it uses; anything else is refused at boot. An assertion expires: replace the file before its NotOnOrAfter and reload.
  • An audit destination. The Initiating Gateway records an audit message for every exchange (ITI TF-2 §3.55.5.1.1), so audit has 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 target ferrofed::audit: the event, its outcome (0 success, 4 the gateway answered with a failure, 8 no answer), this process’s id, the responding gateway’s endpoint and host, and the homeCommunityId the 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 outside profile = "development". OPTIONS {base}/ declares the choice as localization.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. assertion and assertion_file hold 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 is sender-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 https in every profile, development included, and its [xcpd.gateway.endpoint.tls] must name client_identity_file and trust_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. The custodians table 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. Its url must be https outside profile = "development"; a plain http URL 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 of endpoint.credentials or with the Nuts grant of GF-Authentication in [nl_gf.nvi.nuts] (The NVI’s Nuts grant), one of them; an oauth table 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 which ehr_id each 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 its ehds-emergency-access entity (Emergency access), whatever the node answered; a request whose record cannot be stored is refused 503 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 no ehr_id route 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.

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 as urn:oid:2.16.840.1.113883.2.4.6.3 or dotted) or in one namespaces lists. 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 as not-asked with the reason namespace (Metrics). The pseudonym’s system is never accepted in namespaces. 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 its ura, [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-smart under the federation issuer’s [[auth.federation]] table, whose nl.professional_number, nl.role, nl.ura and nl.organisation_type fields 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 a persons issuer 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 as not-asked with the reason caller-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 reason caller-claims-invalid, and so is a patient value the question does not take as a BSN, with patient-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. Its url must be https outside profile = "development". credentials takes basic credentials or the OAuth 2.0 client credentials grant of [nl_gf.mitz.endpoint.credentials.oauth] (RFC 6749 §4.4), never a nuts grant. The gateway obtains the token at the token endpoint, which must be https outside profile = "development", caches it while it is fresh, and sends it with each question as a bearer token (RFC 6750 §2.1). A question Mitz answers 401 is 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 same error text 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-resolved clears meta.federation.complete and fails nothing, as consent-denied does (§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-localized would leave complete true, 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 replaces no-destination for a subject with no EHR the gateway may reach. A 404 covers 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 answers consent-denied there.
  • Nothing a client sees names consent: no status, error, header or error body. OPTIONS {base}/ declares the choice as federation.consent.disclose: false, so a client knows a not-resolved member 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 in meta.federation.consent.error and on GET {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 same error text as a member that does not know the patient and no latency_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-unavailable and 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 answers 404 subject-unavailable, still naming the endpoint the request was routed to. So that this answer never stands for a refusal alone, a node’s own 404 on 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 is 404 subject-unavailable, which in this deployment also replaces no-destination for an ehr_id no 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] audit and [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 FHIR AuditEvent built 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 FHIR create at the repository’s FHIR base (the RESTful ATNA supplement, ITI TF-2 §3.20.4.2).

What each transaction records

TransactionWhenRecordPatient namedSection
ITI-55 Cross Gateway Patient Discoveryeach discovery the XCPD localizer makesthe Initiating Gateway’s DICOM audit message, over sysloginside the base64 query parameters[xcpd]
ITI-83 Mobile Patient Identifier Cross-reference Queryeach query the PIXm resolver or localizer asks a PIX ManagerPIXm Query Consumer audit (BALP Patient Query): the request as sent, base64; for method = "post", its request line, media type and Parameters bodythe source identifier, system and value[audit]
ITI-78 Mobile Patient Demographics Queryeach search the [pdqm] step sends the PDQm SupplierPDQm Query Consumer audit (BALP Query): the request as sent, base64inside the base64 request; a demographics search identifies no patient on its own[audit]
ITI-119 Patient Demographics Matcheach match the [pdqm] step sends under transaction = "iti-119"PDQm Match Consumer audit (BALP Query): the request as sent, base64the identifier on the input Patient, system and value[audit]
ITI-90 Find Matching Care Serviceseach search of the mCSD directory the registry is read from, one per resource typemCSD Care Services Query audit (BALP Query): the first request of the search, base64none[audit]
ITI-91 Request Care Services Updateseach history of a registry refresh, one per resource typemCSD Care Services Updates audit: the first request, base64none[audit]
ITI-93 Mobile Patient Identity Feedeach message the Patient Identity Registry sends to the feed routePMIR Feed audit, the Registry as the source and the gateway’s callback as the destination: the MessageHeaderone entity per Patient Master Identity the message names, by its Patient/<id> reference or an identifier[audit]
ITI-94 Subscribe to Patient Updateseach subscription create, read and delete the identity feed makesPMIR Subscription Create, Read and Delete audits (BALP Create, Read, Delete): the Subscription and, for a create, its criterianone: a subscription names an identifier system at most[audit]
The search of the Registry’s subscriptions by callbackbefore each createBALP Query: the request as sent, base64; ITI-94 defines no searchnone[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):

RecordWhere 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:

ElementWhat it holds
agent.purposeOfUse of the agent:userevery 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
purposeOfEventthe 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.

  • destination has no default outside profile = "development" once a registry is configured, or [pixm], [pdqm] or [pmir] is set, since every access to patient data is recorded: config check, serve and a reload refuse the configuration without it, naming audit.destination. Every build records accesses here, with either binding or none. off is refused outside development; under development an unset destination records nothing.
  • log writes each record as a structured event at the ferrofed::audit log 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 refuses log: config check, serve and a reload name audit.destination and ask for repository (Regulation (EU) 2025/327 Annex II 3.2). log stays accepted under development, and outside it for a gateway with no registry, whose PIXm, PDQm or PMIR records it writes.
  • repository posts each record to [url]/AuditEvent as FHIR JSON. The url is https outside development; under development plain http is accepted and named on the banner, and without spool_dir the 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:

TransactionWhen its record cannot be stored
An access to patient datathe answer is withheld: 503 access-unrecorded with none of the data (The access log)
ITI-83the member’s resolution is unavailable: no member is asked, and the query fails 424 under all-or-nothing completeness
ITI-90, ITI-91the directory read fails as a directory that did not answer: the boot is refused, or a refresh keeps the registry in place
ITI-93the message is answered 503 and nothing is applied, so the Registry sends it again
ITI-94the 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_timeout less the one-second combining margin, so the gateway answers 503 access-unrecorded before 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’s timeout_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.

AccessWhen it is recordedBALP patternAction
A federated AQL query, POST or GET {base}/v1/query/aqlonce at least one member was sent it; a query no member was sent reached no dataIHE.BasicAudit.PatientQuery when the query names a patient, IHE.BasicAudit.Query otherwiseE
A stored-query execution, POST {base}/v1/query/{name}as for an ad hoc query, the stored query named in the recordas for an ad hoc queryE
A routed read: an EHR resource, the read of an EHR by subject, a DEMOGRAPHIC readonce a node acted for it (openEHR-federation-endpoint on the answer)IHE.BasicAudit.PatientRead with the patient, IHE.BasicAudit.Read otherwiseR
A routed write: a create, an update or a delete under an EHR, the creation of an EHR, a DEMOGRAPHIC writeonce a node acted for itCreate, Update or Delete, the Patient variant with the patient; a failed delete claims no pattern, since the Delete pattern fixes outcome to 0C, 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.2Where the AuditEvent carries it
(a) the provider or other individuals who accessed the dataan 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 datathe 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 accessedthe entity named ehds-categories: see Categories; each category’s security label is on it (Labelling the access records)
(d) the time and daterecorded
(e) the origin or origins of the dataone 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 tokenIn the agent:user
the IHE IUA subject_namewho.display, as BALP 1.1.4 §3:5.7.5.4 maps it
the IHE IUA national_provider_identifieran 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 namesa 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:

detailAttribute
relayed-via, relayed-issuercontact-point, and the contact point that asserted them, by its issuer
assertedtrue: the values are the contact point’s assertion, never verified by the gateway
country-codeTable 1 country_code
hp-name, hp-family-name, hp-given-nameTable 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-authorityTable 1 hp_identifier, the assertion’s NameID, and its issuing_authority_name
hp-professional-role, hp-on-behalf-ofTable 1 hp_professional_role, once per role, system|code, and the role the professional acts for
hp-name-id, hp-authentication-methodthe assertion’s NameID with its issuer, and its authentication class
provider-identifier, provider-issuing-authority, provider-name, provider-addressTable 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-carethe facility type, the home community and the point of care the assertion states
identity-assertion-id, trc-assertion-idthe ids of the identity assertion and of the treatment relationship confirmation
asserted-fieldevery 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:

ElementValue
type4 (other), as the other entities FerroFED adds
nameehds-emergency-access
descriptiona sentence that states the mark in words, citing Art 11(5)
detail ehds-emergency-accesstrue
detail ehds-emergency-purposeeach declared purpose that marked it, system|code
detail ehds-consent-set-asideunder [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-client token 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 TREAT or COC that [nl_gf.mitz] purpose configures (Consent). By default a member it drops as consent-denied is 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 one ehds-consent-set-aside detail per 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:

CodeDisplayDefinition, in part
BTGbreak the glasspolicy override operations for “immediately needed health care for an emergent condition”, which “may include override of subject of care consent directive restricting access”
ETREATEmergency Treatmentoperations “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:

ElementValue
type4 (other)
nameehds-subject-inserted
descriptiona sentence that states the mark in words, citing Art 5
detail ehds-subject-insertedtrue

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)CategoryCode
(a)patient summariesPatient-Summaries
(b)electronic prescriptionsElectronic-Prescriptions
(c)electronic dispensationsElectronic-Dispensations
(d)medical imaging studies and related imaging reportsMedical-Imaging
(e)medical test results, including laboratory and other diagnostic results and related reportsLaboratory-Reports
(f)discharge reportsDischarge-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_details of each COMPOSITION, or of each ORIGINAL_VERSION’s data, a delivered row holds, the COMPOSITION a routed read returns, and the COMPOSITION a write sends in canonical JSON. The body is read, never changed (N22).
  • A template key wins over an archetype key. An archetype’s none never 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 FROM and in the paths it selects, and = on archetype_node_id, archetype_details/archetype_id/value and archetype_details/template_id/value in the top-level AND chain of WHERE, read from the syntax tree, never from a comment, a string compared with another path, NOT, != or NOT CONTAINS. A selected path that names its archetype by a pattern or a parameter leaves the record unbound.
  • An access spanning categories records all of them, and none never removes one. An EHR, an EHR_STATUS, a DIRECTORY, 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) and no-object-returned. No category and unclassified are states of the record, never categories: neither is ever written as an ehds-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:

detailValue
ehds-retention-yearsthe period, in years
ehds-retention-endsthe 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-groundwhat 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 readsThe 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 calleragent.identifier=<iss>|<sub>, the caller’s issuer and subject
An operator, for one EHR at one memberentity.identifier=|<ehr_id>, the record’s ehr entity
An operator, for one memberentity.identifier=|<endpoint>, the record’s origin entity
A person’s population accesses, through the access serviceentity.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 accessesoutcome=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:

ValueMeaning
request-namedthe request named the patient, who is the record’s entity:patient
foundthe identity service named the patient, written as an entity:patient
not-foundthe identity service holds no identifier for the patient in a namespace asked
unavailablethe identity service failed or did not answer in time; the gateway logs the failure, with no identifier
not-configuredpatient_namespaces is empty
unsupportedthe 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:

ElementValue
type4 (other)
nameehds-population
descriptiona 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-populationtrue
detail ehds-population-reasonwhy 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-ehrshow many ehr_ids the rows named, when they were read: always for over-limit
detail ehds-population-limitmax_named_ehrs
detail template-id, archetype-ideach 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_id of 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

ItemRequirementHow FerroFED meets it
3.2a 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 datathe provider agent and the Application agent of each record
3.2(b)the specific natural person or persons who accessed the datathe 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 accessedthe 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 accessrecorded
3.2(e)the origin or origins of the dataone 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.3tools to review and analyse the log data, or the connection of external softwarethe 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.4retention periods and access rights by origin and categoryeach 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

ElementLabels
securityLabel of the ehds-categories entitythe labels of the categories
securityLabel of each origin entitythe 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 accessIts labels
reached data of a category your [access_log] map namesthat 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 partthe 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 includedthat 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-Reports label 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 iss names an [[auth.issuer]] the gateway trusts, whose key set verifies the signature under the key its kid names. A token with no kid is verified only when the set holds one candidate key;
  • its JOSE header names an algorithm the issuer’s algorithms admits, by default RS256, RS384, PS256, PS384, ES256 and ES384. A token signed with none or an HMAC algorithm is refused, and so is an HMAC algorithm in algorithms at load (RFC 8725 §3.1, §3.2);
  • under the default profile = "rfc9068", its header names the type at+jwt (RFC 9068 §4) and it carries every claim RFC 9068 §2.2 requires: iss, exp, aud, sub, client_id, iat and jti. A sub that is empty or blank names no caller and is refused;
  • aud names one of the issuer’s audience;
  • exp, nbf and iat hold within the issuer’s clock_skew_seconds;
  • the token names a caller the issuer’s kind admits (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 whose sub is its client_id is a system acting without a user context; any other token is admitted only through a client the issuer lists in first_party_clients, or a delegate it lists in delegates, and an RFC 8693 act claim only when its actor is a listed delegate;
  • patients: patients, each token carrying patient/ scopes and no user/ or system/ 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 = []
KeyDefaultMeaning
auth.issuer[].issuernoneThe 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[].kindnoneWhose tokens the issuer signs: persons, patients, machines or federation (Who the caller is). Required.
auth.issuer[].profilerfc9068The 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[].audiencethe public URLThe 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[].algorithmssixThe signature algorithms admitted: RS256, RS384, PS256, PS384, ES256 and ES384. An HMAC algorithm refuses the start.
auth.issuer[].clock_skew_seconds60The leeway on exp, nbf and iat, at most 300 (RFC 7519 §4.1.4).
auth.issuer[].jwks_max_age_seconds600How long a key set is used before it is read again.
auth.issuer[].jwks_refresh_interval_seconds60The least time between two reads of one key set, after an unknown kid or a failed read.
auth.issuer[].jwks_filenoneA 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_ms10000How 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[].modejwtjwt: 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.endpointthe metadata’sThe introspection endpoint, in place of the one the issuer’s metadata names.
auth.issuer[].introspection.clientnoneThe 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_filenoneA 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.realmferrofedThe realm every WWW-Authenticate challenge names.
auth.product_scopenoneThe 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[]noneRefused: 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.claimacrThe 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.minimumnoneThe least level a patient-data request needs; some value must reach it.
auth.dpop.external_urlnoneThe 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, .algorithms60, 10, the six asymmetric algorithmsHow 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.modetokentoken: the client presents a bearer token. edge: a proxy authenticates the client (The edge mode).
trust.purpose_of_use.requiredtrueWhether a token must declare a purpose of use (Purpose of use).
trust.issuer[].issuernoneThe 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.endpointnoneThe 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_systemnoneThe identifier system under which the cross-reference service knows that member’s ehr_ids.
auth.issuer[].act_represents_patientfalseOn 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[].requesternoneRefused: 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_professionalnoneRefused: a token ferrohealth-smart classifies as a system’s reaches no patient data (Professionals and assurance).
auth.attributes[].issuernoneA 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[].mappingnoneiua: read the IHE IUA extension, extensions.ihe_iua (ITI TF-2 3.71.4.2.2.1.1).
auth.attributes[].authorization_details_purposesfalseWhether 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_authoritynoneRefused since 0.0.20: name the claim as the "professional.issuing_authority_name" source of the issuer’s [[auth.attributes]] table.
trust.issuer[].assurancenoneRefused: set the issuer’s [auth.issuer.assurance] table instead.
federation.contact_point[].issuernoneA 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_headernoneThe 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.headernoneThe 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.

RouteRequires
POST and GET {base}/v1/query/aqlaql-* 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 tagscomposition-* 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/deploymenta 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/exporta 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 an ehrId at one platform. An ehrId names 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). A patient/ 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 in backend_clients.
  • A scope the grammar reads as anything but a resource scope, openid or launch/patient for 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:

RouteAnswers
GET {base}/operator/incidentshow many integrity incidents of each kind the gateway emitted since it started, and the last 20 of each kind
GET {base}/operator/creating-systemsthe 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-queriesevery 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/dependenciesthe state the gateway last observed of each member endpoint and service (Health probes)
GET {base}/operator/deploymenthow 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

StatusCodeWhen
400credential-malformedtwo Authorization fields, or one that is no credential; the challenge is error="invalid_request" (RFC 6750 §3.1)
401unauthenticatedno 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)
403scope-insufficientno granted scope covers the operation, or the client is not admitted to the DEMOGRAPHIC API
403purpose-of-use-requiredthe token declares no purpose of use
401natural-person-requiredthe 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
401authentication-assurance-insufficientthe request reaches patient data and the token states no assurance at the least level its issuer requires
403contact-point-attributes-requireda national contact point’s token falls short of the MyHealth@EU contact-point profile (National contact points)
400correlation-invalida national contact point’s correlation header is sent twice, empty, longer than 128 bytes, or not visible ASCII
403operation-refusedthe ADMIN API, refused to every caller
403patient-context-missingonly a bound issuer’s patient/ scope covers the operation, and the token carries no ehrId
403patient-confinementthe request reaches beyond the patient a patient/ grant is confined to, or a patient grant addresses the DEMOGRAPHIC API
424patient-context-unavailablethe patient of a patient/ grant cannot be resolved at every member
503authentication-unavailablethe 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:

  1. It reads the ehrId claim from the access token, or from the introspection answer. A token without one, or with one that is no HIER_OBJECT_ID, is 403 (patient-context-missing).
  2. 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’s ehrId. A cross-reference that cannot answer, or that places the patient under another ehr_id at the bound member, is 424 (patient-context-unavailable).
  3. It admits the request only when every {node, ehr_id} pair it would send to is one of those. Everything else is 403 (patient-confinement) with nothing sent: a query for another patient, a query that names no patient, 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 and a definition request. An ehr_id the 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.
  4. A query must read the patient’s EHR alone: every class in its FROM is contained, conjunctively, under the one EHR it is scoped to (FROM EHR e CONTAINS COMPOSITION c CONTAINS OBSERVATION o). A class beside that EHR under AND or OR, a second EHR, or an EHR under NOT CONTAINS would let a node answer other patients’ rows, so such a query is 403 (patient-confinement) before anything is looked up (§7.1).
  5. 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 is 403 (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

(§13.4 authn-which-identity)

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

(§13.4 authn-end-user)

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

(§13.4 authn-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

(§13.4 authn-token-binding)

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

(§13.4 authn-residual-risk)

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’s ehrId: 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 issues user/ or system/ 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.

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:

ClaimValue
issthe gateway: the client_id of the endpoint’s oauth grant, the iss of its client assertions there, or else federation.id
audthe node’s endpoint id in the registry
iat, expseconds since the epoch; exp is 60 seconds after iat
jtia fresh version 4 UUID per token
subthe caller’s sub, as the gate verified it
iss_upstreamthe issuer that vouched for the caller
verified_bysignature, introspection, or edge for an identity the edge asserted
subject_organization_idthe caller’s organisation, when its token names one (IHE IUA)
purpose_of_useeach purpose of use the token declares, as {"system", "code"} (IHE IUA, HL7 v3 PurposeOfUse)
scopethe caller’s scopes as granted; under a patient grant, only the patient/ scopes that cover the operation
ehrIdunder a patient grant only: the patient’s own ehr_id at this node (SMART on openEHR master04 §Capabilities)
actingperson when the caller’s sub names a natural person, client when it names a client application (Professionals and assurance)
subject_namethe professional’s name, when the caller’s token states extensions.ihe_iua.subject_name (IHE IUA)
national_provider_identifierthe professional’s identifier from their national authority, when the caller’s token states it (IHE IUA)
national_provider_identifier_authoritythe 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_rolethe professional’s roles, each as {"system", "code"}, when the caller’s token states extensions.ihe_iua.subject_role (IHE IUA)
assurance_levellow, 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_pointfor 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:

  1. At admission, record the gateway’s jwks_uri (its OPTIONS {base}/ declares it as federation.auth.jwks_uri) and the iss it will see: the client_id the node’s authorization server registered for the gateway, or the federation id where the gateway sends no OAuth 2.0 grant.
  2. Read exactly one openEHR-federation-client value; refuse a request with none or with more than one.
  3. Check the JOSE header: typ is openehr-federation-client+jwt and alg is the alg the gateway’s JWK Set publishes for the key the header names, ES256 or ES384. Refuse any other algorithm, none included (RFC 8725 §3.1).
  4. Verify the signature with the key of the gateway’s JWK Set whose kid the header names. Fetch the set again when the kid is unknown: during a rotation the set publishes the current and the previous key.
  5. Check iss against step 1, aud against the node’s own endpoint id, and exp, allowing a few seconds of clock skew (RFC 7519 §4.1.4).
  6. Where the gateway authenticates with an OAuth 2.0 grant, check that iss equals the client_id of the access token on the same request.
  7. Apply scope and purpose_of_use to what you release, confine a patient/ scope to the EHR the ehrId claim names, and record sub, iss_upstream, verified_by and subject_organization_id in your audit trail, with national_contact_point where a contact point relays the request: its professional and provider are the contact point’s assertion, which asserted_by names. 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 of ferrohealth-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 with security_profile = "fapi2", and takes an optional onward table 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 in private_key_file; the gateway publishes its public half in its JWK Set (Signing keys and the JWK Set), under the RFC 7638 thumbprint as its kid unless kid names another. The assertion names client_id as its iss and sub, carries a fresh jti and lives 60 seconds. tls_client_auth and self_signed_tls_client_auth authenticate 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_secret and client_secret_file refuse 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_auth has no default a node takes, so set it.
  • scope is space-separated SMART on openEHR scopes, each a resource scope of the system compartment (system/aql-*.s, system/composition-*.cru); anything else is refused at load. The token request carries it as written (RFC 6749 §3.3).
  • token_endpoint is an https URL with no user name, password or fragment (RFC 6749 §3.2; RFC 9110 §4.2.4); http is accepted only under profile = "development" (What must travel encrypted). With issuer in its place, the token endpoint is read from the issuer’s RFC 8414 metadata, and issuer is held to the same rule.
  • assertion_audience is token_endpoint, the default, or issuer. RFC 7523 §3 admits either as the assertion’s aud. An authorization server under the FAPI 2.0 Security Profile accepts only its issuer identifier, as one string (§5.3.2.1). issuer needs the table’s issuer set, which then replaces token_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_ms bounds 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 onward table sets no scope, 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 is node-error and is sent nothing. Without scope the authorization server would choose the scope itself (RFC 8693 §2.1). A scope set on the onward table, 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) that ferrohealth-smart’s onward check accepts: a JWT naming issuer as its iss, an aud inside audiences, a lifetime within max_onward_lifetime, the gateway as its act or 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 DPoP reaches the node under the DPoP scheme, its requests proven with the oauth table’s dpop_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-error and is sent nothing.
  • The gateway keeps the caller’s token only while some node has an onward table, and never writes it to a log, a span, a metric or the openEHR-federation-client token.
  • 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 oauth table’s client-credentials token, with its scope.

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:

kindCarriedRefused
personsa person of the node’s organisation, acting alone, through a client or for anothereveryone else
federationa professional of another domestic party, and another domestic party’s organisation when the section opts ineveryone else
patientsthe patient, on a token naming the patient contexteveryone 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 a 401 from the node is never retried under it.
  • The gateway’s own requests, which no caller originates, use the oauth table’s token.
  • A caller the edge mode asserted, or a Basic caller, has no token to exchange, so that node is node-error and 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 = true in the node’s [credentials."<id>"] section, beside an onward table of kind federation. 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-REST query group and outside GET, HEAD and OPTIONS is refused 403 operation-refused before any token is asked for. Configuration refuses the switch without an onward table or on any other kind. A national contact point is never carried as an organisation: its token always names a professional.
  • Under a chained agreement, set correlation_claim to 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 is node-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 CONTRIBUTION names the placeholder of the onward token the lane obtained for the caller in its audit and in every version’s commit_audit (ITS-REST 1.1.0 UpdateAudit, 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 a persons issuer. Through a federation or a patients lane a CONTRIBUTION that carries an attestation is refused 403 operation-refused before any token is asked for, because the node refuses every attestation by an external person.
  • A header commit (PUT or POST of a composition, an EHR_STATUS or a directory) is forwarded as the client sent it. The gateway adds no committer to its openehr-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 kind persons whose audience is the node’s own and is one of the lane’s audiences;
  • 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 in act;
  • each person the lane carries binds to a PERSON under 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:

KeyWhat it is
client_identity_fileThe 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_fileA PEM bundle of trust roots, beside the platform’s, the node and its authorization server are trusted by.
min_versionThe 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 for client_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 carries client_id and no client assertion. An onward table with actor_assertion = true still names the gateway as the actor, with an assertion of the oauth table’s private_key_file (RFC 8693 §2.1).
  • certificate_bound = true takes only tokens bound to the certificate (§3). The token is sent under Bearer (RFC 6750), and only over the endpoint’s transport, which presents the certificate it is bound to. Where the token states its binding, as a cnf member of the token response or the cnf claim of a JWT access token, its x5t#S256 must be the SHA-256 thumbprint of the tls table’s certificate (§3.1, §3.2); a token bound to another certificate is never cached or sent, and the node is node-error with 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_file refuses the configuration, and so does an identity file without a certificate and its key, naming credentials."<id>".tls.client_identity. client_certificate_file, client_key_file and ca_file in the oauth table 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:

  1. 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, a token_endpoint, a presentation_definition_endpoint (RFC021 §5) and vp_formats admitting jwt_vp with the holder key’s algorithm (RFC021 §3.1); when it lists dpop_signing_alg_values_supported, the DPoP key’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. Write authorization_server in its canonical form, a lower-case host and no default port, since the metadata must name it as the same text.
  2. It reads the Presentation Definition for scope and 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.
  3. It signs a JWT Verifiable Presentation of every credential (VC Data Model 1.1 §6.3.1): iss and sub the gateway’s did, kid the DID URL kid, aud the issuer, nbf now and exp five seconds later, and a fresh nonce and jti (RFC021 §4.2). A credential whose exp has passed is never sent.
  4. It posts grant_type=vp_token-bearer with the presentation as assertion, the Presentation Submission, the scope and, when set, client_id, with a DPoP proof of dpop_key_file’s key. A demanded nonce is answered once, with a new presentation, since RFC021 §4.4 refuses a presentation nonce seen before.
  5. It takes the token only when its token_type is DPoP. The token is kept until 30 seconds before it expires and dropped when the node answers 401, and every request to the node carries it under the DPoP scheme with a proof of the same key (Tokens bound to a key).
KeyWhat it is
authorization_serverThe issuer identifier of the node’s authorization server (RFC 8414 §2). https outside the development profile.
scopeThe scope the authorization server maps to its Presentation Definition, space-delimited RFC 6749 §3.3 scope tokens.
client_idOptional; sent when the authorization server identifies its clients by one (RFC 6749 §3.2.1).
didThe gateway’s did:web identifier, the holder of the credentials.
kidThe DID URL of the holder’s key, <did>#<fragment>.
key_fileThe holder’s key, a P-256 (ES256) or P-384 (ES384) private key in PKCS#8 PEM.
dpop_key_fileThe 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.

  1. At the first request to the node, ferrohealth-smart reads the authorization server’s metadata from issuer, once, and keeps it; a read that fails is tried again on the next request. The metadata must be application/json, name that issuer exactly, and name a token_endpoint on 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_jwt in token_endpoint_auth_methods_supported, and the key’s algorithm in token_endpoint_auth_signing_alg_values_supported (RFC 8414 §2: an omitted method list means client_secret_basic alone);
    • client_credentials in grant_types_supported, and token exchange for an onward table (an omitted list means authorization_code and implicit alone);
    • every type of authorization_details in authorization_details_types_supported (RFC 9396 §10);
    • the DPoP key’s algorithm in dpop_signing_alg_values_supported, when the list is there (RFC 9449 §5.1).
  2. It asks the token endpoint for a token with the client-credentials grant, or the onward table exchanges each verified caller’s token. The client assertion is signed with private_key_file’s key, and names the issuer, as one string, as its aud (FAPI 2.0 §5.3.3.1); the actor token of an exchange is signed the same way. The request carries scope when set and authorization_details as written.
  3. It takes the token only when its token_type is DPoP and, under authorization_details, when the answer states the details it granted (RFC 9396 §7). A refusal with invalid_authorization_details is reported with that code. Every request to the node carries the token under the DPoP scheme with a proof of dpop_key_file’s key (Tokens bound to a key).

The keys every grant takes, held to the profile:

  • client_auth is private_key_jwt, or tls_client_auth or self_signed_tls_client_auth by the node section’s certificate (Mutual TLS to a node); under private_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, or certificate_bound = true with the node section’s certificate.
  • scope and authorization_details are 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 oauth table 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_use and subject_organisation_type travel as the configured authorization_details of 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:

KeySignsPublished
key_fileevery tokenalways
previous_key_fileneverfor rotation_overlap from the start of the process
next_key_fileneveralways

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:

  1. 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, wait node_jwks_cache more, so every node’s cached set came from a replica that publishes the new key.
  2. Sign with it. Set the new key as key_file, move the old one to previous_key_file, remove next_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 for rotation_overlap from its own start, so finish this rolling restart within rotation_overlap minus the longest token lifetime: 3600 s with the defaults. Raise rotation_overlap before this step for a slower rollout.
  3. Retire the old key. Once rotation_overlap has passed since the last replica restarted, no replica publishes the old key and no token it signed is still valid. Remove previous_key_file at 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(*) and COUNT(path) are the sum of the node counts.
  • SUM is the sum of the node sums, or NULL when no node holds a value.
  • MIN and MAX are the least or greatest node value, over numbers and complete date-times.
  • AVG is asked of each node as the SUM and the COUNT of its path, and answered as their quotient, or NULL when 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, AVG is an integer: the one nearest the exact quotient of the federation’s sum and count, with a tie going to the even integer, so 5 / 2 is 2 and 7 / 2 is 4. The gateway rounds once, after it adds every node’s sum and count, and never rounds a node’s own mean. Over reals, AVG is the decimal quotient written as the nearest JSON number, so 12.5 / 3 is 4.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 for COUNT, more or less than one row) is reported node-error, and the query fails 424;
  • a node that does not answer fails the query 504, as for any query;
  • a request with openEHR-federation-completeness: partial is refused 400 with the code partial-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:

backendWhere the definitions liveFor
redb, the defaultthe embedded store file path namesone gateway process
postgresFerroFED’s own PostgreSQL database, which [store] configuresseveral replicas behind one address
filesread-only, one file per definition under the directory path namesinstances 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 PUT refused 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] without registry.document refuses the configuration, naming registry.document. A key the chosen backend does not read, such as path beside backend = "postgres", refuses it too, naming the key.
  • OPTIONS {base}/ declares definition.stored_query_registry: true while the registry is offered, whatever the backend, and false without it; without it a stored-query definition request goes to the one node the targeting headers name, both PUTs included, and GET and POST /v1/query/{name} answer 501.
  • 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 redb store, 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 postgres backend.
  • 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, naming stored_queries.backend and [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. url and url_file there 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’s postgres backend 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 PUT answers 200 and the other 409 (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 it 500 and names nothing in the body; the log names the failure.
  • config check checks 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 PUT would: 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 check loads 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 and config check, as a row there in a redb or PostgreSQL store refuses the start (the gateway’s own queries).
  • A PUT answers 405 (stored-query-read-only) with Allow: GET, OPTIONS, and OPTIONS on a definition path lists no PUT. To add or change a definition, add its file and restart. Distributing definitions needs a PUT, so federation.fan_out_stored_queries beside 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_queries without [stored_queries], or beside the read-only files backend, refuses the configuration, at config check and at start.
  • Only a stored-query PUT that asks for it is distributed, with openEHR-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. A PUT naming no member stores at the registry alone. With the setting off, a stored-query PUT or version GET that carries a targeting header is refused 400 (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 ENDPOINT or ORGANISATION directive is refused for distribution, because no node can run it. Store it without the header, and it runs federated.
  • A GET of 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 as definition.stored_query_fan_out, true only 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 not openEHR-federation-endpoint names 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, with openEHR-federation-endpoint and openEHR-federation-system-id naming 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 an endpoint-several, and * or an unknown id an endpoint-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 without registry.document. A suspended endpoint answers no-destination.
  • OPTIONS {base}/ declares its_rest.demographic as unsupported: 501 without the setting, and as routed-single-node naming 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_timeout and overall_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, never 200 (client contract).
  • OPTIONS {base}/ declares the setting as definition.fan_out_template_upload, and its_rest.definition says 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
KeyDefaultWhat it sets
config.uioffmanaged keeps the configuration as revisions on the admin listener
config.backendredbredb, 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.pathnonethe redb store file, created when it does not exist
config.url, config.url_filenonethe 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 coreWhy it stays in the file
profileit 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.

RouteAnswer
GET /admin/configthe 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/revisionssaves {"base": <newest>, "reason": "…", "toml": "…"}, 201 with the revision
POST /admin/config/revisions/{id}/applyapplies 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/importa 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:

MemberWhat it says
keythe dotted key, such as federation.per_node_timeout
sectionthe tab it belongs to: federation, fan-out, consent, stored-queries, access-log, fhir or authentication
valuethe value this replica runs with; absent for a secret, and for a setting nothing sets that has no default
sourcewhere the value comes from: file, environment (a FERROFED__ override), revision (the revision this replica runs, under managed) or default
defaultthe value the gateway takes when nothing sets it, when it has one
restarttrue when a change takes effect only at the next start of the replica
nextfor such a setting, the value the next start takes when it differs from the running one
secretfor a secret, set and the file it is read from as reference; never its value
editabletrue 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:

OwnerResourceChanged by
filea member the registry document provides; a stored query of the read-only definition filesthe file alone, read-only in the console
directorya member the care services directory of [registry.mcsd] providesthe directory alone, read-only in the console
consolea member a revision holds under [[registry.members]]the console, through a revision
gatewaya stored query of the reserved namespace eu.ferrofed.eehrxfnobody
storea stored-query version in the stored-query storenobody: 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" },
]
RouteAnswer
GET /admin/config/resourcesrunning 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:

ResourceTableconfig.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.

RouteAnswer
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 in config.secrets_dir, readable and writable by the gateway’s user alone (mode 0600), 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:

RouteAnswer
POST /admin/config/revisions/{id}/confirmwith 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"]
RouteAnswer
POST /admin/config/revisions/{id}/approvewith 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.

KeyDefaultWhat it sets
config.confirm120000how long an applied change to a credential or a binding section awaits its confirmation, from 1000 to 3600000 ms
config.approvalnonethe resource kinds and resources a revision changes only with a second operator’s approval
config.secrets_dirnonethe 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).

RouteAnswer
GET /admin/config/revisions/{id}/exportthe 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 importRefusal
sets a key of the bootstrap core422 configuration-invalid, naming the key
holds a secret inline422 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 out409 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 is409 resource-file-owned
is based on a revision other than the newest409 revision-stale
is sent as another media type415 media-type-unsupported
names no reason, or is larger than 1048576 bytes400 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 SIGHUP reload 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:

MemberMeaning
runningthe revision this replica runs, or null before the first one
in_stepwhether running is the revision the store marks current
refusedthe 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).

KeyDefaultWhat it sets
config.poll5000how 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:

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:

TableWhat a row holdsWhen it goes
ferrofed.stored_query_definitiona 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_accessthe 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_proofone 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_dispensationwho 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_migrationone migration applied, and whennever

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
KeyDefaultWhat it sets
url, url_filenone, one is requiredthe 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_filethe platform’s trusted rootsa PEM file of the CA certificates the database’s certificate is verified by, in place of the platform’s roots
max_connections4how many connections the gateway opens, 1 to 64
timeout_ms10000how long one connection, or one statement, may take; zero is refused
sweep_interval1minhow 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_id an existing member already has, compared without regard to ASCII case;
  • ferrofed admission check exercises 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:

VerdictMeaning
passthe evidence shows the node meets the condition
failthe evidence shows it does not, or the check could not reach the node
cannot-checkthe 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 own system_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.

ConditionWhat a run without writes does
ehr_id generationeach ehr_id read is judged as above, and no two may be equal. A node that returns no EHR is cannot-check.
No reusecannot-check, as on every run
No adoption of foreign ehr_idscannot-check, as on every run
system_id uniquenesseach 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 exchangecannot-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_id the node issued for two subjects, or an EHR the node reads back under another ehr_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_id the 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-check when 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:

StateWhy it is left out
The resolution bindings of each callerThey 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 indexIt 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 membershipThe registry document is the record, and you already hold it.
The stored queriesTheir 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>
AnswerWhen
200The export, as JSON, with Cache-Control: no-store.
400 (body-invalid)The request carries a body. The export takes none.
401, 403The token is missing, does not verify, or lacks the operator scope.
501The 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"
  ]
}
FieldMeaning
format_versionThe 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_idThe [federation] id the gateway declares in OPTIONS {base}/ (§7a.2, N30).
exported_atWhen the process took the export, as an RFC 3339 timestamp in UTC.
replicaThe 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_systemsEvery mapping the process learned, ordered by creating_system_id. It is empty when the process learned none.
learned_creating_systems[].creating_system_idThe creating_system_id of the versions, as their version uid spells it.
learned_creating_systems[].statuslearned: 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[].nodeThe node_id of the member, from the registry document the process runs on. null for a withdrawn mapping.
learned_creating_systems[].endpointThe endpoint_id the versions were seen at. null for a withdrawn mapping.
not_exportedThe 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.999 that ITU-T X.660 reserves for examples, such as namespace urn:oid:2.999.1.1 and value ffd-test-0038, and register it in your cross-reference at the members you want scored, each with an ehr_id no node holds yet. With a PIX Manager it is a Patient your 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 Summary operational template and the two compositions composition-12345-hospital.json and composition-12345-clinic.json, whose only party is PARTY_SELF. Every release attaches them as ferrofed-conformance-seed-data.json, one file that also carries their Apache-2.0 licence and notice, with its SHA-256 in ferrofed-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-run with 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 --gateway URL and the URL of every member endpoint in the registry are https, or plain http to a loopback host (127.0.0.1, ::1, localhost) under the development profile alone;
  • plain http to any other host is refused under every profile, and any http outside the development profile, before the run sends anything;
  • the token endpoints and the other credential sites of the configuration are held to the rule serve applies (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:

CheckPointWhat the run does
invocable on ehr_id aloneCP-27reads the patient’s EHR it seeded at the member, its EHR_STATUS and its compositions by the ehr_id alone
subject never requiredCP-27creates an EHR with no EHR_STATUS and reads and queries it by its ehr_id
node errors passed throughCP-18asks for an EHR the node does not hold and sends an unparsable query, and reads the status the node answers
access decided at the nodeCP-18records not-observable: the refusal under test is the node’s own policy, which a run cannot arrange
consent before releaseCP-19records not-observable: ITS-REST defines no consent resource, so a run cannot arrange a consent refusal
the identifier-integrity conditions of §12b.2CP-33a, and CP-27 for the ehr_id exchangeruns 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:

WhatWhere
the patient’s EHR, PUT {base}/v1/ehr/{ehr_id} with an EHR_STATUS naming the synthetic patientevery member the cross-reference names
the vendored template, unless the node holds itevery member holding the patient
one vendored composition in the patient’s EHRevery 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 compositionthe first two members holding the patient

Through the gateway, the scenarios write:

WhatWhere
an EHR with no subject, created at the member the request namesa member holding the patient
the vendored composition with the synthetic patient’s identifier added to its composer as a DV_IDENTIFIERthe 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 $patientthe 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.tsv and report.md hold one row per track and per conformance point of §17, with the columns kind, id, title, actor, status, result, passed, failed, not_run, issue and reason the harness report has. status is FerroFED’s own: covered, deferred, node-profile or operator, from the conformance matrix the binary was built with.
  • node-profile.tsv holds the node profile findings, one row per finding and point.
  • written.tsv lists every write the run made, by endpoint.

A row’s result reads:

ResultMeaning
passevery scenario that scores the row ran against your deployment and passed
faila scenario that scores it failed; the reason says which check
not-runa scenario that scores it did not run, or none does; the reason says why
deferredFerroFED defers the point by a recorded decision
node-pass, node-fail, node-not-observable, uncheckeda Node point, scored against the member and never the gateway (§16.2)
operator-pass, operator-fail, operator-not-observable, not-applicablean 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

RouteAnswersUse it as
GET {base}/health/liveness200 while the process serves; it checks nothing elseliveness
GET {base}/health/readiness200 while the gateway serves and its own subsystems are up; 503 before boot completes and from the moment SIGTERM or SIGINT arrivesreadiness, startup, the image HEALTHCHECK
GET {base}/healththe 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:

StateThe last request
upgot an answer below 500, a refusal such as 400, 401, 404 or 409 included
failinggot a 5xx answer
downgot no answer: the member could not be reached, or did not answer in time
unknownnone 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.

  1. Readiness answers 503 at once, with the phase draining, and the listener keeps accepting for server.drain_delay. A request that arrives in this window is served like any other.
  2. The listener closes. Each connection finishes the request it is serving and is closed, and no new connection is accepted.
  3. The requests in flight get server.shutdown_timeout to finish. A connection still open after that is dropped.
  4. 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:

Exampledrain_delayshutdown_timeoutbindings_drain_timeoutArithmeticGrace period
deploy/kubernetes/base/50003000050005 (preStop sleep) + 5 + 30 + 5 + 5terminationGracePeriodSeconds: 50
deploy/compose/03000050000 + 30 + 5 + 5stop_grace_period: 40s
the quickstart compose.yaml03000050000 + 30 + 5 + 5stop_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:

OutcomeLineExit
Readiness answers 200ferrofed: <address>: healthy0
Readiness answers another statusferrofed: <address>: unhealthy: readiness answered <status>1
No answer within three secondsferrofed: <address>: unhealthy: readiness did not answer in time1
The listener presents a certificate other than certificate_file holdsferrofed: <address>: unhealthy: the listener presents a certificate other than server.tls.certificate_file holds; send SIGHUP so the gateway reads it1
A refused connection, a failed handshake or another failureferrofed: <address>: unhealthy: <reason>1
The configuration does not loadferrofed: 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:

LimitKeyDefaultPast it
Requests the gateway serves at onceserver.max_concurrent_requests512503 overloaded, with Retry-After
Requests one verified caller sends[server.caller_rate]off429 rate-limited, with Retry-After
Requests the gateway sends one member endpoint at oncefederation.max_in_flight_per_node64the 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 endpointfederation.max_node_answer_size16777216 (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

RequestAdmittedRefused
GET /metrics, no scrape_clients namedevery peernever
GET /metrics, scrape_clients nameda scrape with a client-credentials token (RFC 6749 §4.4) an [[auth.issuer]] signed for a client scrape_clients names401 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 distributiona caller whose access token carries auth.product_scope, from any peer401 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 credentialas above
A read of the configuration revisions, GET /admin/config…as a write action, and never recorded as oneas 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 listen address that is not a loopback address, such as 0.0.0.0:9464, unless allow_remote = true is 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", a listen address that is not a loopback address with nothing to authenticate the scrape: neither scrape_clients nor a [metrics.tls] with client_auth = "required";
  • scrape_clients with no [[auth.issuer]] to sign their tokens;
  • a listen address equal to server.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 nameInstrumentTypeLabelsCounts
ferrofed_integrity_incidents_totalferrofed.integrity.incidentscounterkindthe integrity incidents the gateway emitted, one per incident line under ferrofed::integrity
ferrofed_node_requests_totalferrofed.node.requestscounterendpoint, outcome, classthe requests the gateway sent to a member endpoint
ferrofed_node_request_duration_secondsferrofed.node.request.duration (unit s)histogramendpointthe time a member endpoint took to answer, with the series _bucket (and le), _sum and _count
ferrofed_consent_prefilter_requests_totalferrofed.consent.prefilter.requestscounteroutcome, reasonthe 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_totalferrofed.localizer.requestscounteroutcomethe 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_totalferrofed.demographics.requestscounteroutcomethe 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_totalferrofed.http.requestscounterhttp_request_method, http_route, status_classthe requests the gateway answered, by the route template and the status class 1xx to 5xx, a refused one included
http_server_request_duration_secondshttp.server.request.duration (unit s)histogramhttp_request_method, http_route, http_response_status_code, url_scheme, network_protocol_version, and error_type on a 5xxthe 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_requestshttp.server.active_requestsgaugehttp_request_method, url_schemethe requests being served now
ferrofed_resolver_requests_totalferrofed.resolver.requestscounteroutcomethe 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_secondsferrofed.resolver.request.duration (unit s)histogramnonethe time each resolver call took
ferrofed_localizer_request_duration_secondsferrofed.localizer.request.duration (unit s)histogramnonethe time each localizer call took, an XCPD or NVI exchange; a not-configured call asks nothing and is not timed
ferrofed_demographics_request_duration_secondsferrofed.demographics.request.duration (unit s)histogramnonethe time each PDQm call took
ferrofed_security_events_totalferrofed.security.eventscounterevent, and reason on caller-refusedthe 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_totalferrofed.overload.refusalscounterlimit, and endpoint on node-in-flightthe requests a limit refused (Overload protection)
ferrofed_fhir_dispensations_totalferrofed.fhir.dispensationscounteroutcomethe 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_totalferrofed.registry.reloadscounterresultthe registry reloads SIGHUP asked for
ferrofed_config_revision_follows_totalferrofed.config.revision.followscounterresultunder config.ui = "managed", the revisions another replica marked running that this replica applied or refused (Configuration revisions)
ferrofed_config_revision_in_stepferrofed.config.revision.in_stepgaugenoneunder 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_totalferrofed.identity_feed.messagescounterresultthe 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_eventsferrofed.audit.spool.eventsgaugenonethe 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_bytesferrofed.audit.spool.bytesgaugenonethe bytes the spool holds, its quarantine included
ferrofed_audit_quarantinedferrofed.audit.quarantinedgaugenonethe audit messages in the spool’s quarantine, which could not be read or were no whole frame
ferrofed_audit_delivered_totalferrofed.audit.deliveredcounternonethe ITI-20 audit messages delivered to the audit repository since the process started
ferrofed_audit_retries_totalferrofed.audit.retriescounternonethe failed attempts to deliver, each followed by a backoff
ferrofed_audit_refused_totalferrofed.audit.refusedcounternonethe 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_infothe resourcegaugeservice_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:

LabelValues
kindEhrIdCollision, IndexInsertCollision, LearnedCreatingSystemConflict, PrescriptionIdentifierCollision, RegisteredCreatingSystemConflict
endpointan endpoint id of the registry document
outcomeactive, node-error, time-out, offline, consent-denied; on ferrofed_fhir_dispensations_total, created, unowned, claimed, several, unroutable, silent, refused, unknown, failed
classmember (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)
resultapplied, refused
http_routea 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_methodGET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH, CONNECT, TRACE, QUERY, or _OTHER for any other method
http_response_status_code, error_typethe HTTP status the gateway answered
status_class1xx, 2xx, 3xx, 4xx, 5xx
url_schemehttp where the listener speaks plain HTTP and TLS ends in front of it, https where it serves [server.tls] itself
network_protocol_version1.0, 1.1 or 2, the HTTP version of the request
eventcaller-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
reasonon 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
limitconcurrency, caller-rate, node-in-flight
lea 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.

CallRequests countedoutcome read fromTime recorded
A federated query, GET or POST {base}/v1/query/aql, and a stored-query invocationone per member sent the queryits §11.1 status in meta.federationits latency_ms
A fan-out template uploadone per member sent the uploadits §11.1 status in meta.federationits latency_ms
A stored-query distribution, a PUT naming members or the admin listener’s repairone per member sent the definitionits §11.1 status in meta.federationits latency_ms
A stored-query drift check, a GET naming membersone per member asked for its copyits §11.1 status in meta.federationits 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 requestonethe node’s answerfrom sending it to the answer
The ask-all probe, GET /ehr/{ehr_id} at every member for a read whose owner no earlier step namedone per member probedthe node’s answerfrom 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:

outcomeCovers
activea member that answered with success, and a probed member that answered 404, which says it holds no such EHR
node-errorin 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-outa member that gave no answer before its per-node deadline, and a member still being waited on when the overall budget ran out
offlinea member the gateway sent a request to and could not reach
consent-denieda 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:

AlertFires when
FerroFEDServerErrorsover 5% of the answers are 5xx for 10 minutes
FerroFEDSlowAnswersthe 95th percentile answer takes over 20 seconds for 10 minutes
FerroFEDOverloadedthe concurrency limit refuses requests for 5 minutes
FerroFEDCallerRateLimiteda caller is rate limited for 15 minutes
FerroFEDMemberFailinga member endpoint fails over 10% of its requests for 10 minutes, a 4xx it answered a routed request with (class="client-error") left out
FerroFEDMemberCapSaturateda member’s in-flight cap stays full for 5 minutes
FerroFEDResolverFailing, FerroFEDLocalizerFailingthe resolver or the localizer does not answer for 5 minutes
FerroFEDIntegrityIncidentan integrity incident is raised
FerroFEDRegistryReloadRefuseda registry reload is refused
FerroFEDAuditSpoolBacklog, FerroFEDAuditRefusedthe audit spool holds over 1000 records for 15 minutes, or refuses one
FerroFEDOutboundGateStoppedthe outbound gate stops a request that would have carried a patient identifier
FerroFEDWriteUnrecordedthe 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
FerroFEDCallerRefusalscallers are refused at authentication over once a second for 10 minutes, by reason
FerroFEDAuthenticationUnavailablean 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

SpanUnderAttributes
{method} {route}, such as POST /v1/query/aqlnothing: the root of the gateway’s own tracehttp.request.method, http.route (the route template), http.response.status_code, request_id (the id the gateway minted)
localizethe requestmembers
consent_prefilterthe requestmembers
resolvethe requestmembers, resolved
fan_outthe requestendpoints, http.response.status_code
mergefan_outendpoints, rows
probethe requestendpoints, holding
template_fan_out, stored_query_distribution, stored_query_driftthe requestmembers
node_requestwhichever of the above sent it, or the request for a routed requestendpoint_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

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 each serve start, 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:

FieldSent inWhat it is
schemabothAlways 2, the version of the report format.
eventbothstart or daily.
first_startstarttrue when this start created the instance id, which marks a new installation; false on every later start.
productbothAlways ferrofed.
instance_idbothA 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.
versionbothThe gateway version, for example 0.0.12.
git_shabothThe commit the release lane built the binary from, 7 to 40 hex digits. A local build sends 0000000.
licencebothThe licence grant type, non-commercial (what the Business Source License grants everyone).
deploymentbothHow the gateway was deployed: helm, compose, binary or unknown, from [usage_report] deployment, which the compose file sets to compose.
uptime_sbothSeconds since this process started.
cpu_bucketbothThe CPUs available to the process, as a range: 1-2, 3-4, 5-8, 9-16 or 17+.
memory_bucketbothThe 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.
statusbothThe state facts below.
metricsdailyThe performance aggregates below.

The status object:

MemberWhat it is
profileThe deployment profile, production or development.
membersThe 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.
servicesThe identity, localization and consent services the configuration names, sorted: mcsd, mitz, nvi, pdqm, pixm, pmir, xcpd. Never a service URL or a credential.
stored_queriesWhether the gateway offers the stored-query registry.
fhirWhether 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-100k or 100k+. Each range includes its lower bound and excludes its upper one, so 1-100 means 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:

    GroupRoutes
    query{base}/v1/query/aql, and a stored query under {base}/v1/query/
    composition{base}/v1/ehr/{ehr_id}/composition and every path under it
    ehrevery other route under {base}/v1/ehr
    definitionevery 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_requests over the query group’s requests is the average fan-out.
    • node_timeouts: the node requests reported time-out (§11.1).
    • node_errors: the node requests reported node-error or offline (§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] endpoint moves 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 the audit-spool volume: docker run --rm -v <project>_audit-spool:/s busybox cat /s/usage-report/instance-id prints 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 an HttpOnly cookie of its own (ferrofed_console_theme, ferrofed_console_density, ferrofed_console_motion and 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’s OPTIONS document, with its download.

  • Operator sign-in at an OpenID Provider: GET /login redirects the browser to the provider’s authorization endpoint with the authorization code grant, a nonce and a PKCE challenge (RFC 6749 §4.1, RFC 7636). The state, the nonce and the PKCE verifier stay on the console’s server as a pending sign-in, which the browser knows only by an opaque HttpOnly cookie that expires with it. The provider’s redirect back to /auth/callback is 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, an exp still ahead, and the sign-in’s nonce (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 is 401, and a provider that refuses or cannot be reached is 502.

  • 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_grant or any other, ends the session: a view sends the browser to /login and 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 /login meanwhile. A session with no refresh token ends when its access token expires, and one whose provider sends no expires_in is never refreshed. The ID Token of the sign-in stays the sign-out’s hint; one a refresh answers is not read. Set offline_access = true in [oidc] to ask for the offline_access scope and the prompt=consent it 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’s end_session_endpoint and the session held an ID Token, the console then redirects the browser there with that ID Token as id_token_hint, its client_id, and the post_logout_redirect_uri you 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 answers 503. A GET /logout is 405.

  • The session watch. GET /session answers what the session has left, {"idle": s, "absolute": s} in seconds, and POST /session extends it as activity does; once the session is gone both answer 401 with 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, HEAD or OPTIONS, the sign-out and every form among them, must come from the console’s own pages: a request the browser marks Sec-Fetch-Site: same-origin, or, without fetch metadata, one whose Origin, or else whose Referer, has the origin of redirect_uri. Any other, one that names no origin among them, is 403 before 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 is SameSite=Lax as well, and every action is a POST to the page it sits on: a GET runs nothing.

  • Two separate pools of server-side state. Pending sign-ins live for sign_in_timeout_s and are bounded by max_sign_ins; a full pool drops its oldest pending sign-in, so a flood of GET /login holds at most that many and stops costing anything once it ends. A signed-in session is created only once a sign-in completes, lives for idle_timeout_s without a request and absolute_timeout_s at most, and is bounded by max_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 /login at 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:

    PageWhat it showsRead 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 glancethe 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 meanOPTIONS {base}/ and GET {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 itthe same, and GET {base}/operator/creating-systems
    /servicesevery service the gateway asks, grouped, with its last observed stateGET {base}/operator/dependencies
    /integritythe open and the resolved integrity incidents and the count of each kind since the gateway startedGET {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 versionGET {base}/operator/stored-queries and /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 namesthe same
    /search/answer?q=the same answer as JSON, which the panel under the find field reads as you typethe same

    A page without a signed-in session sends the browser to /login before 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; a 401 links 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_url it 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/aql or POST {base}/v1/query/{name}, as the signed-in operator, exactly as any client sends it. The form offers what the gateway’s OPTIONS {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. offset and fetch are optional. The answer shows the rows under columns[] as the gateway renders them, and every endpoint of meta.federation.endpoints[] with its status, latency, row count and error. An answer whose meta.federation.complete is false says “Incomplete answer” in words before its rows. A 504 or 424 the 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 readable meta.federation is 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. /integrity offers the clear of a held ehr_id, which calls POST /admin/ehr-ids/{ehr_id}/clear on 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 the ehr_ids the incidents name. A stored query’s page offers the drift repair of the version shown, which calls POST /admin/stored-queries/{name}/{version}/distribute with the members in openEHR-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, a 401 links 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 carrying auth.product_scope and 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 answers 404, and the page says so and offers no form (Console-managed resources).

  • The health routes of the FerroHEALTH family: GET /health/liveness answers 200 while the process serves, GET /health/readiness answers 200 while it takes requests and 503 once it drains on SIGTERM, and GET /health follows readiness with {"state":"up"}. The healthcheck command the image runs asks GET /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

Every node across the organisations, with its organisation, system_id, state and median and 95th percentile latency 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.listen on a loopback address, its default 127.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] with client_auth = "required" and a client_ca_file that 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.0 is reachable from the network even when the host firewall says otherwise (The quickstart); FERROFED_BIND_HOST in the release compose.yaml defaults to 127.0.0.1.
  • Decide which open routes the network may reach. The gateway answers without a token on the health family, GET {base}/ and GET {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.eu on port 443 (The usage report). Allow those and nothing else. The Kubernetes package’s base leaves egress open, and its egress component 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 plain http, and a stored-query database password without sslmode=require. The startup banner and a WARN line name each unencrypted credential the development profile admits (What must travel encrypted).
  • Use https for every node that receives a credential, and mutual TLS where a node or a national service asks for it: client_identity_file and trust_roots_file in the tls table 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 https and 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_clients for system/aql-* and demographic_clients for the DEMOGRAPHIC API, under [[trust.issuer]], and have only the issuer your operators sign in at grant auth.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 check names 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 a patient/ 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, with SIGHUP or POST /admin/reload (Reloading the registry); write the steps into your incident runbook.

Secrets

  • Read every secret from a file. Each credential has a _file key: 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 mode 0400, or 0440 with the gateway’s group, as the Kubernetes example does (The container image).
  • Keep secrets out of the environment and the configuration file. A FERROFED__…_FILE variable 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_cache to the longest time a node caches the JWK Set. config check then refuses a rotation_overlap too short for a safe rotation.
  • Rotate in three rolling restarts: publish the new key as next_key_file, sign with it as key_file with the old one as previous_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] listen unset unless you scrape. Unset, nothing listens.
  • Keep it on loopback. Set allow_remote = true only 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 with scrape_clients on 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 in metrics.scrape_clients, and give your Prometheus its client secret with oauth2.client_secret_file; or set [metrics.tls] with client_auth = "required" and a client_ca_file that admits the scraper. Outside the development profile the gateway refuses to start with a listener off loopback and neither.
  • Set auth.product_scope and 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, serve and a reload refuse an unset destination, off and log, naming audit.destination. The log destination 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 encrypted storage class with one of yours that encrypts.
  • Leave the directory modes alone. The gateway creates the spool 0700 with files 0600 and refuses to start when it is open to others.
  • Watch the backlog. Alert on FerroFEDAuditSpoolBacklog and FerroFEDAuditRefused; a full spool fails the transactions it cannot record, and the gateway then answers every access to patient data 503 access-unrecorded. Remove a quarantined record only after you have read why the repository refused it (The audit trail).

Identity services

  • Use https and, 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 in pmir.feed_clients, and agree the client with the Registry’s operator; a message without its token changes nothing.
  • Set federation.binding_ttl no 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) and federation.max_node_answer_size (16 MiB) (Overload protection). Turn the per-caller rate on.
  • Keep the request limits. server.body_limit (1 MiB) and server.request_timeout (30 s), with the proxy’s own timeout longer, so the client sees the gateway’s answer.
  • Rate-limit /login on 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.yaml and the Kubernetes example do.
  • Let the drain finish. Keep the runtime’s grace period above server.drain_delay plus server.shutdown_timeout plus gateway.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’s up.
  • Treat an outbound-gate-stopped event 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 debug in production unless you are debugging, and return it after; telemetry.filter changes what is logged, never what is traced.

Operator console

  • Keep secure_cookie = true. The console refuses false unless redirect_uri is on a loopback host.
  • Register the console as a confidential client with its secret in client_secret_file, and an exact redirect_uri at 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 65532 with a read-only root filesystem, every capability dropped, and writes only to the spool volume (The image). Keep runAsNonRoot, allowPrivilegeEscalation: false and the RuntimeDefault seccomp 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 check in your deployment pipeline; a refused file exits 78 naming 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 Error body with a stable code and the request_id (Errors and status codes). A federated query that fails under all-or-nothing completeness answers a result set instead, whose meta.federation.endpoints[] names each node with its status and its error.
  • The request line. Every request writes one line, request, with the method, the route template as route, the status, latency_ms and the gateway’s request_id: at INFO for a success, WARN for a 4xx and ERROR for a 5xx (What the log records). Every other line about the same request carries the same request_id.
  • The log target. With telemetry.format = "json", each line carries a target; the pretty format 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 by telemetry.filter), ferrofed::integrity (integrity incidents), ferrofed::audit (audit records written to the log) and opentelemetry-otlp (a failed trace export). Every other line’s target is the Rust module that wrote it, such as ferrofed_server::reload, ferrofed_server::access or ferrofed_engine::dispatch::reported, as the entries below name it (Tracing).
  • The metrics on the admin listener: ferrofed_node_requests_total by endpoint and outcome, ferrofed_security_events_total by event and reason, ferrofed_overload_refusals_total by limit, the resolver, localizer, audit spool and reload series (Metrics).
  • The dependencies. GET {base}/operator/dependencies names the state the gateway last saw of each member endpoint and each identity service: up, failing, down or unknown (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 saysCause and fix
the configuration file … could not be readThe 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 assembledThe 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 sectionA FERROFED__ variable names no key; spell it FERROFED__<SECTION>__<KEY> (The environment).
… is deprecated and replaced by …, and both are setKeep the new key only (The compatibility policy).
… is set together with …_file, … names …, which could not be read, … which holds no secretSet 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 itSet 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 positiveCorrect the value of the named key (The base path).
… is not https, and … would travel over it in cleartextA 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 loopbackAn 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 registryA [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 faultsCorrect the grant (OAuth 2.0 to a node).
… is not a space-delimited list of RFC 6749 §3.3 scope-tokensAn 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 configuredCorrect [signing] (Signing keys and the JWK Set).
… is not a usable DPoP keyA 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 faultsCorrect the Nuts grant (The Nuts grant).
TLS material or mutual TLS faults, endpoint … is not an https URL, and credentials.… presents a TLS client certificateCorrect 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 listenerCorrect [server.tls] or [metrics.tls] (TLS on the listeners).
… is read only by ferrofed healthcheckRemove healthcheck_identity_file where no listener asks for a client certificate (ferrofed healthcheck).
… is neither an IP address nor a CIDR blockCorrect 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_urlCorrect 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 setCorrect [telemetry] or [telemetry.otlp] (Tracing).
unknown key … telemetry.otlp_endpoint, … metrics.otlp_endpoint, … trace_sample_ratio, … otlp_tlsMove 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 faultsCorrect [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 openedCorrect [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 bothCorrect 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 builtA gateway with a registry declares both (Node selection, Federation id).
… are both …; set one, … are all …; set oneTwo sections fill one role, such as two resolvers; keep one (Choosing one).
[dev] is no longer readRemove [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 enabledCorrect [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 builtCorrect 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 fieldCorrect [xcpd] or its audit repository (XCPD localization).
nl_gf.nvi.namespaces lists …, … is not a credential the NVI takesCorrect [nl_gf.nvi] (Dutch localization).
nl_gf.mitz.…, the [nl_gf.mitz] consent pre-filter cannot be enabledCorrect [nl_gf.mitz] (Dutch consent).
audit.destination = "off" …, … = "log" records no caller and no patient …, [audit.repository] applies only under …, the [audit] trail cannot startSet [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 usedSet [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 builtThe 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.

CodeStatusCauseConfirmFix
body-invalid400The 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-invalid400openEHR-federation-completeness is repeated or neither all nor partial.The request line alone.The client sends one valid value (Completeness).
partial-unsupported400A 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-invalid400openEHR-federation-dedup is repeated or names no mode.The request line alone.The client sends none or version-identity (Gateway codes).
parameter-invalid400A 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-invalid400The patient identifier or namespace forms no patient reference.The request line alone.The client names the patient and its namespace (Gateway codes).
no-destination404No 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-collision409Two 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-unreachable409A 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).
internal500The 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-found404The 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-implemented501The 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-unknown400A 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-unknown400A directive or header names no registry organisation.OPTIONS {base}/ lists the organisations.The client names an organisation the registry holds (The registry document).
target-required400A 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-several400A request routed to one node selects several endpoints.The request line alone.The client names one endpoint (Gateway codes).
query-parameter-refused400A 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-timeout504The 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-unreachable504The 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-refused424The 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-conflict400Two targeting mechanisms select different node sets.The request line alone.The client uses one mechanism (Gateway codes).
ehr-id-invalid400The path ehr_id is no HIER_OBJECT_ID.The request line alone.The client sends the ehr_id a node issued (Gateway codes).
node-error424A 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-uuid400An 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-invalid400A stored query name breaks the ITS-REST form.The request line alone.The client corrects the name (Stored queries).
query-version-invalid400A stored query version is no major.minor.patch or prefix.The request line alone.The client corrects the version (Stored queries).
query-type-unsupported400A definition’s query_type is not AQL.The request line alone.The client stores AQL (Stored queries).
subject-literal400A 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-held409The 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-unknown404No 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-invalid400A 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-invalid400A 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-acceptable406Accept admits no media type the operation answers in.The request line alone.The client accepts a listed type (Gateway codes).
media-type-unsupported415Content-Type is no media type the operation takes.The request line alone.The client sends a listed type (Gateway codes).
subject-several400The subject of GET {base}/v1/ehr resolves at several members.The message lists the endpoints.The client names one endpoint (Gateway codes).
resolution-unavailable424The 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-held409A 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-targeted400A 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-unsupported400A 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-only405A PUT reached the read-only files backend.stored_queries.backend = "files".Add the definition as a file and restart (Read-only: files).
stored-query-reserved409A PUT names the reserved namespace eu.ferrofed.eehrxf.The request line alone.The client uses its own namespace (The gateway’s own queries).
consent-denied403The 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).
unauthenticated401No token the gateway accepts.caller-refused under ferrofed::security, with the reason.By reason (Every caller is refused 401).
credential-malformed400Two Authorization fields, or one that is no credential.caller-refused, reason malformed.The client sends one well-formed Authorization field (Client authentication).
scope-insufficient403No 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-required403The 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-unavailable503The 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-refused403The request addresses the ADMIN API under {base}/v1/admin/.caller-refused, reason operation.None: the gateway admits no caller there (Scopes per route).
localization-unavailable424The 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-missing403Only 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-confinement403A 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-unavailable424The 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-unavailable404Under 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).
overloaded503server.max_concurrent_requests requests were in flight.limit concurrency under ferrofed_server::overload.Size the limit (The concurrency limit).
rate-limited429One 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-unrecorded503The 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-required401A 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-insufficient401The 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-required403A 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-reached403A 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-uncounted503The 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-missing403A 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-unbound403A 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-invalid400A 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-invalid422A 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-stale409A 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-unknown404A revision route names no revision the store holds.GET /admin/config/revisions lists the history.Name a held revision (Configuration revisions).
rollback-unavailable409The 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-owned409A 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-unknown404A 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-required409The 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-revision403The revision’s author approved it.The revision’s author is the approver.Another operator approves it (Second-operator approval).
approval-not-required409The revision changes nothing config.approval names.review.approval is empty.Apply it without an approval (Second-operator approval).
operator-unverified403A 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-unavailable409The 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-pending409The 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-unavailable409A 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-unmanaged404The 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.

ReleaseDateWhat it added
v0.0.182026-11-10Discharge 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.162026-11-05Laboratory 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.152026-10-30The 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.142026-10-28No 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.132026-10-25The 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.122026-10-24The 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.112026-10-16Prescriptions 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.102026-10-15EHDS 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.92026-10-05An 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.82026-10-04Every 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.72026-10-03Ready 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.72026-10-03Writes 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.62026-10-03Follow-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.62026-10-03Directed 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.62026-10-03Merged 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.62026-10-03Never 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.32026-10-02No 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.32026-10-02Transparent 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:

MilestoneScopeRelease
v0.0.1the repository setup and the architecture of record0.0.1, 2026-10-01
v0.0.2the Cargo workspace and the first federated query over two nodes0.0.3, 2026-10-02
v0.0.3identity resolution outside AQL (§5)0.0.3, 2026-10-02
v0.0.4the federated answer: completeness, timeouts, ordering, de-duplication (§9 to §11)0.0.6, 2026-10-03
v0.0.5the ITS-REST surface and follow-up routing (§7a, §12)0.0.6, 2026-10-03
v0.0.6targeting and self-description (§8, §7a.2)0.0.6, 2026-10-03
v0.0.7definitions and membership (§12.6, §12.7, §12b)open
v0.0.8security and the bindings (§13 to §15, Annex A, Annex B)open
v0.0.9conformance: 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):

CheckWhat it guards
zizmorworkflow security
actionlintworkflow correctness
shellcheckevery first-party shell script
hadolintevery first-party Dockerfile
kubeconformthe Kubernetes package under deploy/kubernetes/, its base and its example overlay rendered by kustomize, in strict mode
observabilitythe 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-stylethe comment budgets of the Rust sources
file-lengthno hand-written Rust file or book page over 1000 lines; a listed file may shrink and never grow
duplicationthe 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
versionsevery repeated pin agrees with docs/VERSIONS.md
favicon-syncthe book’s favicons match the brand mark
conformance-matrixthe 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-placementevery 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-spliceno 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
obligationsthe 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-documentationevery 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-iievery 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-iiievery 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-linksevery 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-helpersthe self-tests of the scripts/gh tracker helpers
crate-version-guard self-testthe crate-version guard judges only what a pull request changes, against a stub repository
manifestsevery 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:

  1. starts the head’s quickstart, the four FerroEHR nodes and FerroPIX from compose.yaml, with perf/compose.yaml publishing FerroPIX on loopback, seeded with docker/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 project ferrofed-perf-base on 127.0.0.2;
  2. starts a PostgreSQL server over TLS for FerroFED’s own store, with a database for each gateway;
  3. checks with ferrofed config check that 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;
  4. runs ferrohealth-perf ab over perf/plan.toml;
  5. plants a poller of the head’s store that never waits, and runs the idle phase of perf/busy-loop.toml beside 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:

  1. 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.
  2. 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):

GroupRequestToken
contact-point-summaryGET /fhir/Patient/$summary?identifier=urn:oid:2.999.1.1%7Cffd-test-0001the treatment token
contact-point-emergencythe same summary, as an emergency accessthe 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 _file sibling;
  • 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, localizer and prefilter, each built over the registry snapshot;
  • its transport sites: every URL a patient identifier or a credential travels to, held to https outside the development profile;
  • its reload rules: effective keeps the boot’s value of what takes a restart, and needs_restart names each such key that changed;
  • its self-description: the mode each role it builds carries, which OPTIONS {base}/ declares as localization.mode or consent.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, as binding-<name>, with the optional dependencies it brings in, and in default when 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 Config and its resolved settings in Settings, 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 with mapping = "iua" and authorization_details_purposes = true for every persons, patients or machines issuer whose callers declare a purpose of use; without it every such caller is refused purpose-of-use-required. A patients or machines table names no field but purpose_of_use. Move a [[trust.issuer]] professional_issuing_authority = "<claim>" to that table’s fields."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 CKM openEHR-EHR-CLUSTER.organisation.v1, and that archetype among its archetypes: the imaging mapping of eehrxf 0.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 under docs/specs/ferrobridge-imaging/ (#1860).

Changed

  • The quickstart runs FerroPIX 0.0.29, the newest release, whose private_key_jwt client 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 (a persons issuer) or its [[auth.federation]] table, as ferrohealth-smart reads them; the gateway’s own reader of the IHE IUA extension and the authorization_details purposes 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 PERSON it 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 four ferroehr-definitions-* one-shots load FerroEHR’s professional PERSON and ROLE templates, vendored under docs/specs/ferroehr-definitions/, before the nodes start. A quickstart volume from an earlier release holds no such templates or persons: run docker compose down -v once 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 persons issuer 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’s persons issuers once its gateway runs v0.0.20 and every FerroEHR endpoint has its onward lane (#1615).
  • The gateway runs on ferrohealth-smart 0.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 eehrxf 0.6.6 and later writes an Organization performer into the Imaging service slot of the examination result as an openEHR-EHR-CLUSTER.organisation.v1, over FerroBRIDGE’s imaging report template at v0.0.24. A performer that is no Organization is 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 the fields."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_s and the rest, #1800), the url, credentials, tls and timeout_ms keys of every outbound table that moved under its endpoint table (#1805, #1815), a node’s user, password, password_file, oauth and onward outside 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_ms by name, under the one-release rule for removal aids: v0.0.19 retired it for gateway.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]] or auth.scopeless_subjects no longer shows its password in ferrofed config check or in the configuration’s Debug rendering: it renders as https://***@…, as [[auth.issuer]] already did. The gateway moves to ferrohealth-smart 0.19, which holds the issuer of every configuration table as a SecretUrl, with ferrohealth-session 0.14, ihe-atna and ehds-logging 0.13, ferrobridge-term and ferrobridge-validator 0.6, eehrxf 0.7, and ihe-iti and nl-generic-functions 0.5, the releases on the same line (#1864).
  • The information sheet names the interoperability component this release ships, eehrxf 0.7.0, where it named 0.3.2, and the pin matrix names the eehrxf, fhirconnect, openehr-mapping-core, ferrobridge-term, ferrobridge-validator, ihe-atna and ehds-logging requirements 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-smart 0.15 as durations under keys that name no unit: on each [[auth.issuer]] clock_skew, jwks_max_age, jwks_refresh_interval and timeout; introspection.cache_max_age; [auth.dpop] max_age and clock_skew; on each [credentials."<id>".credentials.oauth] refresh_margin, timeout and previous_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_subjects entries as tables with issuer and sub (#1799).
  • Move [trust.issuer.patient] act_is_representative to act_represents_patient = true on the [[auth.issuer]] of kind = "patients". The old key refuses the start, naming its replacement. A patient token that carries an RFC 8693 act claim on an issuer without it is now refused as an invalid token (#1799).
  • In the operator console, write [oidc] timeout and [session] sign_in_timeout, idle_timeout and absolute_timeout as durations, and move [oidc] ca_file to [oidc.tls] trust_roots_file (#1799).
  • Move the [server] keys of ferrohealth-server 0.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_size and [server.tls] reload_interval, as in request_timeout = "30s". The old keys (request_timeout_ms and the others) are refused as unknown keys (#1799).
  • Move [telemetry.otlp] client_identity_file and trust_roots_file into [telemetry.otlp.tls], which also takes min_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_ttl and max_node_answer_size, federation.localization.timeout, gateway.bindings_drain_timeout, signing.node_jwks_cache and rotation_overlap, emergency.window, config.poll and confirm, store.timeout and sweep_interval, fhir.document_url_lifetime, eehrxf.terminology.endpoint.timeout, pdqm.timeout, pmir.timeout and check_interval, nl_gf.mitz.timeout, registry.mcsd.refresh_interval, deadline and max_size, audit.repository.spool_max_size and spool_write_timeout, audit.repository.endpoint.timeout and retry.max_backoff, and xcpd.audit_repository.spool_max_size, spool_write_timeout, connect_timeout, send_timeout and retry_max, with the FERROFED__ overrides to match. Each old key (per_node_timeout_ms, window_s, spool_max_bytes and the others) refuses the start and config check, naming its replacement. The operator console writes gateway.timeout in place of gateway.timeout_ms (#1800).
  • Move the address, timeouts, TLS and credential of [audit.repository] and [eehrxf.terminology] into their new endpoint table, ferrohealth-smart’s outbound table. [audit.repository] keeps hostname, source_id, enterprise_site and the spool_* keys, and [audit.repository.endpoint] takes url, timeout (from timeout_ms), retry.max_backoff (from retry_max_ms, the wait between two delivery rounds) and tls. [eehrxf.terminology.endpoint] takes url, timeout (from timeout_ms), tls and credentials; one call is tried once unless retry.max_attempts says more. Each old key refuses the start and config check, naming its replacement (#1805).
  • Move a node’s user, password, password_file, oauth and onward from [credentials."<id>"] into its nested [credentials."<id>".credentials] table, ferrohealth-smart’s credentials table, with the FERROFED__CREDENTIALS__<ID>__CREDENTIALS__ overrides to match. tls, nuts and onward_organisations stay in the section. Each old key refuses the start and config 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] is ferrohealth-smart’s credentials table, which refuses nuts as an unknown key (#1806).
  • Move the address, TLS and credential of every identity, localization and consent service into its endpoint table, ferrohealth-smart’s outbound table, as [audit.repository] and [eehrxf.terminology] moved: url, tls and credentials of [[pixm.manager]], [pdqm], [pmir], [registry.mcsd], [nl_gf.nvi] and [nl_gf.mitz] go to [<table>.endpoint], and the timeout of [pdqm], [pmir] and [nl_gf.mitz] (from timeout_ms) to endpoint.timeout. Each [[xcpd.gateway]] takes its own [xcpd.gateway.endpoint] with its url, and the shared [xcpd.tls] moves into the tls of each gateway’s endpoint table. A table you leave partly written keeps its defaults: one try per call, 1s for PDQm and Mitz, 5s for PMIR and 10s for the others. The NVI’s Nuts grant stays in [nl_gf.nvi.nuts]. Each old key refuses the start and config check, naming its replacement (#1815).
  • The release asset compose.yaml is now the self-contained quickstart. The gateway-only compose file of the production guide, which carried that name, is attached as gateway-compose.yaml: an operator who downloads it by name fetches gateway-compose.yaml and saves it as compose.yaml beside ferrofed.toml and registry.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 to BundleReportEuImaging, mapped by the imaging project eehrxf ships over FerroBRIDGE’s ADL 2 imaging report template, and written to the declared member by its own ehr_id alone, 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 incomplete issue of its OperationOutcome, so the sender is told what the member’s record holds only in the original document (eehrxf 0.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-smart 0.15.1, ferrohealth-session 0.9.0, ihe-atna and ehds-logging 0.9.1, ihe-iti 0.0.84 and nl-generic-functions 0.0.51, with FerroBRIDGE’s eehrxf, ferrobridge-term and ferrobridge-validator 0.2.0, fhirconnect 0.1.249, openehr-mapping-core 0.1.151 and the openehr-* crates at 0.0.136. A patient’s representative is the caller ferrohealth-smart classifies as one, and the gateway no longer reads the act claim itself. A node grant’s own tls table beside the node section’s TLS identity is refused, naming both (#1799).
  • Moved ferrohealth-config to 0.4.1, ferrohealth-telemetry to 0.4.0, ferrohealth-server to 0.8.0, ferrohealth-usage-report to 0.4.0 and the performance gate’s ferrohealth-perf to 0.8.0. The OTLP collector’s TLS is read and handed to the export by ferrohealth-server’s telemetry::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-config duration or size, so the file, the FERROFED__ overrides, config check, ferrofed report and the operator console’s settings show one form, such as "10s" or "16MiB" (#1800).
  • The gateway runs on ferrohealth-smart 0.16.3, ferrohealth-session 0.10.0, ihe-atna and ehds-logging 0.10.0, ihe-iti and nl-generic-functions 0.2.0, and FerroBRIDGE’s eehrxf, ferrobridge-term and ferrobridge-validator 0.3.0. The audit repository and the terminology server are reached through ferrohealth-smart’s outbound client, so [eehrxf.terminology] also takes a client credentials grant. A node’s grant presents the node’s TLS identity through ferrohealth-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_timeout less 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 refused 503 access-unrecorded while 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, through ihe-iti and nl-generic-functions 0.2.0, one client per XCPD responding gateway. A service that refuses a token with 401 is 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 the oauth grant 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-server 0.8.4 with ferrohealth-config 0.4.4 and ferrohealth-secret 0.2.1, so a load, a reload or config check no 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-smart 0.18.0, ferrohealth-session 0.13.0, ferrohealth-config 0.4.4, ferrohealth-server 0.8.4, ihe-atna and ehds-logging 0.12.0, ihe-iti and nl-generic-functions 0.4.0, and eehrxf 0.6.1 with fhirconnect 0.2.1, ferrobridge-term and ferrobridge-validator 0.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, Debug or serialized: 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 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 one openssl line; and an ehr-seed service 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 as gateway-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 tls sub-table, [credentials."<id>".oauth2], .fapi2 and mtls_alias_hosts and their service forms, [trust.issuer.national_contact_point], [trust.issuer.requester], [trust.issuer.assurance], client_tokens_act_for_professional, and [stored_queries] url and url_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 Organization as its Composition.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 to BundleReportEuImaging over the Imaging Report package closure, which the package set now pins (eehrxf 0.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 eehrxf ships 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’s basedOn are 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 NameID as ehdsi.assertion.name_id_format, one of the three formats eHDSI SAML Profile 10.0.0 §2.1 allows. ferrohealth-smart 0.9.4 refuses a contact-point token without it, as it refuses one without the NameID (Authentication, #1615).
  • Run docker compose down -v once 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 a ferroehr-pgdata volume 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]] of kind = "federation" with sender_constraint = "required", an [[auth.federation]] table with contact_point = true, mapping = "ehdsi", its countries and the constant professional.country_code, professional.issuing_authority_name and provider.issuing_authority_name, and a [[federation.contact_point]] naming the issuer, with correlation_header and additional_countries moved there. Have the contact point’s authorization server write the assertion’s attributes under ehdsi, the TRC patient as ehdsi.patient.id, and bind the token to the connector’s client certificate (National contact point, #1632).
  • With [stored_queries] backend = "postgres", move url or url_file from [stored_queries] to a new [store] table, pointed at the database the backend uses now, and set sslmode=require in the connection string, with ca_file when the database’s CA is not one of the platform’s roots. This release refuses [stored_queries] url and url_file at start-up and in config check, naming each, and a [store] without sslmode=require under 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, naming federation.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 its kind: persons for your professionals, patients for an issuer whose patient/ grants [trust.issuer.patient] binds, machines for systems under the client credentials grant alone, or federation with its [[auth.federation]] table. Move audience, algorithms, clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds, jwks_file, timeout_ms and role_claims from [auth] onto every issuer table they held for, and name the issuer’s profile when it is not a standards-only server (entra, keycloak, okta or nl). The start refuses a section-level key and an issuer without kind, naming each (#1682).
  • On each persons issuer, list your own applications in first_party_clients, and a third-party client or an RFC 8693 actor in delegates; a person’s token through any other client is refused (#1682).
  • Move an introspected issuer from [[trust.issuer]] to its own [[auth.issuer]] with mode = "introspection" and an [auth.issuer.introspection] table: endpoint, and the client authentication in [auth.issuer.introspection.client] (client_id, client_secret or client_secret_file). Keep its [[trust.issuer]] policy, without the introspection keys (#1682).
  • Rename [metrics] scrape_users to scrape_clients and [pmir] feed_users to feed_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 an oauth2 client-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 a levels map from each claim value to low, substantial or high), move [trust.issuer.requester] to the nl.professional_number, nl.role, nl.ura and nl.organisation_type fields of the caller issuer’s [[auth.federation]] table, and remove client_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’s jwks_file: ferrohealth-smart 0.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’s mtls_alias_hosts into it. This release refuses each removed key at start-up and in config check, naming the oauth key that replaces it (Onward credentials, #1749):
    • The grant’s key moves from [signing] to the oauth table’s own private_key_file, with previous_private_key_file and next_private_key_file for a rotation. The gateway publishes it in its JWK Set beside the [signing] keys. Pointing private_key_file at the [signing] key file keeps the kid the node’s authorization server knows; a new key needs its kid registered there, or the JWK Set fetched again, before you switch.
    • grant = "token_exchange" becomes an onward table beside the oauth table, with kind = "persons", the exchanged tokens’ issuer, audiences and indicators naming the node in place of resource, actor_assertion = true, and [onward.keys] max_onward_lifetime. The node’s authorization server must issue a JWT access token that names that issuer, an aud among audiences, and the gateway as act or client; an opaque exchanged token now fails the node.
    • A fapi2 table becomes an oauth table with security_profile = "fapi2", client_auth = "private_key_jwt", assertion_audience = "issuer", issuer, and client_key_file and previous_client_key_file renamed private_key_file and previous_private_key_file.
    • tls_client_certificate_bound_access_tokens = true becomes certificate_bound = true. A grant that authenticates by, or binds its tokens to, the TLS client certificate presents the node section’s tls identity; client_certificate_file, client_key_file and ca_file in its oauth table are refused.
  • Give every node’s oauth grant client_auth = "private_key_jwt", "tls_client_auth" or "self_signed_tls_client_auth". A node’s grant that names client_secret_basic, client_secret_post, client_secret or client_secret_file, or leaves client_auth unset, 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_s must be at least 300 plus signing.node_jwks_cache_s (#1749).
  • Move each table’s outbound TLS keys, client_identity, client_identity_file and trust_roots_file, into its tls sub-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 in config check, naming the tls key 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 tls sub-table lowers it with min_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_file and trust_roots_file of [xcpd.audit_repository] into its tls sub-table, [xcpd.audit_repository.tls], as every other outbound table took them with #1750; the sub-table also takes min_version, "1.3" unless lowered to "1.2". This release refuses a key left on the table at start-up and in config check, naming the tls key 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, typed 18842-5, read as an HL7 Europe Hospital Discharge Report 1.0.0 bundle-eu-hdr written by the shipped HDR mapping. The reserved stored query eu.ferrofed.eehrxf::discharge-report selects the compositions. A Laboratory-Reports entry 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-rest endpoint declared for Discharge-Reports): the member is asked by its own ehr_id alone with Patient?identifier= and DocumentReference?patient=&type=, the type naming the codes of eehrxf-document-type-discharge-report-vs (18842-5, 100719-4) the search admits. Each of its DocumentReferences is listed as it holds it, with each attachment URL readdressed to a Binary/{token} URL the gateway signs, and the read answers the member’s Binary unchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded under Discharge-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, typed 85430-7, read as an HL7 Europe Imaging Report 1.0.0-ballot BundleReportEuImaging written by the shipped imaging mapping. The reserved stored query eu.ferrofed.eehrxf::imaging-report selects 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-rest endpoint declared for Medical-Imaging): the member is asked by its own ehr_id alone with Patient?identifier= and DocumentReference?patient=&type=, the type naming the codes of eehrxf-document-type-medical-imaging-vs (85430-7, 18748-4) the search admits. Each of its DocumentReferences is listed as it holds it, with each attachment URL readdressed to a Binary/{token} URL the gateway signs, and the read answers the member’s Binary unchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded under Medical-Imaging.
  • A national contact point’s dispensation and its discard follow the MyHealth@EU rules. A token that lists permissions without PPD-046 is refused permission-missing with ERROR_SEC_GENERIC on a dispensation and discard-refused with ERROR_ED_DISCARD_FAILED on 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 table ferrofed.contact_point_dispensation, with the identity assertion’s organization-id and the facility identifier on performer.actor. A discard is admitted only within those 24 hours and from that organisation and facility; otherwise it is refused discard-refused before 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 saml2 subject_token, the treatment relationship confirmation in trc_token, both checked as ferrohealth-smart’s eHDSI validator checks them, and the ehdsi token it issues. The test suite’s harness authorization server answers that exchange (A national contact point, #1620).
  • An onward lane takes cache = false in 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, as system|value. A token without it is 403 trc-missing. Another patient, a resource of another patient, and a patient no member holds get one answer: 404 not-found with trc-patient-mismatch and the service’s MyHealth@EU *_NOT_FOUND code. A mismatch is logged as a trc-patient-mismatch security 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 marked myhealth_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 fixed assertion or assertion_file, and one without https, client_identity_file or trust_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] threshold and window_s, and a looser limit refuses the configuration naming §2.5. Past the limit the request is denied 403 emergency-limit-reached before any member is asked, with ERROR_SEC_GENERIC on the FHIR face, an access record carrying the emergency mark and the warning EHDSI_WARNING_SEC_UNEXPECTED_NUMBER_OF_REQUESTS, and the security event emergency-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 refused 503 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 FHIR OperationOutcome’s issue.details, and at the head of the access record’s outcome description. A refused token takes the code ferrohealth-smart 0.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 is url or url_file, a secret, and must set sslmode=require under every profile, verified against ca_file or the platform’s roots; max_connections, timeout_ms and sweep_interval_s bound 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. The store readiness 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 DPoP requests (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 refused 401 with the DPoP challenge invalid_dpop_proof. 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 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 a DPoP request is refused 503 (#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_patient claim is 403 trc-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 an ehr_id must 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: 404 subject-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 are 403 trc-unbound. Each break of the binding is logged as the trc-patient-mismatch security 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 under federation.national_contact_point.emergency, as threshold and window_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 eehrxf 0.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); a Medical-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), through ferrohealth-smart’s onward client, which checks and caches each issued token. A person is carried at a persons issuer, a professional of another domestic party at a federation issuer and a patient at a patients issuer; 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’s oauth table keeps for the gateway’s own requests (#1703).
  • A CONTRIBUTION sent through a node’s onward lane names, in its audit and in every version’s commit_audit, the committer placeholder FerroEHR takes and discards, naming the iss and sub of the onward token the node authenticates; a person’s attestation takes the same placeholder, and an attestation through a federation or patients lane is refused 403 before 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 federation carries another domestic party’s organisation, a request with no named person, where its credentials section sets onward_organisations = true. The organisation reaches the node read-only: a write is refused 403 before any token is asked for. A lane with correlation_claim refuses 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 project eehrxf ships over FerroBRIDGE’s ADL 2 discharge report template, and writes it to the declared member by its own ehr_id alone, with its access record. A [[fhir.receive]] entry takes an ADL 2 template with its archetypes beside 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.sh holds it to the vendored text in CI.

Changed

  • Moved the openehr-* crates to 0.0.129, with FerroBRIDGE’s openehr-mapping-core 0.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-telemetry to 0.2.5 and ferrohealth-usage-report to 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 report writes 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-smart 0.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-smart 0.10.1, ferrohealth-session 0.4.0, ihe-iti 0.0.78, nl-generic-functions 0.0.45, and ihe-atna and ehds-logging 0.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 an asserted-field, and the provider acted for as acts-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: a patient.id whose system holds whitespace, or whose value holds a control character or an HL7 v2 delimiter, binds no patient, so the request is refused trc-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).
  • A national contact point’s patient-data request whose token names no patient of a treatment relationship confirmation (trc-missing) now carries ERROR_SEC_DATA_INTEGRITY_NOT_ENSURED (MyHealth@EU Requirements Catalogue 10.0.0 requirement 11.01.02) in its ITS-REST ncp_error and its FHIR issue.details, where it carried ERROR_SEC_GENERIC: the TRC patient is a missing assertion attribute, and the family answers one way (FerroHEALTH/FerroEHR#5102). A national connector that mapped trc-missing by 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 its ferrohealth-smart field name (#1632).
  • The postgres backend of the stored-query registry keeps its definitions in FerroFED’s own store and reads its connection from [store]. [stored_queries] url and url_file are 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 the postgres feature of ferrofed-server is gone (#1636).
  • The revocation check of a MyHealth@EU gateway is the family’s shared one, ferrohealth-server 0.5.1 feature revocation, 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-smart 0.9.4, ferrohealth-session 0.3.1, ferrohealth-config 0.2.10, ferrohealth-ui 0.6.8, ihe-iti 0.0.76, nl-generic-functions 0.0.43, and ihe-atna and ehds-logging 0.4.0 (#1682). Every token is classified under its issuer’s kind and profile, and one that names no caller the kind admits is refused as an invalid token, its check no caller kind in the security log. A persons token is admitted only through a client the issuer lists in first_party_clients or delegates, and an RFC 8693 act claim only from a listed delegate. The access record names how a contact point relayed its accessor in relayed-via and relayed-issuer, and the gate refuses a contact point’s token whose provider identifier is no urn:oid: or urn:hl7ii: identifier, or whose role is ISCO 44 without on-behalf-of.
  • Every [[auth.issuer]] holds its own audience, which takes gateway.public_url when unset; a changed audience of an issuer the gateway already trusts waits for a restart, named auth.issuer.audience (#1682).
  • An [[auth.issuer]] can be set from the environment by element index, such as FERROFED__AUTH__ISSUER__0__KIND=persons, through ferrohealth-config 0.2.10 (#1682).
  • The test harness’s revocable mutual-TLS front reads its certificates and writes its CRL and OCSP answers with der 0.8.3, x509-cert 0.3.0 and x509-ocsp 0.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 reaches der 0.7 and x509-cert 0.2 through ferrohealth-server 0.5.1 and tokio-postgres-rustls 0.14.0 (#1690).
  • Moved FerroBRIDGE’s eehrxf to 0.1.170, ferrobridge-term to 0.1.143, fhirconnect to 0.1.227 and ferrobridge-validator to 0.1.149, so the terminology client runs on the same ferrohealth-smart 0.9 line as the gateway. ferrofed config check and the CapabilityStatement now name Medical-Imaging and Discharge-Reports among 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_clients or pmir.feed_clients names, which ferrohealth-smart classifies as a system’s, and answer any other request 401 with a Bearer challenge, or 403 for another client’s or a person’s token (#1704).
  • A token ferrohealth-smart classifies as a system’s, or that only a system/ scope covers, reaches no patient data whatever professional it names, and the assurance level of every caller is the one ferrohealth-smart assessed under its issuer’s [auth.issuer.assurance]. The Mitz consent pre-filter reads who asks from the NL fields of a domestic federation issuer’s [[auth.federation]] table (#1704).
  • Moved the openehr-* crates to 0.0.130, with FerroBRIDGE’s eehrxf 0.1.179, fhirconnect 0.1.235, openehr-mapping-core 0.1.144, ferrobridge-validator 0.1.151 and ferrobridge-term 0.1.145. A map-mode member’s operational template now names its parent archetype by its full id, …v1.0.0, as openehr-adl builds 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 eehrxf 0.1.181 with fhirconnect 0.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 a MedicationStatement in 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-smart 0.13.0, ferrohealth-session 0.7.0, ihe-atna and ehds-logging 0.8.0, ihe-iti 0.0.83 and nl-generic-functions 0.0.50 (#1748, #1766):
    • An [[auth.issuer]] URL that carries a user name or password, and a jwks_file that 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-vouches assertion 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).
  • 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_ms of its own.
    • A token exchange runs through the node’s onward lane. With no scope on its onward table it asks for the caller’s covering scopes and refuses a call none covers before anything is sent; a configured scope is asked for as written. An exchanged token bound with DPoP reaches the node under the DPoP scheme, and one the node answers 401 is exchanged again.
    • The issuer of a node’s oauth grant is held to the cleartext policy, as its token endpoint is: https outside the development profile.
  • Every outbound client reads its TLS material through ferrohealth-server 0.6.4’s TlsClient, from a tls sub-table that also takes min_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 serve draws its startup banner with the family’s ferrohealth_server::build_info::banner: the wordmark, the build line ferrofed <version>, and one aligned line per fact. The tagline, the maintainer, the manufacturer and its postal address are now the lines Product, Maintainer, Manufacturer and Address, 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 ETag of a new EHR or a new version is read with openehr-its’s entity tag, weak or strong, into the typed HIER_OBJECT_ID or OBJECT_VERSION_ID. A tag that names no such id is passed over for the Location, where any non-empty tag was taken before.
  • Moved the openehr-* crates to 0.0.135, with FerroBRIDGE’s eehrxf 0.1.187, fhirconnect 0.1.241, openehr-mapping-core 0.1.148, ferrobridge-validator 0.1.154 and ferrobridge-term 0.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 with openehr-its’s ITS-REST types: the names and versions accepted and refused are the same, and the registry still refuses the reserved aql wherever 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-types value tree. A page whose entry, 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.type and Bundle.identifier through the fhir-types value tree, and an element FHIR R4 does not define still never refuses it. A success that is no document Bundle with an identifier is now logged at WARN, naming the member’s endpoint and what the answer misses, never a value it carries (#1760).
  • [xcpd.audit_repository] reads its TLS material through ferrohealth-server’s TlsClient, 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 unless min_version lowers it (The audit repository, #1766).
  • SECURITY.md names security@cadasto.com as the email route for a vulnerability report, in place of info@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] keys audience, algorithms, clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds, jwks_file, timeout_ms, mode and role_claims, each refused naming the [[auth.issuer]] table it moved to (#1682).
  • The [[trust.issuer]] keys introspection_endpoint, client_id, client_secret and client_secret_file: an introspected issuer is an [[auth.issuer]] with mode = "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_ms and server.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-smart 0.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’s NameID, and a purpose of use other than TREATMENT or EMERGENCY, or a token missing the facility type, the home community, the point of care, the authentication class or on-behalf-of for an ISCO 44 role, is refused 403 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 report shows 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_ms and server.overload_retry_after_s) is now refused at start-up and in config check naming the key that replaces it, whether it is set in the file or as a FERROFED__ 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 as patient-context-unavailable, the event of a patient/ grant. The answer stays 424 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 404 is 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 refused 403 trc-missing with nothing sent. It was refused at admission as contact-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 Patient carries the name and birth date the demographics binding holds, and each one the binding does not hold is written with a data-absent-reason of unknown (HL7 Europe Base 2.0.1 patient-eu-core, eu-pat-1). The member the report was read at is a Composition.author, so the document rules of FHIR R4 admit its Organization. A demographics binding that cannot answer, or names no one patient, refuses the read with an OperationOutcome and no document is written (#1777).

  • The production guide’s example node grant authenticates with private_key_jwt and a private_key_file, and says how to make that key. The client_secret_basic grant it showed is refused by ferrofed config check since 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-tar is unmaintained, with no patched version) does not affect FerroFED: testcontainers reaches the crate only in the test harness, and the server binary, the ferrofed image and the ferrofed-console image do not contain it. deny.toml accepts the advisory until a testcontainers release 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_claims applied to every trusted issuer’s tokens. ferrohealth-smart 0.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 as role_claims, read from that issuer’s tokens alone (#1682). Upgrade step: remove role_claims from [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 IUA subject_role). scopeless_subjects stays refused as before.
  • A Basic user never holds a system/ scope, through ferrohealth-smart 0.9.1 (GHSA-jhg2-jwjp-hq74, CWE-269, CWE-863). Before it, an [[auth.basic.users]] entry granted a system/ 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 a persons issuer, and a machine to a client credentials client of a machines or persons issuer (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-smart before 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 at warn when a node’s grant obtained no token. A jwks_file holding 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 on ferrohealth-smart 0.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-smart before 0.13.1 kept the body, up to 1 MiB, in KeySetStatus, IntrospectionError::Status and DiscoveryError::Status, and printed it in their Debug rendering, 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 their Display alone, 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.replica and server.bindings_drain_timeout_ms as gateway.public_url, gateway.replica and gateway.bindings_drain_timeout_ms, and server.overload_retry_after_s as server.retry_after_s, with the FERROFED__ overrides to match (FERROFED__GATEWAY__PUBLIC_URL). This release refuses each old key at start-up and in config check (#1490).
  • Nothing in an operator console’s configuration has to change (#1504). The [oidc] endpoints are now optional: remove authorization_endpoint, token_endpoint and jwks_uri to have the console discover them from issuer when it starts, which then needs the provider reachable at start. A reverse proxy in front of the console passes /session through to it.
  • Remove [server] site_root and FERROFED_CONSOLE__SERVER__SITE_ROOT from the operator console’s configuration: the console serves no site bundle, and this release refuses the key at start-up and in config 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-rest endpoint declared for Laboratory-Reports): the member is asked by its own ehr_id alone with Patient?identifier= and DocumentReference?patient=&type=, and is left out of the laboratory report query. Each of its DocumentReferences is listed as it holds it, with each attachment URL readdressed to a Binary/{token} URL the gateway signs, and the read answers the member’s Binary unchanged. A member that does not answer fails the list under all-or-nothing, and each list and read is recorded under Laboratory-Reports.
  • The FHIR face withdraws a dispensation it received: PUT {fhir-base}/MedicationDispense/{id} with status entered-in-error, by the id the receipt’s Location named, is held to MedicationDispense-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-level id (FHIR R4 http.html §3.1.0.4). A body whose id is not the request’s is 400, any other status 422, the member’s refusal 409 and its silence 504; the withdrawal is recorded as an update under Electronic-Dispensations, and metadata declares update for MedicationDispense. MyHealth@EU 07.04 is met (#1468).
  • The operator console serves the session watch, GET /session and POST /session, which answer the idle and absolute lifetime the session has left and 401 once 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 for Laboratory-Reports with the HL7 Europe Laboratory Report 2.0.0 package, the KDS_Laborbericht template and the laboratory mapping eehrxf ships, 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 receives Laboratory-Reports: one EehrxfMhdDocumentReference of the type 11502-2 per laboratory composition, read at a Binary/{token} URL the gateway signs, which writes the composition as an HL7 Europe Laboratory Report 2.0.0 Bundle-eu-lab through the laboratory mapping of eehrxf. A member that does not answer the laboratory query fails the list under all-or-nothing, and each list and read is recorded under Laboratory-Reports.
  • Two read-only stored queries in the reserved namespace, eu.ferrofed.eehrxf::medication-orders and eu.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 of Electronic-Prescriptions can 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 with eu.ferrofed.eehrxf::medication-orders, keeps the compositions the member’s own system created, and maps each into an HL7 Europe MPD 1.0.0 MedicationRequest-eu-mpd through the MPD project of eehrxf, listed beside the native members’ prescriptions and read by the id the search names. The start and config check refuse 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-mpd is mapped by the MPD project of eehrxf, its ACTION names the order it carries out by the order’s OBJECT_VERSION_ID, and the face creates it once with composition_create. A map-mode member named in Electronic-Dispensations names the language and territory its compositions are written in, and config check refuses it without them. A dispensation the owner already keeps under the same business identifier is refused 409 with nothing written.

Changed

  • Moved to the openehr-* crates 0.0.116 with FerroBRIDGE’s eehrxf 0.1.142, fhirconnect 0.1.212, openehr-mapping-core 0.1.138, ferrobridge-term 0.1.141 and ferrobridge-validator 0.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 base eehrxf 0.1.142 writes, in place of https://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 for Laboratory-Reports, which eehrxf 0.1.142 receives as an HL7 Europe Laboratory Report 2.0.0 document; config check names it among the categories with no receiving member until one does.
  • The remaining-dispensations extension is named https://ferrobridge.eu/fhir/StructureDefinition/remaining-dispensations, as eehrxf 0.1.142 defines it and FerroBRIDGE publishes it, and the documentation site no longer serves a copy at https://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.css and the site bundle under /pkg/. The console image carries the binary alone.
  • The operator console moves to FerroUI 0.6.2 and ferrohealth-server 0.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 the ehr_id index as before, and now, when none of them names a node and the path carries a version uid, by the member whose system_id is that uid’s creating_system_id; an untargeted read of such a version reaches its creator without an ask-all probe, and a composition DELETE whose version a member created reaches that member where it was a 400 target-required. A read that reaches a node holding an imported copy returns the copy with openEHR-federation-system-id naming that node, and a write that reaches it is refused 409 naming the controlling system and is never redirected. OPTIONS {base}/ still reports spec_version "1.0".
  • The operator console signs in and keeps its sessions through ferrohealth-session 0.1.1, the family’s console sign-in, over ferrohealth-smart’s relying party; its own sign-in, session store and code exchange are gone (#1504, #1506). The routes stay GET /login, GET /auth/callback and POST /logout, and the cookies keep their names.
  • With [oidc] issuer alone, 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, and config check never asks the provider. [oidc] gains revocation_endpoint, ca_file and timeout_s, and [session] gains store, key and key_file for a console that runs as several instances.
  • A sign-out with no [oidc] ends the session and lands on / in place of answering 503, 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-iti 0.0.73 and nl-generic-functions 0.0.40 (#1516). The mCSD directory is read through ihe-iti’s DirectoryEntry<Endpoint>, and the NVI client and the Nuts grant take the request authorizer ihe-iti now carries. The gateway’s behaviour does not change.
  • Moved to FerroBRIDGE’s eehrxf 0.1.149 with fhirconnect 0.1.218, openehr-mapping-core 0.1.141 and ferrobridge-validator 0.1.144, on the openehr-* crates 0.0.128.
  • The patient summary selects its medication entries by the template section that holds them, as eehrxf’s crosswalk::patient_summary::SOURCES places 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 2 template with the archetypes it specialises, beside an OPT 1.4 .opt.
  • Moved to ferrohealth-server 0.4.3, ferrohealth-session 0.2.2, ferrohealth-smart 0.8.8, ferrohealth-ui 0.6.6, ihe-iti 0.0.74, nl-generic-functions 0.0.41, and FerroBRIDGE’s eehrxf 0.1.152 with fhirconnect 0.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_endpoint only 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), the scripts/release/console-site.sh, console-tools.sh, scripts/checks/console-bundle.sh and console-paths.sh scripts, and the Leptos, cargo-leptos, wasm-bindgen, wasm-opt and leptosfmt pins (#1457). A deployment that built the console itself runs cargo build --release -p ferrofed-console alone.
  • openehr-federation 0.0.57 drops object::Extra::new and order::ResultOrder::unordered, each the same as the type’s Default. A caller writes Extra::default() and ResultOrder::default() (#1485).
  • The four [server] keys that 0.0.15 still read under their old names: server.public_url, server.replica and server.bindings_drain_timeout_ms, now only gateway.public_url, gateway.replica and gateway.bindings_drain_timeout_ms, and server.overload_retry_after_s, now only server.retry_after_s. An old key is an unknown key, which serve and config check refuse by its name, and config check and the start-up log no longer name deprecated keys (#1490).
  • [server] site_root of the operator console, and its FERROFED_CONSOLE__SERVER__SITE_ROOT override: the console serves no site bundle, so the key is an unknown key, which serve and config check refuse 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 status entered-in-error is refused 409 duplicate like 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 sets client_ca_file must set client_auth = "required" beside it to keep requiring a client certificate (or "optional" to verify one only when a client presents it); without it config check and serve refuse the table. A listener whose clients speak TLS 1.2 alone needs min_version = "1.2" in its table, since the default is TLS 1.3.
  • Move a liveness probe of the gateway from GET {base}/health to GET {base}/health/liveness. {base}/health now follows readiness and answers 503 while 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_endpoint becomes [telemetry.otlp] endpoint, [telemetry] trace_sample_ratio becomes sample_ratio, and the trust_roots_file and client_identity_file of [telemetry.otlp_tls] move into [telemetry.otlp]. [metrics] otlp_endpoint becomes telemetry.otlp.metrics = true (the default) beside the same endpoint, and [metrics.otlp_tls] goes with it: the spans and the metrics now share one collector. A deployment that pushed only metrics sets traces = false; one that exported only spans sets metrics = false, or its collector receives the metrics too. An inline client_identity is no longer read: write the PEM to a file and name it in client_identity_file.
  • [server] is the section of ferrohealth-server, and the gateway’s own keys moved to a new [gateway] table: move server.public_url, server.replica and server.bindings_drain_timeout_ms there, and rename server.overload_retry_after_s to server.retry_after_s. This release still reads each old key under its new name and names it in config check and the start-up log; FerroFED 0.0.16 refuses it, and setting an old key beside its new one is refused now. The FERROFED__ overrides follow: FERROFED__GATEWAY__PUBLIC_URL, FERROFED__GATEWAY__REPLICA.
  • server.base_path writes the root as the empty path, its default: remove base_path = "/", or write base_path = "". A / is refused.
  • [server.caller_rate] needs both requests_per_second and burst, 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_ms now 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. Keep idle_timeout_ms longer 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 with ferrohealth-server 0.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 on SIGTERM, within the same server.drain_delay_ms and server.shutdown_timeout_ms. A listener with TLS speaks TLS 1.3 alone unless its table sets min_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] take client_auth (none, optional or required): a client certificate is asked only when it says so, and a client_ca_file under client_auth = "none", the default, is refused, where it used to mean a required certificate. The tables also take reload_interval_s, which reads the files again on that period beside SIGHUP and 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-server 0.3.7 over the indicator registry and lifecycle of ferrohealth-health 0.1.1; FerroFED’s own registry, lifecycle and readiness document are deleted (#1397). GET {base}/health/liveness is the liveness route, 200 while the process serves. GET {base}/health now answers the status of readiness with {"state":"up"} or {"state":"down"}, so it is 503 while the gateway boots or drains. GET {base}/health/readiness lists indicators as an array of {"name", "state"} sorted by name, where it was an object keyed by name, and carries no detail. Point a liveness probe at {base}/health/liveness: the example Kubernetes StatefulSet does, and a deployment that copied it before moves its livenessProbe path 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-secret 0.2.0, the FerroHEALTH family crate, and read every <key>_file sibling by its key-or-file rule; FerroFED’s own copy in ferrofed-registry is 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 read and <key>_file names <path>, which is empty.
  • The installation report is sent by ferrohealth-usage-report 0.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 its status, 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 sent 0, the boot line with the report on ends with the family’s wording (“no clinical content, identifiers, URLs or request paths”), and an endpoint the collector rule refuses is refused as an invalid value of usage_report.endpoint while the configuration is read.
  • The console, the request log, the HTTP server metrics, the Prometheus exposition and the OTLP export are ferrohealth-telemetry 0.2.3, the FerroHEALTH family crate; FerroFED’s own telemetry module and request log are deleted (#1397). The request line is written under the target ferrohealth_telemetry::request, with the fields it had. http.server.request.duration follows the OpenTelemetry HTTP conventions to the letter: its buckets end at 10 seconds, where FerroFED added a 30-second bucket, it carries network.protocol.version, and the method QUERY is named rather than _OTHER. GET /metrics answers with the content type text/plain; version=0.0.4, without the charset parameter. One [telemetry.otlp] table now names the collector both the spans and the metrics go to, with traces and metrics (both true unless set), sample_ratio, trust_roots_file and client_identity_file; [telemetry] environment names deployment.environment.name on both. [telemetry] logged_query_parameters is refused, since the request log carries the offset and fetch paging values alone. A collector’s TLS files are read and checked when serve starts, where config check used to read them too.
  • A [server] value of the wrong type or form (listen, base_path, trusted_proxies, a zero header_read_timeout_ms or max_concurrent_requests, a [server.tls] without its certificate_file or key_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 by ferrohealth-server, behind the same server.trusted_proxies and server.forwarded_header.
  • The gateway reads its configuration with the family’s loader (ferrohealth-config 0.2.8). Every subcommand takes --set key=value, repeatable, applied over the file and the FERROFED__ overrides, and a reload of the file applies the same pairs again. A refusal names the dotted key and the variable or --set pair 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 --set pair set as set. An override such as FERROFED__LISTEN with 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. Its type is the code’s anchor on the book’s Errors and status codes page, its detail the sentence the ITS-REST Error carried as message, its instance the request id as a urn:uuid: URI, and code and request_id stay as extension members, so a client that branches on code keeps working. A client that read message reads detail. A failed fan-out still answers its RESULT_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 an OperationOutcome. The operator console reads the new body.
  • A 408 past the request timeout and a 413 over the body limit answer an about:blank problem with the gateway’s request id as instance, 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[].error by the problem’s detail, or its title when 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 no system), where it wrote that path as what.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 keeps what.reference (MedicationRequest/<id>). This comes with FerroAUDIT ihe-atna and ehds-logging 0.3.2.
  • The client listener serves through the stack of ferrohealth-server 0.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 beside server.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 = false turns it off and server.compression_min_bytes sets the size.
  • A 500 internal for a request whose handler panicked, a 503 overloaded and a 429 rate-limited name the gateway’s own request id in the body’s request_id, where they named the client’s X-Request-Id; the response header still echoes the client’s id.
  • The panic line comes from the family’s panic hook (ferrohealth-telemetry 0.2.4): it is logged under the target ferrohealth_telemetry::panic with the message a thread panicked; the message is withheld, and names the source as file and line with the thread, where FerroFED’s own hook wrote a thread panicked with one location field. It still names the request by the gateway’s request_id and never the panic message. A log alert that matched the old target, message or location needs 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 a 5xx (502), the face’s OperationOutcome now carries the member’s own issues after the gateway’s, unaltered, when the member’s body is an OperationOutcome, 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_id is the version’s creating_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 sets client_ca_file while client_auth is "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, and config check prints the running configuration with every secret redacted; it serves and drains through ferrohealth-server, with its browser security headers and same-origin check; and it logs through ferrohealth-telemetry, whose [telemetry] section and RUST_LOG choose what it logs. It serves the family’s health routes: GET /health/liveness, GET /health/readiness, which answers 503 once the console drains, and GET /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 healthcheck and the live parts of ferrofed report ask the gateway through the healthcheck client of ferrohealth-server, which trusts only the certificate server.tls.certificate_file (or metrics.tls.certificate_file) holds and presents server.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 as cannot connect to 127.0.0.1:8080.
  • ferrofed healthcheck prints the line every FerroHEALTH product’s healthcheck prints, from ferrohealth_server::healthcheck::Outcome. The old ready is now healthy; not ready: readiness answered <status> is now unhealthy: readiness answered <status>; not ready: no answer within 3000 ms is now unhealthy: 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 the not ready: … of a configuration that does not load are now unhealthy: <reason>, with the cause chain as the reason; and the replaced listener certificate reads unhealthy: the listener presents a certificate other than server.tls.certificate_file holds; send SIGHUP so the gateway reads it. The exit codes are unchanged (0 only on 200, 1 otherwise). A probe, log rule or alert that matched ready or not ready must match healthy and unhealthy instead (note that healthy is a substring of unhealthy).
  • FerroFED runs on ferrohealth-server 0.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 with 401 before and after.
  • The interoperability component is the published eehrxf 0.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. fhirconnect moves to 0.1.204, openehr-mapping-core to 0.1.135, ferrobridge-term and ferrobridge-validator to 0.1.141 and the openehr-* 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 at https://ferrofed.eu/fhir/StructureDefinition/remaining-dispensations is 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}/MedicationDispense must carry its own business identifier, an identifier with a system and a value; one without it is refused 422 required, naming MedicationDispense.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.yaml or docker/quickstart/ferropix/ferropix.toml, carry FerroEHR 5.5.0’s and FerroPIX 0.0.10’s renames over: in ferroehr-config, server.bind is server.listen, each Basic user’s roles are its own roles list in place of [authz.rbac.basic_roles], and [ehr_index] provider = "local" is required; each node sets FERROEHR_CONFIG, FERROEHR__REST__SYSTEM_ID and FERROEHR__TELEMETRY__FORMAT in place of FERROEHR__SERVER__SYSTEM_ID and FERROEHR__LOG__FORMAT; FerroPIX’s server.public_base_url is the top-level public_base_url. Run docker compose down -v once before the first start on the new images (#1386).

Added

  • eehrxf 0.0.32 carries the discharge report crosswalk under its discharge feature: each Xt-EHR EHDSDischargeReport element 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).
  • eehrxf 0.0.33 carries the laboratory report crosswalk under its laboratory feature: each Xt-EHR EHDSLaboratoryReport element 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 or DiagnosticReport that carries it, checked against the vendored packages. A crosswalk names companion profiles beside its document profile, and Crosswalk::check_with holds its rows to all of them (#1337).
  • eehrxf 0.0.34 carries the imaging crosswalks under its imaging feature: the Xt-EHR EHDSImagingReport elements against the HL7 Europe Imaging Report 1.0.0-ballot composition and its DiagnosticReport, and the EHDSImagingStudy elements against its ImagingStudy, 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 selects openEHR-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_id alone, matches the received one’s business identifier at the gateway, and refuses one the owner already holds with 409 duplicate, 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).
  • eehrxf writes the laboratory report and the discharge report documents. The laboratory report is a Bundle-eu-lab of HL7 Europe Laboratory Report 2.0.0, with the DiagnosticReport-eu-lab beside 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 a bundle-eu-hdr of 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.
  • eehrxf writes the imaging report document, a BundleReportEuImaging of HL7 Europe Imaging Report 1.0.0-ballot with the DiagnosticReportEuImaging beside its composition. The composition’s type is the report’s code, both carry the imaging-report and Medical-Imaging categories, and its organization authors are the report’s performing organizations, as the profile’s dr-comp-author-org requires, 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 an openEHR-EHR-OBSERVATION.laboratory_test_result.v1, the entry the openEHR CKM template 1013.26.408 (asset version 3, DRAFT) names. The template is vendored by scripts/vendor/openehr-ckm.sh with its status recorded (#666).

Changed

  • docs/release.md has 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 weekly ehds-acts job 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’s Category::crosswalk, which answered one crosswalk or none, is Category::crosswalks, which answers the crosswalks of a category’s datasets in the order of Category::roots: two for imaging, one for every other category. A caller of crosswalk() takes the first of crosswalks() (#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/answer route 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-smart 0.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, which scripts/quickstart/seed.sh reads too. Run docker compose down -v once before bringing an older quickstart up again (#1377).
  • The binding-nl feature carries binding-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 its system_id as FERROEHR__REST__SYSTEM_ID and keeps FerroEHR’s in-instance subject lookup ([ehr_index] provider = "local"); FerroPIX reads public_base_url at the top level. The vendored W3C DID Resolution 1.0 moves to the Candidate Recommendation Draft of 2026-10-26 (#1386).
  • Moved to ferrohealth-smart 0.8.6, with ihe-iti 0.0.66, nl-generic-functions 0.0.33, ihe-atna and ehds-logging 0.3.1, ferrobridge-term and ferrobridge-validator 0.1.140 on the same line, so the build holds one ferrohealth-smart and one openehr-sdt (#1390). A verified Basic credential of [[auth.basic.users]] is now kept for 300 seconds by default (auth.basic.cache_ttl_seconds; 0 keeps the old behaviour), and [auth] ca_file names extra trust roots for the issuers. An access record names each security label once, on its ehds-categories and origin entities, and writes no meta.security, as IHE RESTful ATNA Rev. 3.6 Table 3.81.4.2.2.1-1 maps no meta: a repository that filtered on _security reads the entities’ securityLabel instead.
  • 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-iti 0.0.54 and nl-generic-functions 0.0.29, FerroAUDIT’s ihe-atna and ehds-logging 0.2.10, and ferrohealth-smart 0.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-metadata is retired: the onward grants take the RFC 8414 duplicate-name refusal and metadata location from ferrohealth-smart 0.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 drops development_crossref, the identity-state export drops its development_cross_reference entry, the usage report no longer names the dev service, 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 CONTRIBUTION sent as application/xml is answered 415 (media-type-unsupported) before it is routed, naming the media types contribution_create takes, as a FerroEHR node answers it: ITS-REST 1.1.0 defines that body in JSON alone. It was a 400 (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-smart 0.7.1 masks the userinfo of an OAuth section’s issuer and token_endpoint in 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’s Debug or serialized form (CWE-532, GHSA-vphj-3f8c-5hpx). FerroFED takes it with the releases built on it: ihe-atna and ehds-logging 0.2.11, ihe-iti 0.0.60, nl-generic-functions 0.0.30 and ferrobridge-term 0.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 with kubectl apply -k deploy/kubernetes/base in place of kubectl apply -f deploy/kubernetes/. Its NetworkPolicy now admits only your reverse proxy’s namespace (ingress-nginx until 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’s terminationGracePeriodSeconds is 50 and its governing Service is the new ferrofed-admin. Kubernetes refuses a change of a StatefulSet’s serviceName, so run kubectl delete statefulset ferrofed --cascade=orphan before you apply; the pods and their audit spool claims stay, and the new StatefulSet adopts them (#638).
  • Each [[fhir.receive]] names, in dependencies, every package of its package’s dependency closure (each a .tgz or 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 by config 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.v0 and openEHR-EHR-CLUSTER.medication_supply_amount.v0 (all DRAFT), vendored under CC BY-SA by scripts/vendor/openehr-ckm.sh for 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: each equivalent or equal match of ConceptMap/$translate into 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}/ reports latency_ms_p50 and latency_ms_p95 for 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. Under config.ui = "managed", PUT /admin/config/settings saves 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 a searchset with the Patient the PDQm Supplier holds when a member holds the patient, an identifier no member holds and a restricted patient both answer the empty searchset, a search by demographics is refused, a lookup that finds the patient is recorded in the access log, and metadata declares 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 as ferrofed-vX.Y.Z-kubernetes.tar.gz with the gateway and console images pinned by digest. Its base adds a preStop sleep 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_endpoint and metrics.otlp_endpoint take an https:// collector, reached over TLS with the platform’s trust roots, and [telemetry.otlp_tls] and [metrics.otlp_tls] add trust_roots_file and a client identity (client_identity or client_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, behind FERROFED_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 with 422, each warning is returned in the outcome of the write, and every constraint the validator did not evaluate is named there. A required binding the packages cannot decide is asked of the [eehrxf.terminology] server, and one it does not answer refuses the document with 502. scripts/vendor/fhir-packages.sh fetches 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. /configuration now 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.md states 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 published ferrobridge-term 0.1.131 in place of FerroFED’s own client: the same $translate, $lookup and $validate-code in 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. eehrxf 0.0.28 changes its terminology seam: TerminologyServer takes 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’ whole Parameters, and TerminologyError gains NotFound; an implementation of the seam outside FerroFED changes with it (#925).

Fixed

  • scripts/gh/project.sh updates finds the FerroFED Roadmap again: it looks the board up through repositoryOwner(login:), which resolves an organisation project, in place of user(login:), which cannot (#1229).
  • The compose file every release carries runs the operator console beside the gateway under a console profile (docker compose --profile console up), from ghcr.io/ferrohealth/ferrofed-console at 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_id in 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_id at 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 SIGTERM drain, and each forwarder closes its connection with a TLS close_notify first (RFC 5425 §4.4), as it now does before it reconnects. ihe-atna and ehds-logging move to 0.2.6 and ihe-iti to 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 its required bindings, 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 each required binding of the profile and of Medication-eu-mpd, is now listed as a warning of type not-supported (FHIR R4 conformance-rules.html §2.1.0.6, terminologies.html §4.1.5.1, #1291).
  • The quickstart compose.yaml sends the usage report by default, as every FerroHEALTH compose file does, with its instance id on a new ferrofed-state volume: FERROFED__USAGE_REPORT__ENABLED=false, and FERROEHR__USAGE_REPORT__ENABLED=false for the four nodes, in your shell or .env stop it. scripts/checks/usage-report-off.sh holds 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 IOException from 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. Set FERROFED__USAGE_REPORT__ENABLED=false before you upgrade to keep it off (#1122).
  • [auth] is now the ferrohealth-smart section 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’s introspection_endpoint, client_id and client_secret_file) to a [[trust.issuer]] entry naming the same issuer, and auth.mode, [auth.edge] and [auth.purpose_of_use] to trust.mode, [trust.edge] and [trust.purpose_of_use]. Drop jwks_uri from each issuer: the key set is read from the issuer’s RFC 8414 metadata, or from jwks_file. Replace each issuer’s operator_scope with the one auth.product_scope, a string starting with __ or a full URI you control. auth.audience is a list, and the clock skew, key set and timeout keys are clock_skew_seconds, jwks_max_age_seconds, jwks_refresh_interval_seconds and timeout_ms.
  • Every static bearer token is gone (#1163). Replace bearer_token and bearer_token_file in a [credentials."<endpoint>"] section or a service’s credentials with user and password_file, or with an oauth table, the client credentials grant of ferrohealth-smart; Mitz, the NVI and the terminology server take user and password_file. Replace [metrics] scrape_token_file with scrape_users, naming users of [[auth.basic.users]] whose Argon2id password_hash_file you write, and give Prometheus basic_auth. Replace [pmir] feed_token_file with feed_users, naming the Registry’s own [[auth.basic.users]] user, and give the Registry its password.
  • An oauth2 section that asks for a plain client-credentials token, with no token exchange, no DPoP key, no binding to or authentication by the TLS client certificate and an assertion naming the token endpoint, is refused: move it to an oauth table, whose private_key_jwt signs with its own private_key_file.
  • A log filter that names ihe_iti::atna (the audit spool and forwarder) names ihe_atna::atna instead; the events are the same.
  • The operator console is now ferrofed-console (#1219). Pull ghcr.io/ferrohealth/ferrofed-console in place of ghcr.io/ferrohealth/ferrofed-viewer, rename every FERROFED_VIEWER__<SECTION>__<KEY> override to FERROFED_CONSOLE__<SECTION>__<KEY> and FERROFED_VIEWER_CONFIG to FERROFED_CONSOLE_CONFIG, and run the binary as ferrofed-console. The session and theme cookies are renamed, so every operator signs in once more after the upgrade.
  • If you edited the ferroehr-config block of the quickstart’s compose.yaml, FerroEHR 5.3.2 refuses the 4.3.9 Basic user keys at boot: rename username to user, give each user its scopes (the quickstart grants user/composition-*.cruds, user/template-*.cruds, user/aql-*.cruds and __ferroehr.platform), and move each user’s roles to [authz.rbac.basic_roles] keyed by the user (#1226).
  • Bookmarks of the operator console’s /members, /routing and /federation pages answer 404: 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_motion and the others); the theme cookie is ferrofed_console_theme in place of 0.0.11’s ferrofed_viewer_theme, so each operator chooses the theme once more. A deployment that builds the site bundle itself runs cargo leptos build --split, as scripts/release/console-site.sh does.

Added

  • The gateway sends the FerroHEALTH installation report to FerroPULSE on its envelope v2: one start report per process start and one daily report 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_dir names 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 be https outside profile = "development", caches it while it is fresh, and sends it with each closed authorization question as a bearer token; a question Mitz answers 401 is asked once more with a fresh token. An oauth2, nuts or fapi2 grant 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 from deny.toml. Each release attaches ferrofed-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.sh and crate-version-guard.sh take origin/main as the base when run with no refs, and stray-literals.sh reads the console’s sources when given no directory, so a local run of the guards passes on a clean branch; docs/ci-cd.md names 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 the ui-gates skill 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’s Support line reads newest 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 date Fixes until. The supported_until member of OPTIONS {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-smart 0.3.1, the resource-server crate every FerroHEALTH product shares (#1174). Every token that does not verify is answered one 401 with error="invalid_token" and no description (RFC 6750 §3.1, RFC 9068 §4), and the security log names the failed check as check. Two Authorization fields, or one that is no credential, are a 400 credential-malformed with error="invalid_request". A 403 names 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 a 503 authentication-unavailable then, never a refused start.
  • The metrics scrape and the PMIR feed authenticate HTTP Basic users of [[auth.basic.users]], named by metrics.scrape_users and pmir.feed_users; one from another Basic user is answered 403.
  • The audit trail and the access record come from the FerroHEALTH audit library, FerroAUDIT: the ATNA ITI-20 sender, the BALP AuditEvent with its FHIR Feed, and the European logging component are the published crates ihe-atna and ehds-logging 0.1.0, pinned exactly, and their local copies in crates/ihe-iti and crates/ehds-logging are 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 targets ihe_atna::atna::spool and ihe_atna::atna::forwarder in place of ihe_iti::atna::spool and ihe_iti::atna::forwarder.
  • The openehr-* crates move to 0.0.90 with fhirconnect 0.1.173, openehr-mapping-core 0.1.124 and fhir-types 0.1.127, the line ferrohealth-smart 0.4.0 builds on, so the build holds one copy of openehr-sdt and of every other family member (#1181).
  • ferrohealth-smart 0.4.0 (#1186). Every [<section>.oauth] table takes the keys the crate adds: client_auth = "tls_client_auth" or "self_signed_tls_client_auth" with client_certificate_file and client_key_file for RFC 8705 §2 mutual TLS to the token endpoint, ca_file for trust roots added to the platform’s, and assertion_audience = "issuer" (RFC 7523 §3). An issuer’s metadata and key set are still read on its first token, now by the crate; the key-set-unavailable event is written once per failed read. The answers do not change.
  • The e2e-placement guard now refuses a CI workflow that could skip the container tests: a path filter on its triggers, a condition on the e2e (containers) job beyond the workspace check, or a conclusion check 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-atna and ehds-logging 0.2.1 from FerroAUDIT, ihe-iti 0.0.41 from FerroPIX on that ihe-atna, and nl-generic-functions 0.0.26 from FerroPIX on ferrohealth-smart 0.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 stay ihe_atna::atna::spool and ihe_atna::atna::forwarder, now a stable interface of ihe-atna. The identity binding’s FHIR Feed reaches an http base through ihe-atna’s FeedRepository::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-atna and ehds-logging 0.2.3, FerroAUDIT’s only supported release, with FerroPIX’s ihe-iti 0.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-viewer to ferrofed-console (#1219): the crate and its binary (app/ferrofed-console), the image (ghcr.io/ferrohealth/ferrofed-console in place of ghcr.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 its split feature) 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 with cargo 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 of Accept-Language the catalog holds, else the new [server] locale (default en), and the page writes it as <html lang>. scripts/checks/stray-literals.sh fails 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.md states that a security fix ships alone or the release says why, how a fixed vulnerability’s advisory is published with its release, and the info@cadasto.com route for a report without a GitHub account. scripts/release/preflight.sh <tag> runs the release plan’s checks before the tag (#1237).
  • ihe-iti comes from crates.io at =0.0.40, published by FerroHEALTH/FerroPIX, which owns the crate from here on; the local copy under crates/ is gone. Its features and its behaviour are the ones the gateway already used: the PIXm, PDQm, mCSD, PMIR and XCPD clients, with balp for the FHIR profiles’ audit records and atna for the ITI-55 audit message, both written through ihe-atna.
  • nl-generic-functions comes from crates.io at =0.0.25, published by FerroHEALTH/FerroPIX, which owns the crate from here on; the local copy under crates/ is gone. Its Nuts grant now holds the authorization server’s issuer to the RFC 8414 rule of ferrohealth-smart, so a plain http issuer 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_MAPPING whose match is 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.sh installs wasm-opt alone and the site build needs no Tailwind (#1150).
  • Static bearer tokens, inbound and outbound: bearer_token and bearer_token_file in every credentials section, [metrics] scrape_token and scrape_token_file, and [pmir] feed_token and feed_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}, /routing and /federation are gone: the members are /organisations and /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_id and system_id as “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_token row, and the environment override example on the configuration page sets a password file in place of the removed bearer_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_ns or idle_ns can 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-webpki 0.103.16 and webpki-root-certs 1.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:nonroot moves to index digest d620678, which ships ca-certificates 20250419+deb13u1, in the FROM of docker/Dockerfile and docker/console/Dockerfile and in the pin row. rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 were already in the lock file.
  • ctutils moves from 0.4.2 to 0.4.4 (RUSTSEC-2026-0385, GHSA-rv83-4vc9-vqxg): before 0.4.4 its constant-time mask generation and u128 comparisons could compile to a branch on a secret value. It reaches the gateway through digest and the Argon2 password hashing of ferrohealth-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_access in [oidc] scopes. Remove it from the list and set [oidc] offline_access = true instead (#643).
  • A receiving member’s template that admits several COMPOSITION.category codes and assumes none now refuses every received document of its category with a 422 (MissingRequiredSuffix on <root>/category|code). Give the template’s category an assumed value (AOM 1.4 assumed_value) (#714).
  • ferrofed_node_requests_total carries a new label, class: member for a member’s own answer or failure, client-error for a 4xx it answered a routed request with. Such a 4xx now counts as outcome="node-error", where it was active. The shipped FerroFEDMemberFailing rule leaves class="client-error" out, so it pages on the same member failures as before; a rule or a panel of your own that reads outcome="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 = true on [auth.issuer.patient] declares that the RFC 8693 act claim 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 its provider, a PARTY_IDENTIFIED whose one identifier is the act sub, never the person’s PARTY_SELF, and its access record carries the ehds-representative-inserted entity. A token whose act names the representative by nothing, by its own sub or by an identifier of the document’s patient is 403 with nothing sent. Without the declaration the gateway reads no representative, and the person’s own insertion stays PARTY_SELF. eehrxf 0.0.19 adds Representative and ReceivedComposition::inserted_by_representative; ehds-logging 0.0.24 replaces AccessRecord::subject_inserted with inserted, an Insertion of the subject or a representative. RFC 8693 is vendored.
  • A configuration-invalid refusal 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)): eehrxf 0.0.21 carries, under the features prescription and dispensation, the crosswalks from the Xt-EHR EHDSMedicationPrescription and EHDSMedicationDispense 1.0.0 models to the HL7 Europe MPD 1.0.0 MedicationRequest-eu-mpd and MedicationDispense-eu-mpd profiles, held to both vendored packages. With fhir-r4, the new medication module reads a member’s MedicationRequest and MedicationDispense into the datasets with a typed refusal for every rule a resource breaks, writes them back in the MPD form, and checks a resource and each Medication it contains against the vendored MPD profiles, naming what the check cannot evaluate. The number of dispensations a prescription item has left travels in the extension https://ferrofed.eu/fhir/StructureDefinition/remaining-dispensations on dispenseRequest, written only when the member states a value.
  • The members that serve electronic prescriptions and dispensations on the FHIR face (#1039, Regulation (EU) 2025/327 Art 14(1)(b) and (c)): [[fhir.serve]] declares, per category, the members that serve it on their own hl7-fhir-rest endpoint in the HL7 Europe MPD 1.0.0 profiles, and config check names them. A category no member is declared for is answered as not served on GET {fhir-base}/MedicationRequest, GET {fhir-base}/MedicationRequest/{id} and POST {fhir-base}/MedicationDispense: 404 with an OperationOutcome of the issue type not-supported, never an empty list, and no member is asked. GET {fhir-base}/metadata lists MedicationRequest and MedicationDispense only 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|value asks every member declared for Electronic-Prescriptions that holds the patient at its own endpoint, by its own ehr_id alone, following the member’s next links under its own base. The answer is one searchset with each member’s MedicationRequest as the member sent it except its id and its entry’s fullUrl, which carry the face’s locator, the id the read takes (#1041); one Provenance naming the member, and a warning when two members hold a prescription under the same identifier. _count pages the answer through the gateway’s own -offset parameter. A silent member, and a member the cross-reference could not answer for (not-resolved), fails the search under all-or-nothing and is named under partial; an unknown patient and a patient a consent decision hides get the same empty searchset. The search takes the composition- search scope over every template and is recorded under Electronic-Prescriptions.
  • The read of one prescription on the FHIR face (#1041, Regulation (EU) 2025/327 Art 14(1)(b)): GET {fhir-base}/MedicationRequest/{id} reads a prescription by the id the search now gives it, which names the member’s own endpoint, its ehr_id and its own id of the prescription, so the gateway holds no state between the two. The member the id names is asked alone, by its ehr_id and its own id (_id), and its MedicationRequest is answered as it sent it, with the id read and the member named in openEHR-federation-endpoint. An id with no owner answers 404 with one text whatever the reason; an ehr_id two members claim answers 409 and raises the integrity incident; a member answering two prescriptions for one id answers 409; a caller confined to one patient reads that patient’s prescriptions alone. The read takes the composition- read scope over every template and is recorded under Electronic-Prescriptions. The search names each prescription by the same id, and its fullUrl by the read; a prescription whose parts do not fit an id is listed as its member sent it, with a warning naming 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}/MedicationDispense takes one MedicationDispense, held to the HL7 Europe MPD 1.0.0 profile before any member is asked, and writes it once to the one member that owns the prescription it names, its body as it was sent. The gateway resolves the patient from subject, asks every member holding the patient for its prescriptions by that member’s own ehr_id, and matches the prescription’s business identifier itself, so the identifier never reaches a member. A prescription named by the face’s MedicationRequest/{id} alone is refused 422, since the member receives the body unchanged and can link it only by the identifier. A written dispensation answers 201 with a Location naming it by the face’s id; no owner answers 404 as an unknown patient does; two members holding the prescription answer 409 and raise the new PrescriptionIdentifierCollision integrity incident; the owner’s refusal answers 409 and its silence 504, the outcome unknown and the write never sent again. The receipt takes the composition- create scope over every template, is recorded under Electronic-Dispensations and Electronic-Prescriptions, and is counted by outcome in ferrofed_fhir_dispensations_total. Serving Electronic-Dispensations now needs fhir.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 StructureDefinition of the remaining-dispensations extension (#1053): eehrxf 0.0.23 carries it as medication::prescription::REMAINING_DISPENSATIONS_DEFINITION, a simple extension of MedicationRequest.dispenseRequest with a valueUnsignedInt, at most once, and the documentation site serves it at https://ferrofed.eu/fhir/StructureDefinition/remaining-dispensations and beside it as remaining-dispensations.json. It is tested against the FHIR R4 4.0.1 Extensibility, Defining Extensions and StructureDefinition pages, now vendored by scripts/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, which scripts/checks/technical-documentation.sh holds 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): the consent-refusal-code, payload-profile and ehr-id-system extensions of Endpoint, the connection-type and access-log-origin code systems, and a NamingSystem for each of the five sid/* 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 by scripts/vendor/fhir-r4.sh, and tied to the code that reads it, and a test fails on any other ferrofed.eu FHIR 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 (production or development), 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_only when the gateway takes no PUT under its name (its own eu.ferrofed.eehrxf namespace, or the read-only definition files), and GET {base}/operator/stored-queries/{name} answers one stored query’s versions by its qualified name, a 404 when none is held (#1111).
  • The read of one prescription on the FHIR face answers an ETag and a Last-Modified (#1118, FHIR R4 http.html §3.1.0.2): when the member’s MedicationRequest carries meta.versionId, GET {fhir-base}/MedicationRequest/{id} answers ETag: W/"<versionId>", and when it carries meta.lastUpdated, it answers Last-Modified with that instant as an RFC 9110 HTTP-date in GMT. A prescription with no versionId or no lastUpdated is answered without that header, and so is one whose versionId is not a valid FHIR id or whose lastUpdated is not a valid FHIR instant, 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 the id its receipt’s Location named, at the member that id names alone, by the member’s own ehr_id and its own id, with the read rules, the ETag and the Last-Modified of a prescription read. It takes the composition- read scope, is recorded under Electronic-Dispensations, and the capability statement declares it beside the create. 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} and GET {fhir-base}/MedicationRequest/{id}/_history/{vid} read as the read does, at the member the id names alone, with its scope, ETag, Last-Modified and access record, and answer the resource when the member’s meta.versionId is {vid}, 404 otherwise. The Location a dispensation’s receipt answers now reads back what it named, carries the member’s version only when that version is a FHIR id, and the capability statement declares vread for 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 embedded redb file 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 as config check checks the file and records who, when and why; a save on a stale base answers 409 revision-stale; a secret is held only as its _file reference. 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, which GET /admin/config/resources names: 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} and POST /admin/config/members/{node_id}/remove save a revision you preview with the diff and then apply or roll back; a change to a member the file provides is refused 409 resource-file-owned, and the removal of no console member 404 resource-unknown. A console member survives a SIGHUP reload, 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 sets registry.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 refused 409 resource-file-owned, and the console’s survive a SIGHUP reload. PUT /admin/config/credentials/{endpoint_id} and PUT /admin/config/bindings/{section} save a revision with the resource’s settings and secret changes; …/remove saves one without it. A revision holds a secret only as its _file reference: a secret value is written to a new file of mode 0600 in the new config.secrets_dir, never logged, echoed or answered, and refused 409 secret-store-unavailable without that directory. An applied change to a credential or a binding section awaits a confirmation, POST /admin/config/revisions/{id}/confirm, within the new config.confirm_ms (120000 ms by default); without one the gateway rolls it back, on every replica and across a restart. The new config.approval names 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}/export answers the revision as an attachment under a comment header naming it, its author and its time, with every secret as its _file reference 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 provides 409 resource-file-owned, and saves it unapplied with the origin import, 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 action POST /admin/reload for 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 (class auth), or whose certificate files do not form a pair (class listener-certificate) is refused as a whole. auth.audience waits for a restart. POST /admin/reload needs the operator scope, is recorded with its operator, and answers what it applied, or 422 (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] replica setting, else the HOSTNAME the process runs with (a Kubernetes pod’s name), else unnamed; a configured name outside 1 to 64 ASCII letters, digits, ., _ and - is refused at load. Each answer under {base}/operator/ carries it in a ferrofed-replica header and each report in a replica member, and the identity-state export names replica in place of scope. 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_access setting, off by default, asks for the offline_access scope with prompt=consent (OpenID Connect Core 1.0 §11); oidc.scopes no longer admits offline_access itself (#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.tsv and the technical-documentation guard 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’s plan job 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 in docs/registers/economic-operators.tsv, which holds no row because the rows are kept outside the public repository, and the economic-operators guard in CI. The guard’s --register mode 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.security and on its ehds-categories and origin entities, 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 in https://ferrofed.eu/fhir/CodeSystem/access-log-origin unless [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/export on the admin listener (#798). It needs a token with the issuer’s operator_scope and is recorded with the operator who ran it. The answer is JSON with a format_version, the federation id and every learned creating_system_id mapping with its node and endpoint, documented on the book’s “Exporting the identity state” page. It leaves out the resolution bindings and the ehr_id index, 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 each ehr_id its 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 an ehds-population entity stating the query’s scope and why no ehr_id is 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_id collision, 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’s admin_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 (local terminology) 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 check names each Art 14(1) category the FHIR face receives, with the member its [[fhir.receive]] entry declares, and the categories no member receives: the set GET {fhir-base}/metadata names. 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.sh from the records at the tag and the values only the manufacturer supplies in conformance/eu-declaration-of-conformity.toml. The release lane attaches it as ferrofed-vX.Y.Z-eu-declaration-of-conformity.md once 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 the annex-ii guard 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 every config.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 in ferrofed_config_revision_follows_total. GET /admin/config names the revision the replica runs as running, whether it is the current one as in_step, and a revision it refused as refused. The gauge ferrofed_config_revision_in_step and the alert FerroFEDConfigurationDrift report 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_id the member’s ETag or Location named (ITS-REST 1.1.0 EHR API), held to the HIER_OBJECT_ID form, is an ehr entity with the patient the identity binding holds behind it, and the origin’s created-ehr; an answer that names none, no ehr_id or two that differ records created-ehr-unread as unnamed or malformed and names no value. ehds-logging 0.0.21 adds CreatedEhr and the origin’s created_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-transformation entity per transformation, naming the direction (provided or received), 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. eehrxf 0.0.18 adds transformation::Report, Mapping::files and Mapping::pin; ehds-logging 0.0.23 adds transformation::Transformation and AccessRecord::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 its endpoint_id, base URL, ehr_id identifier 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 same endpoint_id. A form drawn without the field keeps the held FHIR endpoints whole, the form that adds a member refuses a node_id the gateway already holds, a base URL that is not an absolute http or https URL 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/$summary with a token confined to the person and the scope patient/aql-eu.ferrofed.eehrxf::*.s, and gets the summary from every member that holds them. A request for another patient is 403 (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 sub names the person and whose patient/ scopes are confined to them, is written with every entry naming the record’s subject, a PARTY_SELF with no identifier, as its provider (openEHR RM ENTRY.provider). Its access record carries the ehds-subject-inserted entity. A person’s document that maps to no entry, or to a GENERIC_ENTRY, is 422 with nothing sent. A professional’s receipt is unchanged. eehrxf 0.0.17 adds ReceivedComposition::inserted_by_subject, and ehds-logging 0.0.22 adds AccessRecord::subject_inserted and its BALP entity, which ehds-logging 0.0.24 replaces with inserted (#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 to fhirconnect 0.1.111 on openehr-* 0.0.86; the release ships the later versions the openehr-* 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 HttpOnly cookie, 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_id routing 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 what OPTIONS {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 latest tag 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 a release/<major>.<minor>.x maintenance branch (#1087).
  • The notice for a release past its support period, in the startup banner, ferrofed config check and 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 serve logs the address each listener bound on its listening and serving the admin listener lines, so a listener configured on port 0 names the port the system gave it, and server.listen and metrics.listen may 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 create on Bundle only 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 a create that 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, with fhirconnect 0.1.117, openehr-mapping-core 0.1.111 and fhir-types 0.1.113 on the same release, so the server still links one copy of each family. A received document is mapped to a composition whose category the template decides: its assumed value, or its one admitted code, or event when it leaves the category open. A template that admits several categories and assumes none now refuses the mapping, naming <root>/category|code, where event was taken before.
  • The Complaints and incidents and Regulatory status pages, SECURITY.md and 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/aql and GET {base}/v1/query/{qualified_query_name}[/{version}] binds the string it names, quotes removed and escapes decoded, as the same string does in a POST body: name="123" binds 123, where the quotes were kept before. A patient identifier bound that way, quoted, inside text, namespace qualified or spelled in JSON escapes, is still refused 400 identifier-elsewhere before any member is asked, and one bound quoted as the subject now resolves as the bare identifier does.
  • columns[].path of a federated result set keeps the AQL variable as the client wrote it, c/uid/value and p/id, as the Federation Tier §9.4 example renders it, where it was /uid/value and /id before. Two variables of one class, such as c1/uid/value and c2/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_ID the node’s ETag or Location names as the origin’s written-version, and the version-uid of the composition it wrote; an answer that names no version, or no OBJECT_VERSION_ID, records written-version-unread as unnamed or malformed and names no value. A received document’s record names its version the same way. The origin status of 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 or failed (#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_total reads the outcome of 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’s 4xx answer is now node-error where it was active, so the counter and the record never read one answer apart, and a new class label, member or client-error, tells a member’s failure from a client’s mistake it answered correctly. A probed member’s 404, which says it holds no such EHR, stays active, and a request answered with a DPoP nonce challenge no proof could answer is now counted as node-error. The dashboard’s member request panel splits by class (#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 document Bundle carries only the summary’s content, as the HL7 Europe Patient Summary Bundle profile slices it (#1008).
  • A reload names a changed server.public_url, server.trusted_proxies, server.forwarded_header, server.replica or signing.rotation_overlap_s under 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 %3B and 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 Timeout to a request body not read whole within 30 seconds, and 400 to one that breaks off, before any session check or server function runs.
  • The operator console answers 413 Payload Too Large to a request body sent chunked that grows past its limit, as it answers one whose Content-Length declares it. Before, it answered 500.
  • 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 eehrxf 0.0.23 says what the code does (#1054): the required terminology bindings are held by ReceivedDocument::check_bindings and terminology::bindings::check_required, whose results convert into the check’s findings, and Category::document_type and Category::profiles give None for 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 200 when the cross-reference could not answer for a member (#1104): under all-or-nothing it fails with 424 naming that member as not-resolved, as the patient summary and the prescription search do, and under partial it names the member in an outcome entry 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 as not-resolved in every section, where it answered 404, and the document list names each one in an outcome entry, where it answered an empty searchset. A patient summary under partial now 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.yaml stack, and every FerroEHR container the test harness starts, run with FERROEHR__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-unrecorded before 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 answered 503, 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 event write-unrecorded and the alert FerroFEDWriteUnrecorded. AccessSink in ehds-logging 0.0.17 gains reserve, and ihe-iti 0.0.35 adds Spool::reserve and AuditRecorder::reserve.
  • Under config.ui = "managed", a changed configuration file that a SIGHUP reload records as a revision is marked pending-restart, with the keys a restart applies, when the reload leaves settings for the next start. Before, it was marked applied, so GET /admin/config claimed a configuration ran that only partly did, while a revision saved and applied through the API was marked pending-restart.
  • The interoperability component no longer writes an openEHR terminology id such as local as a FHIR Coding.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, LOINC and ICD10 are 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", a SIGHUP reload whose changed file the reload refuses records the file’s revision and a config-revision-refused record 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_id or 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.version only in the form the code system’s versions take (#977). A SNOMED CT version passes only as a URI naming an edition, such as http://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-invalid message, 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-invalid message 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, with spool_dir on durable storage, before you upgrade.
  • Set server.shutdown_timeout_ms to at least server.request_timeout_ms, or remove it so it takes the request timeout (#625). config check and serve now 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 than server.drain_delay_ms plus the drain: the examples use stop_grace_period: 40s in Compose and terminationGracePeriodSeconds: 45 in Kubernetes.
  • If you run the Kubernetes example, move from deployment.yaml to statefulset.yaml (#628). Replace the encrypted storage class with your own. Wait until the dependency report, GET {base}/operator/dependencies with an operator token (#751), reads up for the audit repository, so the spool on the old emptyDir is 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] listen names an address other than a loopback one outside profile = "development", set [metrics] scrape_token_file to a file holding a scrape token and give your Prometheus the same token with authorization.credentials_file, or set [metrics.tls] client_ca_file (#635). Without either, serve and config check refuse the configuration. The Kubernetes example reads the token from the metrics-scrape-token key of the ferrofed-secrets Secret.
  • To run the admin listener’s stored-query distribution outside the development profile, from the gateway’s host or another, set operator_scope on the [[auth.issuer]] your operators use and send a token carrying it (#629, #635). Without one it answers 401 unauthenticated, from a loopback peer as from any other, where a loopback peer was served before.
  • If a backend client reads patient data through the gateway with a system/ scope or a token whose sub is its client_id, set client_tokens_act_for_professional = true on its [[auth.issuer]] and have its tokens name the professional it acts for in extensions.ihe_iua.national_provider_identifier (#661). Until both hold, such a request is answered 401 natural-person-required. Then set [auth.issuer.assurance] on each issuer whose tokens reach patient data; without it no assurance level is required, and config check notes the issuer.
  • A document received on the FHIR face whose codings carry no display is now refused with a 422 unless [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/dependencies with a token that carries your issuer’s operator_scope (#751). GET {base}/health/dependencies is no longer served and answers 404, so move every monitor that read it, and give it an operator token. ferrofed report reads the report with --operator-token-file. A proxy rule that kept /health/dependencies inside can go; the health family left open, {base}/health and {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: a redb or PostgreSQL store, or a definition file, holding one there refuses the start, and config check refuses such a file.
  • If you wrote an [access_log] map on a development build, rewrite its categories with the codes of HL7 Europe’s EEHRxFDocumentPriorityCategoryCS (#824), which are case-sensitive: patient-summary becomes Patient-Summaries, electronic-prescription Electronic-Prescriptions, electronic-dispensation Electronic-Dispensations, medical-imaging Medical-Imaging, medical-test-result Laboratory-Reports and discharge-report Discharge-Reports, in [access_log.templates], [access_log.archetypes] and the keys of [access_log.retention.categories]. Declare each national category in national_categories as <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 the ehds-category detail <system>|<code>, never the bare code.
  • The registry document refuses an [[organisation]] that manages no [[endpoint]] (§8.1: “an Organization manages 1..* Endpoints”), so config check and 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 answers 404 with no-destination.

Added

  • The identity page of the book says how each member’s ehr_ids reach the PIX Manager: the PIXm ITI-104 feed or the PMIR ITI-93 feed from each member, the bulk load of the EHRs that already exist, why the gateway feeds no Manager itself, and what to do when the hospital MPI does not answer PIXm (#622). It names SanteMPI 2.5.12 as a PIX Manager verified against two CDRs, with its setup and its limits, and the products checked that do not answer as the gateway needs.
  • The end-to-end lane resolves a patient through SanteMPI, a deployable PIX Manager each member feeds over ITI-93, and gets a federated query answered by both members (#622).
  • The access log: every federated query, stored-query execution, routed read and routed write that reaches patient data at a node is recorded with the verified caller, as Regulation (EU) 2025/327 Annex II 3.2 asks of an EHR system’s logging component and the GDPR asks of an access trail (#623). Each record is a BALP AuditEvent sent through the [audit] spool to the Audit Record Repository. It names the provider, the person and their purposes of use, the patient and each ehr_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 or none. An access the map cannot classify exactly is recorded unclassified, with its ids as evidence, and is never refused. The gateway stores the record before the answer leaves. An access whose record cannot be stored answers 503 access-unrecorded with 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-logging 0.0.1, the European logging component as a library (#623): the Annex II 3.2 access record, the Art 14(1) categories, the category map and its classification, the sink trait, and, behind balp, the record written as a BALP AuditEvent through ihe-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.conf is 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.sh runs config check over 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.json publishes ahead of a rotation and that never signs, so every replica publishes a new key before any replica signs with it (#627). next_key_algorithm names the algorithm the next key is meant for, and config check refuses a next key on another curve, naming signing.next_key_file, or one that is already the current or previous key. The book gives the three-step rotation for any number of replicas and the node cache time it assumes.
  • The metrics now show what the gateway’s own clients see (#629). ferrofed_http_requests_total counts every answered request by method, route template and status class, and the OpenTelemetry HTTP server metrics http_server_request_duration_seconds and http_server_active_requests time them and count those in flight. The cross-reference resolver’s calls are counted by outcome in ferrofed_resolver_requests_total and timed, and the localizer and demographics calls are timed beside their existing counters. ferrofed_security_events_total counts every caller refused at client authentication by its reason, and every identifier-hygiene event of the ferrofed::security log, 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, from deploy/observability/, held to the exported metrics by scripts/checks/observability.sh and promtool check rules in 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 new networkpolicy.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 an upgrade fragment, and CI runs config check from 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, and config check and the start-up log warn about it, naming the release that will refuse it; setting the old and the new key together is refused. The redb and PostgreSQL stored-query stores record a schema version (ferrofed_schema and ferrofed.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 answered 503 overloaded with Retry-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 and client_id and never by a forwarded address, a bucket of burst requests refilled at requests_per_second; past it the answer is 429 rate-limited with Retry-After. At most federation.max_in_flight_per_node requests (64 by default) go to one member endpoint at once; one past it waits for a slot until its per-node deadline and is then time-out with nothing sent (§11.5, N38), so one slow member holds no other member’s requests. A zero in any of them is refused by config check, and ferrofed_overload_refusals_total counts every refusal by its limit. The errors page lists overloaded and rate-limited.
  • TLS on the gateway’s listeners (#632). [server.tls] serves HTTPS with a certificate chain and key from certificate_file and key_file, and [metrics.tls] does the same for the admin listener. An optional client_ca_file requires 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). SIGHUP reads 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 healthcheck follows [server.tls], accepting exactly the certificate the file holds and presenting healthcheck_identity_file where a client certificate is required, and says when the file was replaced without a SIGHUP. config check reads 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_scheme on the HTTP server metrics reads https on 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 400 target-required by the others. A client sends openEHR-federation-endpoint on 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 v3 ActReason code BTG, and a record whose verified token declares one carries the entity ehds-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, and config check notes an access log that names no emergency purpose. The book’s audit and authentication pages name the codes a deployment maps (#659).
  • crates/ehds-logging 0.0.9: the emergency module, EmergencyPurposes and Emergency, the record’s emergency, 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, acr by default (RFC 9068 §2.2.1), the values that stand for the levels low, substantial and high of 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 answered 401 authentication-assurance-insufficient with the RFC 9470 §3 challenge insufficient_user_authentication; no node is asked and no access record is written. The level is read from the token or the introspection answer, and config check notes each issuer that declares none. Each node is told who acts (acting), the level reached (assurance_level) and the professional’s IHE IUA subject_name and national_provider_identifier in the caller’s openEHR-federation-client token, 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 new professional_issuing_authority names, 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 Patient carries 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 a data-absent-reason of unknown. A patient the Supplier does not know is a 404, several patients under one identifier a 422 multiple-matches, a patient with no name a 422 required, and a Supplier that does not answer a 502 or 504; 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}/ and OPTIONS {base}/ carry a manufacturer object (name, postal_address, email, website), the startup banner and ferrofed --version print it, as does ferrofed-viewer --version, and every page of the operator console names it in its footer. Both container images carry it in the org.opencontainers.image.vendor and .authors labels and a new eu.ferrofed.manufacturer label. The details are written once, and a test holds every surface and LICENSE to 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.sh holds its standards and version to docs/VERSIONS.md and its date to CHANGELOG.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 and docs/post-market.md the working checklist; SECURITY.md, SUPPORT.md and 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 from client_secret_file, or with a client assertion the [signing] key signs ("private_key_jwt"), and asks for the scope the 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); a 401 gets one more request with a fresh token. config check names every key it refuses: token exchange, a DPoP key, a certificate binding or TLS client authentication in an identity service’s grant, a secret beside private_key_jwt, a scope outside RFC 6749 §3.3, and a token endpoint that is not https outside the development profile. A node’s oauth2 grant refuses a client secret. The ihe-iti PIXm, PDQm, mCSD and PMIR clients take an authorizer::Authorizer for the headers of each request.
  • crates/eehrxf 0.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. Behind fhir-r4, it compiles FHIRconnect mappings against an operational template and runs them in process over a canonical-JSON composition, through FerroBRIDGE’s fhirconnect 0.1.108, answering a FHIR R4 Bundle with a Provenance. 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-types moves to 0.1.108, the release fhirconnect builds on. cargo deny now checks every feature, so a dependency behind a feature no crate enables yet is checked too.
  • A member’s own exchange-format endpoint, the native mode (#686): a second [[endpoint]] of a node with connection_type = "hl7-fhir-rest", its categories, profiles and the ehr_id_system the member’s ehr_id is written in. For a patient summary, the gateway leaves such a member out of its section queries and asks its endpoint Patient/$summary by identifier=<ehr_id_system>|<ehr_id> and profile, 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, 424 or 504 under all-or-nothing, and is named in every section under partial. The endpoint is absent from OPTIONS {base}/, and a directive or a targeting header naming it is 400. config check refuses a native endpoint with no ehr_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_MAPPING of 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 an equivalent or equal match becomes a coding), $lookup and $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 required bindings of its Bundle and Composition profiles, 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 and DISTINCT are unchanged. An origin’s set can name a category of a row the merge then cut by LIMIT or DISTINCT, and never misses one it delivered.
  • ferrofed report writes 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 their ehr_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.sh withdraws 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> and latest image 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” in SECURITY.md and records the withdrawal in the register. It runs dry by default. docs/release.md describes the route.
  • The access record names the client’s address behind a reverse proxy (#722). server.trusted_proxies lists the proxies, each an IP address or a CIDR block, whose forwarded client address the gateway takes, and server.forwarded_header names the header they write: forwarded (RFC 7239), the default, or x-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 sets X-Forwarded-For to the address it received the request from and drops Forwarded, and the production guide trusts it on loopback.
  • server.public_url names the gateway’s public base URL once (#723). Set, an unset auth.audience is that URL, an unset signing.jwks_uri is the JWK Set route under it, and an unset pmir.callback_url is the feed route under it. Its path must be server.base_path, and a signing.jwks_uri or pmir.callback_url that is still written must name the route the gateway serves under it, or config check refuses 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.sh commands, 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 a 401 naming at+jwt with 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 as ferrofed.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-only checks a member whose governance forbids test data in its production CDR (#726, §12b.1, §12b.2, N42a): it creates nothing, reads the ehr_id and system_id of up to --count existing EHRs through one AQL query, judges ehr_id generation and system_id uniqueness on them, and ends its report with every condition a run without writes leaves unproven, the ehr_id exchange 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 whatever auth.purpose_of_use.required says; one that lacks either is 403 contact-point-attributes-required or 403 purpose-of-use-required, with nothing sent. Its requests are served with consent exclusions withheld whatever [federation.consent] disclose says (Regulation (EU) 2025/327 Art 8, Art 11(5)). The professional and the provider reach each node in the national_contact_point claim of the conveyance and the access record in an ehds-relayed entity, both marked as asserted by the contact point, and the outbound gate reads them like every other caller claim; the IUA person_id is never read. A correlation identifier the connector sends in the declared correlation_header is recorded with the request, and one out of form is 400 correlation-invalid. OPTIONS {base}/ declares the setting under federation.national_contact_point, and the book’s authentication and §13.4 pages name who authenticates the foreign professional (CP-39).
  • crates/ehds-logging 0.0.6: Accessor carries what a contact point relays (Relayed), Request a 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:user carries the professional’s IHE IUA subject_name as who.display and national_provider_identifier as an ihe-otherId extension typed NPI, the assurance level of the authentication as an ihe-assuranceLevel extension, and who acts, person or client, as its role. 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-logging 0.0.4: Accessor carries the acting mode, the assurance level and the professional’s name and identifier, and the requester’s professional number moves to alt_id. crates/ihe-iti 0.0.30: a BALP User takes a name, a provider identifier, roles and an assurance level, written to the agent: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 in docs/post-market.md describe 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.md lists 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 --assemble now opens a release’s notes with its end date, adds its row to SECURITY.md and 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-admitted before the action has any effect, counted under the same name, and admin-write-finished with its outcome, abandoned when 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.md and ferrofed-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 labels eu.ferrofed.information-sheet and eu.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 version 1.0.0, one per patient summary section openEHR content feeds (eu.ferrofed.eehrxf::patient-summary-problems and 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 $patient and $namespace, so no node receives the identifier. They are listed, read and run by name as any stored query. A PUT into the namespace answers 409 stored-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-eehrxf 0.0.1, the interoperability component’s federation half, which builds the section queries as AQL syntax trees with openehr-query; eehrxf still 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).
  • eehrxf 0.0.4: the patient summary crosswalk (#777). Under the patient-summary feature, each row of crosswalk::patient_summary keys on an Xt-EHR EHDSPatientSummary element 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::check fails when an element a producer SHALL:able-to-populate has 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::ResourceProfile reads 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 Support line, and OPTIONS {base}/ declares it as the top-level supported_until, or null for a build from source or a pre-release. Once the date has passed, the banner shows an UNSUPPORTED notice, serve logs it at WARN and ferrofed config check prints it as a warning. scripts/checks/versions.sh fails when a release in CHANGELOG.md has no row in the SECURITY.md support 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 carries ehds-retention-years, ehds-retention-ends and ehds-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-logging 0.0.7: the retention module, the period a record is kept by category and origin with the three-year floor of Art 9(2), the record’s retention, its three BALP detail entries, and CategoryMap::category (#795).
  • The access record of a request addressed by ehr_id, such as a routed read or write or a query scoped to one ehr_id, names the patient the identity binding holds under it, in each namespace the new [access_log] patient_namespaces names, so a search of the log by the person’s identifier (ITI-81 patient.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. Each ehr entity says how its patient was looked up in a patient-lookup detail (request-named, found, not-found, unavailable, not-configured, unsupported) and keeps the ehr_id; the access is recorded and answered whatever the lookup says, and no identifier reaches a node or a log line (#796).
  • crates/ehds-logging 0.0.8: PatientLookup and the patient of each EhrAt, every patient found written as a BALP entity:patient, and the patient-lookup detail (#796).
  • eehrxf 0.0.6: the interoperability component’s receive side (#801, part of #665). Under fhir-r4, receive::ReceivedDocument::read reads 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-7 to bdl-11, and fullUrls that agree with their resource), and resolves its subject to its one Patient entry. 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: a subject, patient, beneficiary or for must name it, and a reference that could name another patient, a type that disagrees with its target, a second or contained Patient and an element the R4 table does not describe are refused. The text is kept byte for byte. ReceivedDocument::check holds the decoded document to a Bundle and a Composition profile read from the vendored package: cardinalities, fixed[x] and pattern[x] values, and value, pattern, exists and type slices, 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. Under openehr, Mapping::to_openehr maps the document into one canonical composition through FHIRconnect and keeps the document in the composition’s FEEDER_AUDIT.original_content as a DV_PARSABLE. dataset::Element now carries its fixed[x] or pattern[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}/Bundle takes a document Bundle and writes it as one openEHR composition to the member [[fhir.receive]] declares for its Art 14(1) category, by ITS-REST composition_create at that member’s own ehr_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’s FEEDER_AUDIT.original_content. Its patient is resolved from every Patient.identifier in a namespace fhir.receive_namespaces names, at the receiving member alone, and every identifier must name the same ehr_id; no patient identifier is sent in the path, the query string or the headers. The caller needs user/composition-*.c (a read scope is refused), and a patient/ 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 an OperationOutcome error, 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 and config check.
  • eehrxf 0.0.8: ReceivedDocument::category names a received document’s priority category from its Composition.type, Category::profiles and Category::document_type name the profiles and the document type of each category, Category::code and Category::of_code name each category by its HL7 Europe priority category code, such as Patient-Summaries, and ReceivedComposition::model is the mapped composition as the RM model holds it (#802).
  • The FHIR face’s capability statement, GET {fhir-base}/metadata, declares the create on Bundle that 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 the Bundle profile 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, GET and POST {fhir-base}/Patient/$summary answer the patient summary as an HL7 Europe Patient Summary document Bundle, and GET {fhir-base}/metadata the face’s CapabilityStatement. 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 one Provenance per 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 the aql- search scope of each section query, every summary that reached a member writes one access record with the patient-summary category, and every error is an OperationOutcome. The book’s new page “The patient summary over FHIR” describes the request, the document and the configuration.
  • eehrxf 0.0.7: the document module writes the EPS document Bundle from the resources and sections a caller gives, naming every entry under a base and keeping two runs over one composition apart; Mapping names its compiled contexts and runs under a FHIRconnect call context (#809).
  • ehds-logging 0.0.9: Evidence::constructed classifies 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 optional type token of the patient summary, LOINC 60591-5, answers a searchset of DocumentReferences that meet the HL7 Europe EU Health Data API EehrxfMhdDocumentReference profile: the summary the gateway writes on request, retrievable through Patient/$summary, and each native member’s own document, named by attachment.url Binary/{token} with its media type, SHA-1 hash and size. The gateway asks each such member’s own endpoint by its ehr_id alone, 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, 424 or 504, under all-or-nothing, and is named in an OperationOutcome entry with search.mode outcome under partial. 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 for fhir.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, and metadata declares the search and the read.
  • eehrxf::receive::conform::check_resource holds 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 to bundle-eu-eps, composition-eu-eps, the profile each entry claims and the EU core profile its mapped entries are written to (#815).
  • [federation.consent] emergency chooses 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 an ehds-consent-set-aside detail of its ehds-emergency-access entity, and the answer never shows it. "pass-to-node" needs a declared emergency purpose to load, config check notes it beside [nl_gf.mitz], and OPTIONS {base}/ declares the setting as federation.consent.emergency. crates/ehds-logging 0.0.13 carries Emergency::with_consent_set_aside. The book states that the pre-filter is asked with the configured TREAT or COC, never the caller’s purpose.
  • A directed query can order on an ENDPOINT attribute through the directive’s variable, ORDER BY p/organisation DESC alone 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 the LIMIT (§8.1, §9.3, §11.6.1). Under DISTINCT the attribute must also be selected, and under version-identity dedup with a LIMIT the key is refused 400 with the new code endpoint-order-dedup (§10.2). The variable anywhere else is still refused with endpoint-variable.
  • A stored-query PUT {base}/v1/definition/query/{name} with no version is stored as a new version the registry assigns: 1.0.0 for a name it holds no version of, else the next patch of the newest version by number (after 1.9.0 and 1.10.0, 1.10.1), answered 200 with Location naming it as ./{name}/{version} (§12.7 stored-query-versioning; ITS-REST definition_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 answers 409 stored-query-held and stores nothing. The literal-patient refusal applies before any version is assigned, the reserved namespace stays closed, a read-only registry still answers 405, and a distribution sends the assigned version. The code query-version-required, the 400 this PUT answered before, is retired, and the error page lists it as retired (#875).
  • The operator can clear a held ehr_id collision once the node at fault has fixed it, with POST /admin/ehr-ids/{ehr_id}/clear on the admin listener (#876). It needs a token with the issuer’s operator_scope, is recorded with the operator who ran it, takes the node-local ehr_id alone, and forgets it from this replica’s ehr_id index and from every caller’s resolution bindings. The next read asks every member again and is refused 409 again 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 under cleared.
  • 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 Endpoint with connectionType hl7-fhir-rest in HL7’s endpoint-connection-type names its categories as payloadType codings in HL7 Europe’s priority category code system, its profiles in payload-profile extensions and the identifier system of the member’s ehr_id in one ehr-id-system extension. 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-iti 0.0.34 reads payloadType codings and valueCanonical and valueUri extensions of a directory Endpoint.

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 reports federation.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-federation 0.0.45 carries the new FEDERATION_SPEC. The book links the specification site at its 1.0 path.
  • nl-generic-functions 0.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-metadata 0.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, serve and a reload refuse the configuration without one, naming audit.destination. Outside development a gateway with a registry also refuses destination = "log", naming audit.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). log stays 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. The log destination never names the client of a request the gateway received.
  • ihe-iti 0.0.27 writes a BALP entity with detail entries, a user’s organisation as an agent of its own and their alternative identity as altId, and a record that claims no pattern. openehr-federation 0.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.yaml in place of deployment.yaml, so each replica keeps its audit spool on a persistent volume claim of its own instead of an emptyDir a reschedule deletes (#628). The claim names an encrypted storage 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 reads up for 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 _file key keeps it out of the configuration, as config check does; a test holds config check to 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 sub is its client_id (IHE IUA ITI TF-2 3.71.4.2.2.1), or one only system/ scopes cover (SMART on openEHR master08), is answered 401 natural-person-required, unless its issuer declares client_tokens_act_for_professional = true and the token names the professional, in the IUA national_provider_identifier or the [auth.issuer.requester] professional claim. Definitions, OPTIONS and 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 Summary Patient requires 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.federation names 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 of ferrofed-server, so a build without the binding-ihe feature records every access to patient data and runs a registry outside development. binding-ihe keeps 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 log audit 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/dependencies names every member endpoint id and its state. The operator console page says to set Strict-Transport-Security at 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-functions 0.0.21 moves the request authorizer a Generic Functions client takes out of the FHIR-bearing nvi feature into a feature of its own, authorizer, at nl_generic_functions::authorizer; nvi turns it on, and the engine’s Nuts grant implements it with nuts-auth and authorizer alone. The architecture test now fails when the engine compiles fhir-types through any crate.
  • eehrxf 0.0.2: its fhir-r4 feature compiles no openEHR crate (#730). The FHIRconnect mapping from openEHR moves behind a new openehr feature, which turns on fhir-r4, and the architecture test fails when fhir-r4 alone reaches an openEHR crate or the mapping engine.
  • A national contact point’s country_code must 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 refused 403 contact-point-attributes-required. [auth.issuer.national_contact_point] additional_countries admits 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-profile send each ehr_id-scoped query as a fixed AQL template with the ehr_id as the value of its $ehr_id parameter in query_parameters, so a node must bind ITS-REST query parameters, in a WHERE clause and in an EHR predicate, to pass them (#739). The scenario queries of conformance run are built and printed through openehr-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] filter no longer quiets the security log (#767): the gateway adds ferrofed::security=info after the configured filter, so every security line at info or 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-api 1.0.0-ballot), written <system>|<code> in the ehds-category, ehds-category-basis and ehds-retention-ground details, with a new ehds-category-version detail 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-logging 0.0.12 carries the change: Category::system, version and token, NationalCategory in place of NationalCode, and classify::Unreadable in 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 purposeOfEvent too, each once, beside the agent:user purposeOfUse that 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 as BTG among 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-iti 0.0.32, ehds-logging 0.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 answers 400 (subject-several) instead of 409, as N41 has it when no one node is found, with the endpoints a client may name in openEHR-federation-endpoint listed in the message (#869). An ehr_id two members claim keeps N42’s 409 (ehr-id-collision). A client that branched on the 409 reads the 400 and the subject-several code.
  • The 409 (controlling-system-unreachable) for a versioned write whose controlling system is no member now names that system by the version’s creating_system_id, read as an RM system_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_endpoint declares without an openEHR-federation-endpoint header (#873). One destination the operator fixes and OPTIONS {base}/ declares is the explicit choice §12.6 asks for, so the request no longer answers 400 target-required. A header naming another endpoint is still 400 targeting-conflict, the query string and headers are held as before, and its_rest.demographic says the header is optional.

Fixed

  • A rolling restart no longer cuts requests in flight (#625). The drain, server.shutdown_timeout_ms, now defaults to server.request_timeout_ms, and config check, serve and a reload refuse a drain shorter than the request timeout, naming both keys. The new server.drain_delay_ms keeps the listener accepting for that long after SIGTERM, with readiness already 503, so a load balancer stops routing to the gateway before it stops taking requests; it is 0 by default. The example configurations drain for 30 seconds, the Kubernetes example waits 5 seconds first, and the grace periods outlast both: terminationGracePeriodSeconds: 45 and stop_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_url rely on: the new on_drain key keeps it by default, for the other replicas and the next start, and on_drain = "unsubscribe" deletes it, for the last gateway or a replica with a callback_url of its own. Replicas that find several subscriptions for their callback_url adopt the same one and delete the others, and a delete the Registry answers 404 or 410 counts 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_ms after 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.sh fails when a file the release ships or builds its images from is missing, where it skipped the check, and scripts/checks/kubernetes-example.sh and scripts/checks/release-compose.sh read 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) targetIdentifier in 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 with 424 (PIXm 3.1.0 §2:3.83.4.2.2.1, §5.2). Two different values in one domain stay ambiguous (#679).
  • The request span records http.request.method as _OTHER for a method no RFC the gateway knows defines, and names that span HTTP {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 SIGHUP reload 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 method field, _OTHER for 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 a curl example that posts a bare number, or opens a here-document and passes -d anything but @- or @<file> (#721).
  • config check writes 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, and serve still creates the directory. A spool serve would refuse is refused by the key that names it, and serve names 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 reported time-out in 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, naming fhir.base and pmir.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.entry open, as bundle-eu-eps does, 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 at Provenance.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}, in Composition.subject and in each mapped resource, where the absolute fullUrl in Composition.subject read as a second subject. Each mapped resource now claims in meta.profile the 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: the LIMIT the query carries, or for a query with no LIMIT the new federation.max_node_rows plus one (9999 rows by default, so a node is asked for 10000, the page FerroEHR serves by default; declared in OPTIONS {base}/ as paging.max_node_rows). A node that answers more rows than that is node-error and its rows are never merged: the query fails 424 under all-or-nothing, and a partial answer is incomplete and names the node (§11.4, §11.5). The ITS-REST offset and fetch members now select a window over the AQL result (the skip is the AQL OFFSET plus offset, the count the smaller of fetch and the rows the AQL LIMIT leaves), paged under the declared OFFSET strategy. The code paging-conflict, a 400 for 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, GET or POST {base}/v1/query/{name}[/{version}], answers 501 Not Implemented on a gateway without the stored-query registry, as N32 requires of an unsupported area (#871). With the registry, its_rest.query says those paths are served from it. The behaviour is unchanged.
  • Every stored-query answer that carries Location names the stored query’s own URL, {base}/v1/definition/query/{name}/{version} (ITS-REST Location_Query). With server.public_url set it is that absolute URL on every route. Without it, the PUT answers 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 no Location, because no reference from the admin listener reaches the client listener (#899).
  • A native member’s answer on the FHIR face counts as its document only when it is a FHIR document Bundle whose identifier has a system and a value (FHIR R4 bdl-9), which the EehrxfMhdDocumentReference profile needs as the list entry’s masterIdentifier (#901). Any other answer, a document with no identifier included, makes the member node-error. Under all-or-nothing, Patient/$summary and the document list fail with 424 and name the member. Under partial, 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 an outcome entry. GET {fhir-base}/Binary/{token} refuses such an answer with 424 and 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 with 400 query-name-invalid, on PUT, GET and 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 a Location or 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 answers 401 unauthenticated or 403 scope-insufficient otherwise. Only profile = "development" still admits a loopback peer without a credential. GET /metrics takes 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_file authenticates the scrape. Each refusal is counted as admin-write-refused or scrape-refused. The Kubernetes example sets a scrape token, and the diagnostic report reads GET /metrics with 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 as archetype_details/archetype_id/value binds its class as archetype_node_id does. A selected path that names its archetype by a pattern, a parameter or any comparison other than = marks the record unclassified. An archetype id written in another case, with another version or a namespace is held to the map’s exact keys and marked unclassified, never matched to a near key. crates/openehr-federation 0.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 is node-error with an error naming the bound, which fails the query 424 under all-or-nothing and clears meta.federation.complete (§11.1, §11.4); a routed request answers 424. config check refuses a zero, and a reload that changes the bound logs it as needing a restart.
  • ferrofed admission check --read-only no longer prints or keeps the ehr_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’s operator_scope, so a reader without a token no longer learns the federation’s membership (#751). The operator console and ferrofed report read 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_id only at a member the query was sent to, at the endpoint it was sent through. A routed request’s record reads the path ehr_id and the subject of GET {base}/v1/ehr with the parsers the route is served by, so a subject_id holding a + is recorded as the patient the gateway resolved, where it was recorded with a space. A routed answer whose ehr_id or subject the record cannot read is withheld with 503 access-unrecorded.
  • rustls moves 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-iti moves to 0.0.33 and ehds-logging to 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 nonce before a session begins (the new [oidc] keys token_endpoint and jwks_uri), the console shows the member endpoints with their membership standing and last observed health (/members), the integrity incidents and the creating_system_id routing 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, a 401 with a link back to sign-in. The routing table and the stored queries are paged, 100 rows a page. With secure_cookie on, the console’s cookies carry the __Host- prefix, and a new sign-in ends the session the browser held.

  • A read-only operator surface on the gateway: GET {base}/operator/incidents (each incident kind’s count and the last 25 incidents of each kind), /operator/creating-systems (the routing table) and /operator/stored-queries (every held version, as the ITS-REST StoredQuery), admitted only for a token carrying the operator_scope its [[auth.issuer]] entry names as one whole scope token. The last two answer one page, {"items", "offset", "total"}, under the optional offset and limit parameters, with limit at most 100. It answers routing ids, counts and stored definitions only, and an incident names an ehr_id only when it is a bare UUID (§5.4.1, N33).

  • ferrofed conformance run --node-profile reports 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 its ehr_id alone, 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: the ENDPOINT attributes a client selects are each row’s endpoint id and system_id (§9.4, N12, CP-37).

  • Every release attaches the conformance seed data, ferrofed-conformance-seed-data.json, with its SHA-256 in ferrofed-conformance-seed-data.json.sha256sum (#573). The file holds the vendored template and compositions conformance run writes, with their Apache-2.0 licence and notice, and --seed-data takes 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 takes mtls_alias_hosts, each a host name with an optional port, and a fapi2 grant that uses mutual TLS then takes a token_endpoint of mtls_endpoint_aliases on one of those hosts, over https alone, 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 no fapi2 grant over mutual TLS, refuses the configuration, naming the key. oauth-server-metadata 0.0.2 adds AliasHost and Issuer::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 takes client_identity_file and trust_roots_file, the keys and the TLS type every identity service takes, and the node and its token endpoint are reached over one transport that presents that certificate, https under every profile; a section may name that material alone. An oauth2 grant, and a fapi2 grant, may authenticate with client_auth = "tls_client_auth" or "self_signed_tls_client_auth", sending client_id and no assertion (RFC 8705 §2), and with tls_client_certificate_bound_access_tokens = true take only tokens bound to the certificate (§3): a token whose cnf names another certificate, in the token response or as a JWT claim, is refused before the node is sent anything (§3.1, §3.2). It composes with token exchange per caller, whose actor token the gateway still signs. A fapi2 grant over mutual TLS uses the mtls_endpoint_aliases token endpoint (§5), needs the metadata to list tls_client_auth and state tls_client_certificate_bound_access_tokens as the grant uses them, and needs a client key only for a token exchange. A grant that sets both dpop_key_file and certificate binding, either without a client identity, an identity with no certificate, an http token endpoint or node, and TLS material in a service’s own credentials section refuse the configuration. RFC 8705 is vendored under docs/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 /health and the healthcheck command, operator sign-in at an OpenID Provider with the authorization code grant, a nonce and PKCE, its state, nonce and verifier held on the server behind an opaque HttpOnly cookie 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’s OPTIONS {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 with FERROFED_VIEWER__ overrides and a client_secret_file. The code exchange, the operator views (#276) and the query console (#277) are planned. The release lane builds, attests and pushes the image with the same SLSA provenance and SBOM attestations as the gateway image, CI lints the browser half for wasm32 and 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 its DPoP key, cached until 30 seconds before it expires and dropped on the NVI’s 401. 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). An oauth2 or fapi2 table under [nl_gf.nvi.credentials] is refused, naming its key, since the IG defines no such grant for the Localization Service. The nl-generic-functions NVI client takes an authorizer that makes each request’s headers and is handed the request URL without the query that holds the pseudonym (0.0.12).

  • ferrofed conformance run --config <file> scores a configured deployment against the Connectathon tracks 1 to 7, 9 and 11 over ITS-REST (#546; §16.3, §16.4). It starts the gateway in-process from the configuration, or drives one already serving at --gateway <url>, as a client holding a bearer token from --token-file. It seeds a synthetic patient in the urn:oid:2.999 arc 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 than development without --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 --gateway URL and every member endpoint are held to https, or http to 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 it not-run with the reason, never pass; --node-profile adds the admission check of every member as the node profile findings. The end-to-end tracks call the same scenario checks, and a run against the harness reads what the harness scores and nothing more.

  • An end-to-end check that the AVG the gateway declares decomposable in OPTIONS {base}/ is the mean weighted by each node’s count (§11.6.3, N39, CP-10, CP-32), behind FERROFED_E2E. With two values at one FerroEHR node and one at the other, each node is asked its SUM and COUNT, 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:web DID 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, as application/did+ld+json and with no client authentication, like the JWK Set. The document is built from the holder keys the nuts sections name and nothing else, one JsonWebKey2020 verification method per kid, 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 one kid refuse the configuration. nl-generic-functions 0.0.13 adds the nuts_auth::did_document module, HolderKey::public and Did::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 builds syntaric/openehr-federation-ref at 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. The e2e (containers) job uploads the Markdown and TSV report as the differential-report artifact. No difference is a FerroFED defect; three reference divergences and one specification question are new upstream reports (#212 T190 to T193).

  • A fapi2 grant’s client key rotates with an overlap (#514): the new previous_client_key_file names the previous P-256 client key, which the JWK Set at {base}/.well-known/jwks.json publishes 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 the openEHR-federation-client conveyance and every oauth2 client assertion with its own algorithm, and the JWK Set publishes each key with its alg, 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 pins ehrbase/ehrbase 2.36.0 and the ehrbase/ehrbase-v2-postgres 16.2 database image by digest, each a docs/VERSIONS.md row the versions guard compares, and every node profile check runs against it in the e2e (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 its security.additionalAuthorizations expresses; 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 an EHR_STATUS subject in a namespace such as urn:oid:2.999.1.1, which the BASE OBJECT_REF.namespace pattern admits. The admission check therefore creates no test EHR there, and its integrity conditions fail on that cause. Its EHR read omits ehr_access, which the RM EHR class makes mandatory. As at FerroEHR, both ehr_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 AuditEvent carries the BALP agent:user with the token’s iss and sub, requestor true and its purposes of use, and an Application agent with its client_id; the ITI-55 DICOM message carries the Human Requestor with the sub as UserID and aud<sub@iss> as UserName, 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-iti 0.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 event edge-identity-asserted names the caller by subject_ref and client_ref, keyed HMAC-SHA256 references stable for the process, in place of the subject and the client, and a verified caller’s Debug shows 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 one ehr_id at both nodes for two different synthetic patients. A read and a query scoped to it are refused 409 ehr-id-collision listing 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 the ehr_id index 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 refused 400 target-required and probes nobody, although its If-Match names node A’s system_id. As the federation operator, the harness then applies the admission check to a FerroEHR candidate deployed with member A’s system_id: recorded under that system_id, the registry load refuses it as a duplicate, and recorded under one of its own, the check fails the system_id condition 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.sh writes 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 the conformance-report artifact (#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_profile checks what a member CDR’s ITS-REST interface shows of each node obligation. They check that an EHR, its EHR_STATUS and its compositions answer to the ehr_id alone, that an EHR created with no subject is read and queried by its ehr_id, and that an unknown ehr_id and 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 both ehr_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]] takes method = "post" beside "get", the default: the gateway then posts the ITI-83 sourceIdentifier and targetSystem parameters in a Parameters body 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. get stays the default because PIXm 3.1.0 §2:3.83.4.1.2 prescribes the GET and requires no Manager to accept a POST. Any other value refuses the configuration naming the key. The ITI-83 audit record of a POST holds its request line, media type and body. ihe-iti 0.0.22 adds pixm::Invocation and PixmClient::invoked_by, and the harness PIX Manager answers a posted query.

  • Each regional binding is one Cargo feature of ferrofed-server: binding-ihe carries the IHE binding of Annex A ([pixm], [pdqm], [xcpd], [registry.mcsd], [pmir] and [audit]) and binding-nl the 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’s POST {base}/v1/query/aql or /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 from meta.federation, and says in words when complete is false; a 504 or 424 all-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] key end_session_endpoint names the provider’s end-session endpoint, sends the browser there with the ID Token hint, the client id and the optional post_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, Origin or Referer); any other request is a 403 before 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 behind FERROFED_JOURNEYS=1 in the new journeys (browser) CI job, at the Chrome for Testing release docs/VERSIONS.md pins.

  • 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 from scripts/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 a planned point or track, so a re-pin scores or defers every point it adds. Six rows of the obligations checklist that still read planned after their work shipped now read tested and 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’s DPoP nonce challenge that no proof could be made for is the node’s node-error, with the node counted as asked, read from the client’s DpopProof { sent }. The node clients no longer keep a record of each call’s sends. openehr-federation 0.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’s DPoP nonce challenge is the node’s time-out, with the node counted as asked, read from the client’s DeadlineElapsed { sent } in place of the call’s prover record (#470). The record still reads a re-send that no DPoP proof could be made for, which the client reports with no such field. openehr-federation 0.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 honour EHR_ACCESS on 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 (reason caller-claims). Such a call is counted in ferrofed_consent_prefilter_requests_total with outcome="not-asked" and its reason, no longer as no-signal, and it leaves the pre-filter’s state on GET /health/dependencies as it was. The client’s answer is unchanged: every candidate is asked, nothing appears in meta.federation.consent, and under [federation.consent] disclose = false the 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 under node_selection = "ask-all" is refused naming its section, as before (#489).

  • The configuration resolved log line names the compiled bindings, and each binding logs what it is configured to reach on a binding configured line 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-ihe and binding-nl turn on new ihe and nl features of ferrofed-identity and an nl feature of ferrofed-engine, so cargo build -p ferrofed-server --no-default-features builds neither ihe-iti nor nl-generic-functions. The registry document in FHIR form (registry.format = "fhir"), read with the mCSD reader, is part of binding-ihe (#551).

  • The authorization server metadata checks of RFC 8414 move out of nl-generic-functions into a crate of their own, oauth-server-metadata 0.0.1, which the OAuth 2.0 issuer audience, the FAPI 2.0 grant and the Nuts grant share. nl-generic-functions 0.0.11 drops its oauth-metadata feature (#551).

  • An error that needs a cross-reference resolver names the resolvers the build carries, [dev] or [pixm] by default and [dev] without binding-ihe (#551).

  • [federation.consent] disclose = false keeps 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 it not-resolved, with the same error as a member the cross-reference does not know the patient at, so meta.federation.complete stays false and nothing fails. 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, a new code in the error vocabulary. OPTIONS {base}/ declares the choice as federation.consent.disclose, the pre-filter metrics still count every exclusion for the operator, and the default stays the specification’s consent-denied (N27a) (#493). The setting covers a node’s own consent refusal too, a 403 with a code the registry lists in consent_refusal_codes: a federated query reports the node not-resolved with no latency_ms, and a read by subject, a routed request and an ask-all probe answer 404 subject-unavailable. On a request under {base}/v1/ehr/, the creation of an EHR and a DEMOGRAPHIC request, a node’s own 404 gets that same answer in this mode, so it never stands for a refusal alone; every subject-unavailable carries one fixed message. The node request metrics now count such a refusal as consent-denied on every path, whatever the setting.

  • nl-generic-functions 0.0.16 adds BSN_SYSTEMS and is_bsn_system to identification, beside PSEUDO_BSN_SYSTEM and URA_SYSTEM: the naming systems of the BSN itself, the IG’s $bsn system 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>.md and holding its entry, instead of editing CHANGELOG.md, so two pull requests no longer conflict on one file. The release cut runs scripts/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 new changelog job of CI runs changelog.sh --check over every fragment, and the changelog-guard job fails a pull request that adds no fragment and leaves CHANGELOG.md untouched, unless it carries the no-changelog label.

  • The operator console serves its site bundle brotli- or gzip-compressed, as the browser’s Accept-Encoding chooses, with Vary: 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-identity names the PMIR subscriber’s error ihe::pmir::PmirConfigError, after its module, where it was LifecycleConfigError, and the log target both audit log recorders write to is ihe::audit::AUDIT_TARGET, where it was ihe::xcpd::AUDIT_TARGET; its value, ferrofed::audit, is unchanged. In nl-generic-functions 0.0.18, mitz::BSN_ROOT takes its value from the BSN OID of identification::BSN_SYSTEMS, which is unchanged (#606).

  • The operator console refuses to start with [session] secure_cookie = false unless its [oidc] redirect_uri is an http URL on a loopback host (localhost, 127.0.0.0/8 or ::1), so a deployed console never sends its session cookie over plain HTTP; the refusal names session.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 LICENSE copy names that contact where it named the maintainer, so openehr-federation 0.0.42, ihe-iti 0.0.25, nl-generic-functions 0.0.17 and oauth-server-metadata 0.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-invalid or patient-value, so the call is counted as not-asked, GET /health/dependencies keeps the pre-filter’s state, and the client’s answer carries no meta.federation.consent.error. No claim value reaches a label, a log line or an error.
  • A stored-query definition that a PostgreSQL or redb store 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 answers 500. The security log records a held-definition-refused event 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-action now 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 answered 404 before.
  • 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 traceparent or tracestate. The request span recorded the client’s trace id and span id as a span link, and a client chooses its trace id freely, so a patient identifier hex-encoded in it reached the operator’s trace collector. The gateway now reads neither header: it starts its own trace for every request, as before, and every trace id and span id an exported span carries is one it generated (#446).

  • A node is never sent a request without its configured onward credential, and never the caller’s own Authorization header.

  • The error a 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 none or an HMAC, of another typ than at+jwt, from an untrusted issuer, for another audience, or outside its validity window is refused, and an issuer that cannot be asked fails closed with 503. A patient/ scope admits nothing, because the gateway cannot bind it to the token’s patient context, and a system/aql-* scope counts only for a listed backend client. The DEMOGRAPHIC API admits only the clients an issuer lists in demographic_clients. The client’s token is never forwarded to a node.

  • Outside profile = "development", serve, config check, admission check and every reload refuse a credential sent to a URL that is not https, naming the key of the URL and of the credential: a registry endpoint with a [credentials] section, an oauth2 token endpoint, a PIX Manager with credentials, and a metrics.otlp_endpoint carrying 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, a WARN log line and config check name 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 and config check like a cleartext credential (#402).

  • A reload whose file changes profile is refused with class profile, 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 an http XCPD gateway before (#402).

  • One module holds every transport rule: a URL a credential or a patient identifier is sent to must be https outside the development profile, and a key set or introspection endpoint the gateway verifies callers against must be https, or http to loopback, under every profile (#402).

  • Outside the development profile, the stored-query store’s PostgreSQL connection string must set sslmode=require when it carries a password to a networked host: disable and prefer, the driver’s default, which falls back to no TLS, are refused naming stored_queries.url by config check, the start and a reload, even though the store itself changes only on a restart. Under the development profile it starts and is named in the banner, the log and config check (#416).

  • No request reaches a node without the caller’s identity: one that reaches the dispatcher with no verified caller is answered 500 and nothing is sent. The conveyed token never carries a patient identifier (§5.4.1, N33): it holds no IUA person_id, and the outbound gate reads every caller claim, refusing with nothing sent a request whose claims would carry the identifier its query was resolved on. A client’s own openEHR-federation-client header 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] take client_identity (or client_identity_file) and trust_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 behind https with client authentication (#507).

  • A FAPI 2.0 grant as an onward credential, for a node whose authorization server follows the FAPI 2.0 Security Profile, as the BgZ/eOverdracht track of the Dutch binding does (Annex B §B.4a): a [credentials."<id>".fapi2] section names the server’s issuer, and the gateway reads its RFC 8414 metadata once, holds it to the issuer and to everything the grant sends, authenticates with an ES256 private_key_jwt assertion whose aud is the issuer, binds every token with DPoP, and sends the configured RFC 9396 authorization_details, taking only a token response that states the details granted. Token exchange per verified caller works as for oauth2. 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 an oauth2 section makes every client assertion name the authorization server’s issuer, set in issuer, as its aud in place of the token endpoint (RFC 7523 §3; FAPI 2.0 §5.3.2.1). The token endpoint stays the default (#497).

  • nl-generic-functions 0.0.10 adds the oauth-metadata feature: the RFC 8414 issuer identifier and metadata checks the Nuts grant built, now shared by both Annex B authentication tracks (#497).

  • The PDQm demographics step of Annex A §A.2, ahead of localization and resolution. With [pdqm], a patient named in a namespace the table lists is taken to a PDQm Patient Demographics Supplier first, by ITI-78 (an identifier search that asks for the master domain only) or, where the deployment declares transaction = "iti-119", by the ITI-119 $match with onlyCertainMatches. 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 member not-resolved and fails nothing (N6); several matches, two master identifiers or an uncertain match refuse the resolution and fail the query 424, and the gateway never picks one. A Supplier outage follows the localization failure policy (§14.1). The Supplier is reached over https outside development, with no redirect, a bounded answer and its own timeout, which OPTIONS {base}/ declares as timeout.demographics_ms; each exchange is audited through [audit] as the PDQm Query or Match Consumer record, required outside development; the step shows as demographics on GET /health/dependencies and is counted in ferrofed_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-iti 0.0.19: PdqmClient::match_patient posts a MatchInput to [base]/Patient/$match and reads the Match Output Bundle, every matched Patient with its score and match-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 the urn:oid:2.999 example 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 its did:web key, by the VP Token Grant Type of Nuts RFC021. The token is always bound to the section’s dpop_key_file with 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 is nl-generic-functions feature nuts-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, for TREAT or COC? 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 is consent-denied, never asked, and clears complete while 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 in meta.federation.consent.error, and a pre-filter that denied some members and failed for others is counted as partial in ferrofed_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 as nl-gf-mitz. The client is the new mitz feature of nl-generic-functions 0.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, so scripts/vendor/mitz.sh pins 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 AuditEvent and, under the new [audit] table, posted to an ATNA Audit Record Repository over the FHIR Feed of ITI-20 (destination = "repository") or written to the ferrofed::audit log 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 fails 424, a directory read is refused, an ITI-93 message is answered 503 unapplied. The repository shows on GET /health/dependencies as audit_feed, and the identity Registry’s fault can be audit-failed. ihe-iti 0.0.18 adds the balp feature, 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-functions adds lrza::ura_in, and ihe-iti reads every identifier of a directory Organization (#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 is not-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 is https outside development, the section is refused beside [xcpd] and under ask-all, and OPTIONS {base}/ declares localization.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: feature nvi asks 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 a searchset of localization records about that patient; feature lrza reads the URA of an NL-GF Organization. The source of the IG (fhir.nl.gf 0.3.0, EUPL-1.2) is vendored under docs/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 serves POST {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 from feed_token_file, and only when it holds to the PMIR profiles; otherwise it is answered 401, 400 or 415 and changes nothing. An applied merge or delete drops the resolution bindings of the ehr_ids it carries in a member’s ehr_id domain, and an update, or a change that names no member ehr_id, drops every binding. The log and the new ferrofed_identity_feed_messages_total counter carry the change kinds, counts and results, never an identifier. GET /health/dependencies reports the Registry as identity_registry, and the subscription is found by search before it is created, so a create answered late is adopted and never made twice; one in error or off, or one the Registry lost, is replaced; failed checks back off up to 32 times check_interval_s; a create answered with no usable Location stops further creates and shows as identity_registry_fault = "unmanageable"; and the drain stops the loop before it deletes the subscription. The Registry URL and the callback URL are https outside the development profile. The ihe-iti crate gains the pmir feature: the ITI-94 subscriber and the ITI-93 feed reader and response, held to the PMIR 1.6.0 package, now vendored under docs/specs/ihe-pmir/; the testkit gains a harness Patient Identity Registry. Track 8 stays deferred (#147).

  • [auth.issuer.patient] honours one issuer’s patient/ 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’s ehr_ids; config check refuses a binding whose endpoint the registry lacks, or that has no registry or no cross-reference. A bound issuer’s patient/ scope counts on an EHR’s data alone. The gateway reads the token’s ehrId claim (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, an ehr_id the pairs do not place, EHR creation and definitions are 403 (patient-confinement) with nothing sent, nothing probed and, for another patient’s read by subject, no resolution binding or index entry recorded. A query whose FROM reads a class beside its one scoped EHR (under AND or OR, a second EHR, an EHR under NOT 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-federation 0.0.38 adds Analysis::within_one_ehr, which says whether every class a query reads is contained under that one EHR. A token whose resource scopes are all patient/ is refused the DEMOGRAPHIC API the same way, bound or not and even for a listed demographic client. A token without the claim is 403 (patient-context-missing), and a cross-reference that cannot resolve the patient is 424 (patient-context-unavailable). The bare ehrId is never compared across members (§12.5). Each node’s openEHR-federation-client token carries the patient’s ehr_id at that node as ehrId, and only the covering patient/ scopes in scope (N26); the outbound gate refuses, with nothing sent, a request whose token would tell its node another ehr_id than the one the request is composed for. Without the section a patient/ 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_ratio sets the share of client requests whose spans the trace export sends, from 0.0 to 1.0, 1.0 unless 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 a traceparent, 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: a serve process’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 oauth2 section with grant = "token_exchange" and a required resource (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 node node-error with nothing sent. Exchanged tokens are cached per endpoint, caller token and scope (the caller token hashed), at most 1024, and dropped on a 401. 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 or openEHR-federation-client claim carries it.

  • DPoP-bound onward tokens per endpoint (RFC 9449, #439): an oauth2 section’s dpop_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 the DPoP scheme, 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_endpoint names an http:// collector; off by default. A federated query is one trace: the request, the resolution, the fan-out, the merge, and one node_request span 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-REST operationId, 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 W3C traceparent from its own span in that trace. A client’s traceparent and tracestate are 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_id resolved, 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 drops the caller’s bindings naming the denied member (N27a). They live in memory for federation.binding_ttl_ms and number at most federation.binding_capacity (100000 by default), and OPTIONS {base}/ names the step in its_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 of registry.document. At start the gateway reads the Organizations and Endpoints 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 on hl7-fhir-rest (§15.2, N19), a missing managing organisation (N20) or a duplicate id or system_id stops 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 on GET /health/dependencies as directory. config check reads the directory too. Credentials for the directory travel over https only, 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-iti 0.0.13: the mcsd feature carries the ITI-90 Query Client and the ITI-91 Update Client (McsdClient), every walk bounded by one Budget (a deadline and caps on pages, bytes and entries), and a replica of a directory’s Organization and Endpoint resources kept in step with ITI-91 (Replica). IHE mCSD 4.0.0 is vendored under docs/specs/ihe-mcsd/ by scripts/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 MSGID IHE+RFC-3881 over TLS (RFC 5425, tls://; tcp:// under profile = "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 created 0700 with 0600 files, and a directory open to other users refuses to start; spool_dir is required outside development, and [xcpd] home_community is required with audit = "repository", since the audit message names the gateway’s homeCommunityID (§3.55.5.1.1). Recording only writes to the spool. Delivery is bounded at every step (connect_timeout_ms for the connection, send_timeout_ms for the TLS handshake and each write and flush), and a failure drops the connection and retries with a jittered exponential backoff up to retry_max_ms. A spooled message that cannot be read or is no whole frame is moved to the spool’s quarantine/, 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/dependencies reports audit_repository (degraded while retrying, while messages wait and while any sits in quarantine), and the metrics ferrofed_audit_spool_events, ferrofed_audit_spool_bytes, ferrofed_audit_quarantined, ferrofed_audit_delivered_total and ferrofed_audit_retries_total carry no label. Where the audit messages go takes a restart.

  • ihe-iti 0.0.14: the atna feature, the ITI-20 sender (the DICOM audit message, the RFC 5424 syslog frame, the RFC 5425 TLS connection, the spool and the forwarder), and AuditEvent::message for the ITI-55 Initiating Gateway table. AuditRecorder::record is 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-denied naming the denied endpoints, never the 404 of 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 the localization.error of §14.1, with complete and the status unchanged. GET /health/dependencies reports the pre-filter as consent under the members’ rule (a decision or an answer below 500 is up, a 5xx is failing, no answer is down), absent when none is configured. Each call is counted in ferrofed_consent_prefilter_requests_total by outcome (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 reported not-localized and never asked. A localizer that fails or stays silent past federation.localization.timeout_ms fails closed: no member is asked, every member is not-localized with the localizer’s error, and meta.federation.localization.error carries it too. federation.localization.on_failure = "ask-all" widens to every member instead, and OPTIONS {base}/ declares the policy, the binding as localization.mode and the budget as timeout.localization_ms. A directed query is never localized (§8). The development cross-reference serves as the localizer under profile = "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 403 is consent-denied only when its ITS-REST Error carries a code the registry lists for that endpoint in the new consent_refusal_codes key (in the FHIR form, one https://ferrofed.eu/fhir/StructureDefinition/consent-refusal-code extension per code). The list is empty by default, so every refusal stays node-error until 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, clears meta.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 reported consent-denied with no latency_ms, is never resolved or contacted, and loses any ehr_id the 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 under federation.consent. For development, [[dev.consent_denied]] rows beside the cross-reference are a static pre-filter, accepted only under profile = "development" and declared as development-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.js redraws 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, the client_id and the scope, written in the SMART on openEHR grammar and held to the system compartment, with optional resource and audience. Each assertion is ES384, names the client as iss and sub and the token endpoint as aud, lives at most 300 seconds and carries a fresh jti (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’s 401 drops 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) at GET {base}/.well-known/jwks.json, each with its RFC 7638 thumbprint as kid, and declares the configured location as federation.auth.jwks_uri in OPTIONS {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 are https outside profile = "development"; a client certificate, extra trust roots and a SAML XUA assertion your identity provider signed are configured with _file keys, and the assertion is sent unchanged. OPTIONS {base}/ declares localization.mode = "xcpd", and [xcpd] takes effect on a reload.

  • ihe-iti 0.0.11: the xcpd feature, the ITI-55 Initiating Gateway client. It is the only feature that compiles uuid and jiff, 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 answers 424 localization-unavailable with no member asked; a targeted read is never localized (§8).

  • The localizer on GET /health/dependencies as localizer, under the members’ rule, and its calls in ferrofed_localizer_requests_total by outcome (#410).

  • The ITI-55 Initiating Gateway audit message (#410, ITI TF-2 §3.55.5.1.1): ihe-iti 0.0.12 hands every exchange’s message to an AuditRecorder, and [xcpd] audit = "log" writes it at the ferrofed::audit log target without the query parameters, which name the patient. A message the recorder cannot accept fails the discovery closed under every on_failure policy, ask-all included, and shows the localizer failing.

  • 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}/ declares localization.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 from jwks_uri, read from jwks_file, or given inline as jwks) or by RFC 7662 introspection. The key set is cached, refetched once for a key it does not hold and at most once per auth.key_set_refetch_s. Scopes are read in the SMART on openEHR grammar with openehr-sdt, and one table maps every ITS-REST operation to what it requires. A purpose of use, in the IHE IUA extension or RFC 9396 authorization_details, is required unless auth.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-required and operation-refused (403), and authentication-unavailable (503). The book has a new page, Client authentication.

  • scripts/quickstart/token.sh mints the token the quickstart gateway accepts, from a development issuer whose key pair it generates with openssl on 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, typed openehr-federation-client+jwt, signed for that one node with the gateway’s [signing] key, the key its JWK Set publishes. Its aud is the node’s endpoint id, exp is 60 seconds after iat, jti is fresh, and iss is the client_id of 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, introspection or edge), 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.sh writes the compose quickstart’s development signing key before the first docker 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 its authors, 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, serve and a reload refuse the configuration without it, naming audit.destination, and refuse "off" outside development. Set destination = "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 by docker/postgres/20-ferrofed-node-databases.sh and compose.yaml together. A quickstart volume created before this change keeps the old passwords, so the nodes are refused by their databases: run docker compose down -v once before the next docker compose up --wait, which removes the quickstart’s data and its synthetic patients, then run scripts/quickstart/seed.sh again.
  • The openehr-* family moves from 0.0.81 to 0.0.82 (FerroEHR #3557, #3558). An onward OAuth 2.0 grant now requests its configured scope in the canonical form of the SMART on openEHR grammar, as openehr-sdt prints it (#456): the permissions in c, r, u, d, s order and one space between scopes, so system/aql-*.sr is requested as system/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 the openehr-its client itself (#448): the client writes the DPoP scheme and asks the endpoint’s key for each request’s proof, and answers a node’s use_dpop_nonce challenge with one re-send. The token request proves itself and answers a token endpoint’s use_dpop_nonce the same way, with a newly signed client assertion, and under token exchange a new actor token, so no jti repeats (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 DPoP nonce and was not sent the request again is counted as asked (#470). A deadline that passes before the re-send is the node’s time-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 is node-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 is node-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 128Mi and is limited to 1Gi, and the audit-spool emptyDir has a sizeLimit of 512Mi, which holds the default spool bounds (spool_max_bytes 64 MiB, spool_max_events 100000). 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 user 65532:65532 with mode 0700 (#442), so the ITI-20 audit spool needs no step on the host. The release compose.yaml mounts the named volume audit-spool at /var/lib/ferrofed, and the Kubernetes example an emptyDir there, writable through fsGroup 65532. Set xcpd.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): an ehrId names no namespace and an ehr_id means 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, by registry.document or by [registry.mcsd]: a federating gateway without a signing key refuses to start, and config check refuses its configuration with exit code 78, naming the key.
  • The ADMIN API under {base}/v1/admin/ answers 403 (operation-refused) to every caller, where it answered 501.
  • A gateway that federates and trusts no issuer refuses to start, and config check refuses its configuration, with exit code 78.
  • [xcpd] requires audit (#410): "log", or "off", which only profile = "development" accepts and OPTIONS {base}/ declares as localization.audit. A configuration without it refuses to boot.
  • ihe-iti 0.0.12: XcpdError::Fault carries the HTTP status it came with, XcpdError::status() names the status the responding gateway answered with, an unreadable answer with a status other than 200 is Rejected, and XcpdError::Audit reports an audit message the recorder refused.
  • An onward credential that cannot be obtained fails that node as node-error with 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.error carry 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] and federation.demographic_endpoint without one, federation.node_selection and federation.id missing beside one) name both of its sources, registry.document and [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 no Organization (#434).
  • The book draws the v0.0.8 work on main as 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 on main, in the next release, and v0.0.7 as released. The README, llms.txt and 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, serve and a reload refuse a configuration whose pre-filter, PDQm and localizer budgets together reach federation.overall_timeout_ms, naming the keys, and OPTIONS {base}/ declares the budget as timeout.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 under node_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 member not-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_events and spool_max_bytes 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 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 new ferrofed_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 (424 for the query, 503 for 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 and GET /health/dependencies read 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 not https outside profile = "development" is refused while the configuration loads, as an XCPD gateway URL already was, naming its key; before, only serve, config check and 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 the ferrofed-secrets Secret the Deployment now mounts at /run/secrets/ferrofed. CI runs ferrofed config check over 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 up on GET /health/dependencies while the gateway serves the registry it last accepted (#481). The directory shows degraded, with directory_fault naming the class of the refusal (registry-invalid or configuration-mismatch) and never a member or a URL, until a later refresh is accepted. A directory that did not answer still shows down, and one whose answer is broken failing. A directory that answers a refresh with a 4xx shows failing where it showed up, and a 401 or 403 names directory_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.toml and registry.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 marked EDIT), put each credential in a file under secrets/, and run docker compose up --wait. The compose file runs the published ghcr.io/ferrohealth/ferrofed image at the release’s version and mounts the two files and secrets/ 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, the ferrofed healthcheck probe and the unless-stopped restart policy, so a configuration it refuses (exit 78, naming the key) is retried at Docker’s doubling delay. The release lane attaches the three files to the draft and refuses to publish a draft missing any of them, scripts/checks/versions.sh and the release plan hold the image tag to the product version, and the CI job release compose renders the compose file and runs ferrofed config check over the two examples as attached. The repository’s compose.yaml stays the four-node quickstart. The container page of the book has a section “The gateway from a release”.

  • The operator can send a stored-query version the registry holds to the members that missed it (#342; §12.7 stored-query-drift, N44, CP-40; no specification gives drift repair a request, so this is our own design). POST /admin/stored-queries/{name}/{version}/distribute on 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 new meta.registry member says held, where a first distribution’s says stored. It is 404 for a version the registry does not hold, 400 for a request naming no member, a body, a deployment without distribution or an endpoint-targeted definition, and 405 with an empty Allow at a read-only registry. Without the admin listener the action does not exist. Every second PUT of a held version stays 409 (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 by result, applied or refused (ferrofed_registry_reloads_total). [metrics] listen serves the Prometheus text exposition at GET /metrics on an admin listener of its own, never the gateway’s listener, and [metrics] otlp_endpoint pushes the same metrics to an OTLP gRPC collector; both are off by default. serve and config check refuse a listener on a non-loopback address unless [metrics] allow_remote = true is set, a listener on server.listen, and a collector that is no http:// URL. Every label value is drawn from a closed set or the registry document, never from a request (§5.4.1, N33). The book’s operate section has a Metrics page listing every metric and its labels. The integrity incident webhook the architecture named is removed from the design: alert on the counter.

  • The stored-query registry runs on one of three backends, chosen with [stored_queries] backend (#268; §12.7, N44, CP-40). redb, the default, is the embedded file for one gateway, as before. postgres, behind the ferrofed-server cargo 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 with INSERT … ON CONFLICT DO NOTHING stores exactly one of two racing PUTs of a new version, the other is a 409 (stored-query-held), and a read or an invocation reads the store again first, so every replica serves what any of them stored. Its connection string is a secret, url or url_file, TLS is rustls with the platform’s roots, and the schema ferrofed and its table are created when absent. files is read-only: one file per {qualified_query_name}/{version}.aql, loaded and admitted at start; a PUT is a 405 with the new code stored-query-read-only and Allow: GET, OPTIONS, OPTIONS lists no PUT, and a malformed file refuses the start and config 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 the ehr_id before anything is sent (#289; §12.4, §12.5.2, N23, N42, CP-15; ITS-REST 1.1.0 ehr_create_with_id). When a resolution binding of the session or the ehr_id index places the ehr_id at a member other than the one the targeting headers name, the create is a 409 with the new code ehr-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 the ehr_id. A create at the member that holds the ehr_id is forwarded, so that node answers its own 409, and an ehr_id nothing 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_queries beside the registry (#78; §12.7, N44, N43, CP-40). Off by default, refused by config check and at start without [stored_queries], and declared in OPTIONS {base}/ as definition.stored_query_fan_out, true only beside the registry. Where it is on, a stored-query PUT whose openEHR-federation-endpoint is * or whose targeting headers name members is stored at the registry first, then sent to each member as the registry’s canonical AQL through openehr-its’s generated client, and answered with the registry’s StoredQuery and meta.federation on the template fan-out’s statuses: 200, 207 with complete false, or 424 and 504, the registry holding the definition whatever the members answered and nothing rolled back. A definition carrying a FROM ENDPOINT or ORGANISATION directive is refused for distribution with 400 definition-endpoint-targeted before anything is stored, and stays storable and executable federated. A GET of a version naming members reports drift per member: active where its copy is the same query, and node-error with error.code definition-differs or definition-missing otherwise, 207 unless every named member matches. An invocation always runs the registry’s AQL, never a member’s copy. With the setting off, a registry PUT or version GET carrying openEHR-federation-endpoint or openEHR-federation-organisation is refused with 400 stored-query-fan-out-unsupported, storing and reading nothing, where the header was ignored before.

  • The federated AQL answer carries the provenance headers (#288; §7a.3, N31, CP-24). openEHR-federation-endpoint lists the endpoints that contributed rows and openEHR-federation-system-id their 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 answered active with a row_count above 0; one that answered no rows, failed or was never asked is not listed, an answer that fails lists none, and meta.federation.endpoints[] still names every endpoint (§11.1). The headers carry registry ids and system_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, and OPTIONS {base}/ declares the configured value as definition.fan_out_template_upload. Where it is on, an ADL 1.4 or ADL 2 template upload whose openEHR-federation-endpoint is * (every active member) or whose targeting headers select several endpoints is sent to each member independently, byte-identical through the routed forwarding path, and nothing is rolled back. The answer is meta.federation with one endpoints[] entry per registry member and no node’s body: 200 when every named member accepted, 207 with complete: false when some failed, and 424 or 504 when none accepted. The provenance headers list the members that accepted. A plain upload still names its one node, and * stays endpoint-unknown on every other definition request and whenever the setting is off.

  • ferrofed serve opens 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 and openehr-* 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. Under profile = "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 the pretty rendering, so a log collector reading JSON never receives it, and config check, healthcheck and admission check print none. It never shows a credential, a URL, a header value or request data. Each pin comes from the crate constant scripts/checks/versions.sh holds to docs/VERSIONS.md, and the openehr-* 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 the pretty log and the startup banner, whatever the format and the terminal.

  • The ITS-REST GET forms of query execution are served (#287; N1, CP-1). GET {base}/v1/query/aql and, where the stored-query registry is offered, GET {base}/v1/query/{name}[/{version}] carry the request in the query string: q (ad hoc only), offset and fetch by name, and every other pair a query_parameters member, so a stored GET binds q=… to $q. The generated openehr-its parameters decode it (FerroEHR #3540), a + is a literal plus, and the request runs the pipeline of the POST form unchanged: the rewrite, the outbound gate, the fan-out, the merge and the completeness rules, with the same node requests and the same answer. An ehr_id is dropped, as from a POST body. A query string the decoder refuses is 400 body-invalid and asks nobody; a null parameter is 400 parameter-invalid, as in a body. OPTIONS on both paths lists GET, and the request log records neither q nor 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/ answers 501 and no node is asked. The new [federation] demographic_endpoint setting declares the one registry endpoint that serves the area. A DEMOGRAPHIC request names that endpoint in openEHR-federation-endpoint and 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 with openEHR-federation-endpoint and openEHR-federation-system-id. The setting is never applied as a default: a request naming no endpoint is 400 target-required and no node is asked, one naming another endpoint is 400 targeting-conflict, and several endpoints, * or an unknown id are refused as on any routed request. config check refuses an id the registry does not hold, and the setting without registry.document. OPTIONS {base}/ declares its_rest.demographic as unsupported: 501, or as routed-single-node naming the endpoint a request names, and OPTIONS on 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, its 404 or validation 400 included, comes back as the node sent it with openEHR-federation-endpoint and openEHR-federation-system-id. Without a header the request is 400 target-required; * or an unknown id is 400 endpoint-unknown and two endpoints are 400 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-query PUTs are routed, the versioned PUT {base}/v1/definition/query/{name}/{version} included (#298), and OPTIONS on either path lists PUT. OPTIONS {base}/ declares its_rest.definition as routed-single-node, and OPTIONS on 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, --count from 2 to 50) through POST {base}/v1/ehr, each for a fresh synthetic subject in urn:oid:2.999.1.0, reads each back, and reports every condition as pass, fail or cannot-check with its evidence: ehr_id generation (a version-4 UUID, and no ehr_id issued twice), system_id uniqueness (the system_id the node reports is the one the registry records, which the registry holds unique), and the ehr_id exchange (the configured cross-reference maps each subject to the ehr_id the node created, or cannot-check where the gateway cannot write to it). Reuse across restores and adoption of foreign ehr_ids are always cannot-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 exits 0 when no condition failed, 1 when 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 serve reads 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 learned creating_system_id route the new document maps elsewhere is withdrawn with a RegisteredCreatingSystemConflict incident, and ehr_id index 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 the ERROR line names the failure class and the files, never their content. config check is 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 sends GET {base}/v1/ehr/{ehr_id} to the one member that holds it, under that member’s own ehr_id, with no query string, and returns the node’s answer unmodified with openEHR-federation-endpoint and openEHR-federation-system-id. No node request, error body or log line carries the subject. A subject several members hold is refused with the new code 409 subject-several listing the endpoints, unless openEHR-federation-endpoint names one of them; a subject no member holds is the operation’s own 404 no-destination; a cross-reference that cannot answer is the new 424 resolution-unavailable. OPTIONS {base}/v1/ehr now lists GET.

  • 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 answers 404. No prefix is reserved, and /rest/openehr is 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, naming server.base_path. Nodes are still asked at their own base, and a node’s Location still 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 canonical WHERE e/ehr_id/value = '…', so both forms send the same query and answer the same rows. An undirected query scoped to one ehr_id goes only to the member that owns it, found as a path ehr_id is (§12.5.1, N41): the session’s binding, the ehr_id index, then the ask-all probe. Every other member is reported excluded, the answer names the acting endpoint in openEHR-federation-endpoint (N31), two claimants are 409 ehr-id-collision with neither queried (N42), an ehr_id no member holds is 404 no-destination, and one that is not a bare UUID and no earlier step routes is 400 probe-requires-uuid with no member asked (§5.4.1, N33). A query directed by FROM ENDPOINT or 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_STATUS or the directory, a directory delete (each naming its preceding version in If-Match) and a composition delete (naming it in the path) route by the path ehr_id as before, and are sent only when the registry maps the preceding version’s creating_system_id to that same node, as the member’s own system_id or a [[creating_system]] mapping; a learned mapping never counts. Otherwise the write is refused 409 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 (no If-Match, several, *, a weak or unquoted tag, or no OBJECT_VERSION_ID) is refused with the new code 400 preceding-version-invalid. POST {base}/v1/ehr is now served, and it and PUT {base}/v1/ehr/{ehr_id} go only to the one endpoint the targeting headers name: without them the request is 400 target-required, and with several endpoints 400 endpoint-several; neither a binding nor the index routes a new EHR. OPTIONS {base}/v1/ehr answers POST, OPTIONS.

  • A CONTRIBUTION that 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}/contribution routes by its path ehr_id as before, and the gateway now reads the body with the ITS-REST NewContribution type: each version’s preceding_version_uid must be one the registry maps to that node, by the rule above, or the request is refused 409 controlling-system-unreachable and no node is sent it. A CONTRIBUTION of creations alone routes as before. A body that is no CONTRIBUTION in the representation its Content-Type selects, a preceding_version_uid that is no OBJECT_VERSION_ID, or an XML body, is refused 400 preceding-version-invalid. The node receives the body byte for byte.

  • A CONTRIBUTION whose versions’ data is FLAT or STRUCTURED is routed as a canonical-JSON one is (#297; §12.4, §12a.1, N23, CP-15). Under Content-Type: application/openehr.wt.flat+json or application/openehr.wt.structured+json the envelope stays canonical (ITS-REST 1.1.0 contribution_create), and the gateway reads it with the generic NewContribution of openehr-its 0.0.80 for each version’s preceding_version_uid, leaving data to the node. A CONTRIBUTION of creations alone goes by its path ehr_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, is 409 controlling-system-unreachable and no node is sent it. The node receives the body byte for byte, under the declared media type. A CONTRIBUTION in canonical XML is still refused 400 preceding-version-invalid (#308), now with a message that names the XML form.

  • Integrity incidents for an ehr_id two members claim (#63; §12.5.2, §12b.2, N42, CP-33). A request whose ehr_id the session’s resolution bindings, the ehr_id index or the ask-all probe finds at two members or more is refused 409 ehr-id-collision listing the claiming endpoints, and the gateway logs one EhrIdCollision incident: an ERROR line under the new log target ferrofed::integrity with the ehr_id, the routing step that found the claimants (detection) and the claimants’ endpoint ids. When the index learns an ehr_id it already holds at another member, it raises the index-insert alarm of §12b.2 once, an IndexInsertCollision incident naming the members, keeps both and routes neither; a held collision lasts until the entry is forgotten or the gateway restarts. An incident names an ehr_id only 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 by kind.

  • The obligations checklist (#278): conformance/obligations.tsv holds 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 the evidence cells of the conformance tables as it reads their reason cells.

  • Container readiness (#303). ferrofed healthcheck asks the gateway on this host for GET /health/readiness over loopback, prints one line and exits 0 only on 200, 1 otherwise; the image carries HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 over it, and the compose.yaml gateway service the same healthcheck. Readiness answers 503 until boot completes and from the moment SIGTERM or SIGINT arrives, 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 new GET /health/dependencies always answers 200 with 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-pinned kubeconform in 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 SIGTERM and reloads on SIGHUP through Unix signals, and every release binary and the container image are Linux. The book says so under Operate, and building ferrofed-server for a non-Unix target, such as Windows, stops at a compile_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 required conclusion, assembles the site as the Docs workflow publishes it and runs lychee 0.24.2, pinned by version and SHA-256, with --offline --include-fragments over every page, and over README.md against 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.sh runs the same check locally, with a self-test, and scripts/site/assemble.sh takes SITE_ROADMAP=off to leave the roadmap block unrendered. docs/VERSIONS.md pins 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/, and OPTIONS {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; and OPTIONS on an unserved path. The errors test now sends each of them.
  • The quickstart’s compose.yaml runs only the published ghcr.io/ferrohealth/ferrofed image: its build: block is gone, so docker compose up --build no longer builds the gateway, and scripts/checks/release-compose.sh fails when any compose file in the repository carries build: (#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, so docker compose down no 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 reported node-error until #83. Two pages are new: Identity resolution, covering [pixm], [dev], profile and federation.default_namespace, and Health probes, moved out of the container page. Prefer: wait is 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_seconds times it beside its count in ferrofed_node_requests_total, and a probe still waiting when the overall budget runs out is a time-out timed to that moment. The book’s Metrics page lists which calls each outcome and 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). A node-error carries 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 an offline or time-out node 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 AVG over integers answers an integer (#309; AQL 1.1.0 §3.9.1.5, “it will also determine the return type”). When every node’s SUM is 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, so 5 / 2 is 2 and 7 / 2 is 4; 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’s SUM is a real, AVG is the decimal mean written as the nearest JSON number, as before. openehr-federation is 0.0.33.
  • The openehr-* family moves to 0.0.81 (FerroEHR #3548, #3551, #3552), and openehr-federation to 0.0.35 with it (#329).
  • A body routed to one node without a Content-Type travels with the first media type the operation lists, application/json wherever ITS-REST lists several, where it was a 415 for an operation whose body is declared in more than one (#329). ITS-REST 1.1.0 makes Content-Type optional with no default, so the first listed is the gateway’s own choice, as for a missing Accept. The Content-Type of a body is held to the media types the operation’s body is declared in, and a CONTRIBUTION is read for its preceding versions by the request body reader of openehr-its.
  • The compose quickstart runs four FerroEHR nodes, ferroehr-a to ferroehr-d on ports 8081 to 8084, each with its own system_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 through docker/postgres/20-ferrofed-node-databases.sh, so the per-node ferroehr-a-postgres and ferroehr-b-postgres services and their volumes are gone (docker compose down -v removes the old ones). Each node database admits only its own node’s role: CONNECT is revoked from PUBLIC, 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 in urn:oid:2.999.1.1: one at all four nodes, one at two, one at one and one at none, and scripts/quickstart/seed.sh creates exactly those EHRs and a composition in each over ITS-REST. A patient query now resolves in the quickstart instead of failing closed with 424. 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 after docker 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.sh and scripts/checks/obligations.sh are 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 generated openehr-its parameters (#292, FerroEHR #3540), so a + is a literal plus. A query_type given twice, or a pair that does not percent-decode to UTF-8 text, is now 400 body-invalid and nothing is stored; an undeclared parameter stays 400 query-parameter-refused.
  • The openehr-* family moves to 0.0.80 (FerroEHR #3539 to #3541, #3543), and openehr-federation to 0.0.31 with it. The fuzz crate is now held to the family pin by scripts/checks/versions.sh.
  • An ehr_id that the session’s resolution bindings or the ehr_id index hold at two members is now refused 409 ehr-id-collision listing 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 longer 400 target-required. Naming the node in openEHR-federation-endpoint still routes the request to it (§12.5.1 step 1, N41). The integrity incidents of the creating_system_id map are logged under ferrofed::integrity too.
  • The 409 controlling-system-unreachable message now names the controlling system in every case (#66; §10.3 copy-write-reject, N36, CP-29). Where the registry routes the version’s creating_system_id to another member, it names that creating_system_id as 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 a CONTRIBUTION version by position), and still quotes nothing of the request. CP-29 is scored end to end: a write against a row that version-identity dedup kept, sent through the copy’s ehr_id, is refused 409 whether 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 own ehr_id reaches it alone and its answer names no copy.
  • A body routed to one node always travels with a Content-Type its ITS-REST operation declares (#298; §12.6, N43, CP-34). The gateway reads each operation’s request-body media types from openehr-its (request_media): a client Content-Type for an operation that declares no Content-Type parameter, such as the versioned stored-query PUT, is composed as the listed media type it names, or refused 415 media-type-unsupported; a body sent without one travels with the one media type the operation’s body is declared in, text/plain for a stored query and application/json for a commit, and an operation whose body is declared in several is 415. A forwarded body no longer reaches a node without a Content-Type.
  • Each path identifier of a routed request is parsed as the identifier class openehr-its states 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). The uid_based_id of a DELETE is an OBJECT_VERSION_ID, as ITS-REST declares: a DEMOGRAPHIC delete addressing a HIER_OBJECT_ID is 400 parameter-value-invalid, and a composition delete stays 400 preceding-version-invalid, with nothing sent.

Fixed

  • Every link in llms.txt is 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 500 when 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 a time-out in ferrofed_node_requests_total, marked down on GET /health/dependencies, and listed as time-out in meta.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 in ferrofed_node_requests_total nor timed in ferrofed_node_request_duration_seconds, and leave the member’s state on GET /health/dependencies as it was, where they counted as time-out and set the member down. 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 answer 504 (node-timeout), and the member record in meta.federation says time-out.

  • GET {base}/health/dependencies records 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 below 500 is up, a 5xx is failing, and no answer is down, read from the node’s own HTTP status whatever the §11.1 record says. A query member that answered 400, a refused store, a node that refused the gateway’s onward credentials, and a drift check whose copy differs or is missing are up where some of them were failing; a request that never left the gateway changes nothing.

  • ferrofed_node_requests_total and ferrofed_node_request_duration_seconds count 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 stays offline in meta.federation, which §11.1 leaves no other status for, and is no longer counted and timed as an offline node 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 from openehr-its’s route table, so the line never carries the ehr_id or version uid of the path, and the configured base path appears in front of it once. An OPTIONS request 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/aql and the stored-query POST {base}/v1/query/{name} hold their Content-Type to application/json, the media type ITS-REST 1.1.0 lists for both (#269). Another media type, or a parameter other than charset=utf-8, is 415 media-type-unsupported, and no node is asked (RFC 9110 §15.5.16); a body sent as text/plain used to be read as JSON. A body sent without a Content-Type is read as JSON, the first listed media type, as for a missing Accept, since ITS-REST and RFC 9110 §8.3 name no default. The stored-query definition PUT {base}/v1/definition/query/{name}/{version} the registry answers holds its Content-Type to text/plain under the same rule: another media type is 415 media-type-unsupported and nothing is stored.

  • The query-parameter-refused security 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 definition PUT that raises it too (#269).

  • OPTIONS {base}/ declares the EHR area as the gateway routes it (#290; §7a.2, N30, CP-23). its_rest.ehr now says that a new EHR, by POST {base}/v1/ehr or by PUT {base}/v1/ehr/{ehr_id}, goes only to the endpoint the targeting headers name, and that the PUT is refused when another member holds its ehr_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 four its_rest declarations 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-endpoint header or FROM ENDPOINT, or undirected with the patient resolving at one member alone, answers with openEHR-federation-endpoint and openEHR-federation-system-id naming that endpoint and its node’s system_id, as N31 requires of a request dispatched to a single node (#319; §7a.3, CP-24). Zero rows, a 424 or a 504 from that node carries them too; a query that asked no node carries neither.

  • A request routed by its target alone (POST {base}/v1/ehr and a definition request) checks its declared header and query values before its target, as the EHR route does: a malformed value is 400 parameter-value-invalid, and an Accept or Content-Type the operation does not list is 406 or 415, before 400 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 as node:a, -node or 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 the ENDPOINT attribute 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, Accept or path version_uid against, and a patient identifier written there reached the node. The gateway now works from what the ITS-REST operation declares in openehr-its’s parameter table. It composes Accept, Content-Type and Prefer itself, so the node receives a value the operation lists, in its own spelling: Accept is negotiated as an RFC 9110 §12.5.1 media-range list, */* or no Accept sending the first listed type, and one that admits no listed type is refused with the new code media-type-not-acceptable (406); Content-Type must name a listed type, with charset=utf-8 accepted and dropped, or it is refused with the new code media-type-unsupported (415); Prefer keeps only the listed preferences and ignores the rest (RFC 7240 §2). Each path identifier parses as the openEHR identifier it names with openehr-base (version_uid an OBJECT_VERSION_ID, a text uid_based_id a UID_BASED_ID, a uuid-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 by openehr-base’s Iso8601_date_time (ITS-REST Overview, “Datetime format”). A malformed value is refused with the new code parameter-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 event parameter-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-tag key, 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, an Accept parameter and a path uid. ferrofed-engine adds the declared module, which takes mime for the media types, and ForwardError::Value.
  • The Debug output of the configuration no longer prints an inline credential (#364). An inline bearer_token or password of 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 new ferrofed_registry::secret module: a Secret, which Debug, Display and Serialize render as ***, or a SecretUrl, 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 PostgreSQL url and url_file and metrics.otlp_endpoint are SecretUrls too. A _file sibling 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-identity adds PixmConfigError::BaseUrl for a Manager base URL that does not parse.
  • A PIX Manager url that carries a user name or a password is refused by config check and at start, naming pixm.manager[N].url and 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 Debug output of ihe-iti’s types no longer prints a credential (#370). PixmClient and PdqmClient derived Debug, 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, an Authorization header among them. Each client now shows its URLs with the userinfo and the query replaced by ***, and leaves the HTTP client out. mCSD’s DirectoryOrganization and DirectoryEndpoint show their fullUrl and the endpoint address the same way, and the endpoint leaves out the rest of its resource, whose header list may hold a credential. The crate carries this redaction itself and depends on nothing in FerroFED. Every redacted value in ihe-iti now shows the family’s ***, where the identifiers, the matched Patients and the page links showed [REDACTED], and so do ferrofed-identity’s PatientRef and the admission probe’s synthetic subject. ihe-iti is 0.0.9.
  • A URL whose password holds an unencoded /, ? or #, such as https://user:pa/ss@host, no longer shows that password in the rendering of a ferrofed_registry::secret::SecretUrl or of an ihe-iti type (#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 a creating_system_id the registry document does not map; one seen at two nodes raises the LearnedCreatingSystemConflict integrity 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 path ehr_id in the order of N41, never by the version’s creating_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] path set, the gateway holds stored queries itself in an embedded redb file that survives a restart, and OPTIONS {base}/ declares definition.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 version major.minor.patch, and the text is analysed as an inline query would be, with each $parameter standing in for a bound value, and held as its canonical print. A definition that names the patient by a literal is refused 400 subject-literal, so no patient identifier is held at rest; a second PUT of a held name and version is refused 409 stored-query-held and the held text stands, across a restart too. GET on the same path reads the ITS-REST StoredQuery back, and GET {base}/v1/definition/query/{pattern} lists the versions of every name the pattern starts. POST {base}/v1/query/{name}[/{version}] runs the stored query over every member exactly as if its text were sent inline, with the client’s offset, fetch and query_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’s name naming the gateway’s definition. New codes: query-name-invalid, query-version-invalid, query-version-required, query-type-unsupported, subject-literal (each 400), stored-query-held (409) and stored-query-unknown (404). Without [stored_queries] the definition routes still answer 501. openehr-federation is 0.0.30 and adds aql::definition::Definition, the admission analysis of a stored definition. Definition fan-out to the nodes is #78.

  • Routing a path ehr_id in 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 new ehr_id index names, and for a read only, the one member an ask-all probe finds. The probe sends GET {base}/v1/ehr/{ehr_id} to every member at once within the per-node timeout and the overall budget (§11.5); a read of the EHR itself is answered from the owner’s probe answer. A read no member holds is 404 no-destination, one two members hold is 409 ehr-id-collision listing the claimants, and one a member did not answer for is 504 (node-timeout, node-unreachable) or 424 (node-refused, and the new code node-error), naming the member: a member that gave no answer may hold the ehr_id, so the owner is unknown. A read that names no node no longer answers 501. A write none of the first three steps routes stays 400 target-required, and nothing is probed. A step that names two members names none, and no later step picks one of them. The path ehr_id must be an openEHR HIER_OBJECT_ID, and any other value is 400 with the new code ehr-id-invalid before any routing. The index learns from resolutions and from members’ successful answers, holds ehr_ids and member ids only, in memory, and forgets the least recently used ehr_id past federation.ehr_index_capacity (default 100000; 0 is refused). Session bindings answer once client authentication lands (#80); the integrity incident of a collision is #63.

  • OPTIONS {base}/, the gateway’s self-description (#73; §7a.2, N30, CP-23). The body validates against the vendored options-root.schema.json and is built from the running configuration: the federation id, the major.minor of the pinned specification, aql.fan_out from 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, the OFFSET strategy (bounded with its max_window, or reject, never a cursor), the decomposable aggregates (an empty list when none), nothing offered in the definition area, localization.on_failure: "closed", the ITS-REST areas, and every registry endpoint with its organisation, membership status, node, system_id, and product and version where the registry holds them. It declares no targeting mechanism, no resolution carrier, no asynchronous queries and no JWKS location, and it needs no patient identifier. OPTIONS on a path under {base}/v1/ answers 204 with the methods served there in Allow, without asking a node, and 501 where 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/id or p/endpoint_id, p/organisation or p/organization_id, p/system_id or p/url through the FROM ENDPOINT variable gets each value in every row, as a string, from the registry entry of the endpoint the row came from: the values meta.federation.endpoints[] reports for it. No node is asked for an attribute, and a query that selects none keeps the row shape of a single CDR. The §9.4 example answers the §9.4 columns and rows, with columns[] 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 refused 400 with the new refusal endpoint-name-collision, and a path through the variable that is no §9.3 attribute with endpoint-attribute-unknown. Under DISTINCT the attributes take part in which rows are equal. An attribute beside an aggregate recombined across endpoints is refused 400 indecomposable-aggregate, and ordering on an attribute stays refused 400 endpoint-variable. The 501 not-implemented answer for these queries is gone. openehr-federation 0.0.27 adds the attribute module with EndpointAttribute, ColumnSource::Endpoint(EndpointAttribute), Analysis::attributes, NodeAnswer::with_attributes and Merged::attributes; the engine adds Plan::annotating and FederatedAnswer::attributes. CP-37 is covered.

  • Track 10, the adversarial identifier-leakage suite, judged on node-side wire capture (#90; §16.3 track 10, §5.4, N33, N34, N5, CP-26). One synthetic patient identifier is supplied in each of the four positions the track names: an EHR_STATUS.subject.external_ref predicate, a PARTY_IDENTIFIED/DV_IDENTIFIER predicate, a SELECT projection, 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 the 400 the specification names, with no node asked. Every dispatched request must locate its node by the node’s own ehr_id alone, and a projected subject column must be the re-injected identifier. The converse check commits a COMPOSITION with the identifier in a DV_IDENTIFIER and 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 behind FERROFED_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-testkit adds the leak module, the search over the proxy journal.

  • The openEHR-federation-endpoint and openEHR-federation-organisation request headers, the targeting mechanism beside the AQL (#71; §8.4, §8.4.1, N35, CP-28). Each carries a comma-separated list of registry ids, over one field line or several, and selects nodes exactly as the FROM ENDPOINT and ORGANISATION directive does: a listed endpoint where the patient is not known is not-resolved, every other endpoint is excluded, an identifier the registry does not know, or a header with no identifier in it, is a 400 (endpoint-unknown, organisation-unknown), and a selection of no endpoint is 404 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 refused 400 with the new code targeting-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 refused 400 query-parameter-refused like any parameter the operation does not declare. Neither header reaches a node: both join Authorization and X-Request-Id in 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.document names a FHIR R4 JSON Bundle of Organization and Endpoint resources, 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 under https://ferrofed.eu/fhir/sid/, and an endpoint’s connectionType is openehr-rest-query in https://ferrofed.eu/fhir/CodeSystem/connection-type. config check refuses, naming the resource, an endpoint whose connectionType is hl7-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-iti 0.0.8 reads the directory content under its mcsd feature, resolving references inside the Bundle as FHIR R4 §2.36.4.1 does.

  • The FROM ENDPOINT and ORGANISATION directive in AQL (#70; §8.1, §8.4.1, N11, N19, N20, CP-6). FROM ENDPOINT p ["node-a-pub", …] asks exactly the listed endpoints, and FROM 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 own ehr_id, a listed one where the patient is not known is not-resolved and fails nothing, and every endpoint the directive does not list is excluded. The directive is parsed by openehr-query’s federation feature 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 refused 400 with the new codes endpoint-unknown and organisation-unknown, which locate it by its place in the list and never quote it, and an organisation that manages no endpoint answers 404 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 refused 400 with the new refusal endpoint-variable. openehr-federation adds aql::directive::FacadeQuery, Context::with_targeting, Analysis::sources and ColumnSource::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 a creating_system_id that is no member’s own system_id to an endpoint, and a member’s own system_id maps to that member without one. The document is refused, by config check too and naming the creating_system_id, when a mapping names an undeclared endpoint, maps one id twice (ASCII case aside), or maps a member’s own system_id. ferrofed-registry adds 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 at ERROR with 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-identity gets one copy of a version held at several endpoints, keyed on the full OBJECT_VERSION_ID of the row’s COMPOSITION, else VERSION, uid as openehr-base reads it. The copy kept is the one from the endpoint whose registry system_id is the version’s creating_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.dedup records the mode on every answer, none and failing 424 and 504 envelopes included, and beside the rows suppressed_rows and suppressed_endpoints[], counted before DISTINCT, OFFSET and LIMIT. Every node is asked the version uid, as a hidden column when the client does not select it (under DISTINCT only a selected uid is the key), and a query with a LIMIT and no ORDER BY is ordered on it. Under the mode a tie on the ORDER BY keys breaks on the uid before endpoint_id, which keeps a per-node LIMIT n (or k + n for a page) exact after suppression (§11.6.1). The default stays none (§10.1), which none states explicitly; any other value, or a repeated header, is 400 dedup-invalid. A node whose version uid is not an OBJECT_VERSION_ID is node-error, and the query fails 424 under all-or-nothing. A recombined aggregate under the mode is 400 indecomposable-aggregate (§11.6.3). openehr-federation 0.0.20 adds dedup::DedupMode, aql::Context::with_dedup, order::ResultOrder::with_version_key, merge::NodeAnswer::with_system_id, merge::Suppressed, merge::Disagreement::VersionId and aql::refusal::Indecomposable::Dedup.

  • SELECT DISTINCT at 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 (2 and 2.0 are one value, two spellings of one instant are two), and the duplicates are removed before the LIMIT and the OFFSET (AQL 1.1.0 §LIMIT), so a duplicate no longer takes two slots of a LIMIT n answer or of a bounded OFFSET page. The copy kept is the first under ORDER BY, then endpoint_id. A re-injected subject column and a column the gateway adds never make two rows distinct. Each endpoint’s row_count stays what it contributed (§9.5). A node that returned its full LIMIT with two rows the Tier holds equal is node-error, because a distinct row can lie past its cut, and the query fails 424 under all-or-nothing. openehr-federation 0.0.18 adds order::ResultOrder::with_distinct and distinct, and merge::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 by openehr-its’s routes::lookup, is forwarded once to the node the openEHR-federation-endpoint header names, through Client::forward. The body reaches the node byte for byte, a DV_IDENTIFIER in a committed COMPOSITION included, and the node’s status, body, Location and ETag come back unmodified, a 404 or 500 included. Every routed answer names the acting endpoint and its node’s system_id in openEHR-federation-endpoint and openEHR-federation-system-id. Only the request headers and query parameters the matched ITS-REST operation declares travel, read from openehr-its’s per-operation parameter table (FerroEHR #3530): If-Match reaches the node on a PUT and never on a GET. The client’s Authorization and X-Request-Id never travel, and the node receives the request’s minted X-Request-Id. An undeclared query parameter, and subject_id or subject_namespace anywhere, is refused before dispatch. New codes: target-required (a write that names no node, 400), endpoint-unknown and endpoint-several (400), query-parameter-refused (400), node-timeout and node-unreachable (504) and node-refused (424, a node that refused the gateway’s onward credentials). A read that names no node still answers 501 until #62 routes it. A node’s 3xx is its answer, passed on with its Location unmodified, 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, MAX or AVG is 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; MIN and MAX are re-applied over numbers and complete date-times; AVG is asked of each node as its SUM and COUNT. A node value the recombination cannot use is node-error, so the query fails 424 with no value. DISTINCT, COUNT(DISTINCT …) and a plain column beside an aggregate are refused 400 (indecomposable-aggregate), and a partial request for a recombined aggregate is refused 400 (partial-aggregate). The new [federation] decomposable_aggregates list (all five by default, [] for none) declares the functions, and an undeclared one is still undirected-aggregate. A directed single-node aggregate is sent unchanged. openehr-federation 0.0.13 adds the aggregate module, aql::Context::with_decomposable_aggregates and merge::combine, and takes rust_decimal 1.43.0, already in the tree through openehr-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 as excluded with the reason, which stays out of scope, so it neither clears complete nor fails the query. A [[node]] of the registry document may record its CDR product and version, which the report carries only from there and omits when the registry does not say. latency_ms appears exactly for the endpoints the gateway dispatched to, and row_count counts what each node contributed before any federation-level DISTINCT, dedup or LIMIT.

  • 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 answers 200 with the rows of the nodes that answered, names every other node with its status, and sets complete: false. A cross-reference that cannot answer is reported there and is not a 424. all is accepted explicitly. Any other value, a repeated header, or partial where federation.best_effort = false withdraws the mode is a 400 that asks no node and never quotes the value. The not-resolved and consent-denied carve-outs and the scope rule hold in both modes. complete is 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 in meta.federation.timeout, and a wait that set it is echoed in Preference-Applied. Only the first wait counts; a malformed one is ignored and never refused. wait=0 asks no node and reports each one time-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 BY with LIMIT re-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’s LIMIT n unchanged, with the row’s uid appended as the last ORDER BY key; an ORDER BY path 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_ORDERED values through the openEHR RM’s own comparison), breaks ties on the endpoint id and then the uid, and cuts the result at n. A node that returned n rows out of that order, or more than n, is reported node-error, so the query fails 424 under all-or-nothing. TOP n is read as LIMIT n; TOP n BACKWARD, a TOP beside a LIMIT clause, and a DISTINCT query ordered on a path it does not select are refused 400. A query with LIMIT and no ORDER BY now returns at most n rows across all nodes.

  • OFFSET paging across a fan-out (#53; §11.6.2, N9, N39, CP-32). OFFSET never reaches a node. Under the default federation.offset_strategy = "bounded", ORDER BY … LIMIT n OFFSET k asks each node for LIMIT k + n, checks each node’s visible order as for LIMIT n, merges in the federation order and returns rows k to k + n. A page whose k + n is past federation.max_offset_window (1000 rows per node by default; 0 refuses to boot), an OFFSET with no LIMIT, and an OFFSET with no ORDER BY are refused 400, the first naming the bound. offset_strategy = "reject" refuses every OFFSET past zero 400. The ITS-REST offset and fetch members follow the same strategy.

  • The comment-style guard checks citations (#173). It fails on a Rust comment, doc comment or lint reason that cites docs/architecture.md, a path under .claude/ or a rule file by name, or that names a decision-register entry such as decision A17. It applies the same check to the full-line # comments of the shell scripts under scripts/ and of every Cargo.toml. comment-style.sh --self-test proves 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 Error body with a stable code and the request_id, and its status follows one table. A refused query is a 400 named by its refusal (not-aql, unreducible, offset-unsupported, and the rest), an unknown path a 404 not-found, an unexposed ITS-REST area a 501 not-implemented, and the gateway’s own fault a 500 internal, and a request with no member in scope a 404 no-destination; ehr-id-collision and controlling-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 versions guard 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 of CHANGELOG.md, so a release cut that forgets the page fails. Each specification row of docs/VERSIONS.md must name a crate constant (FEDERATION_SPEC, ITS_REST, AQL) that carries the version the row pins. versions.sh --self-test proves 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-write writes the files and the README block between conformance:begin and conformance:end, and the conformance-matrix guard fails when either drifts from the matrix or the pass list. The golden test fails when a case in conformance/aql-golden/pass-list.txt stops passing or an unlisted case passes, and rewrites the list when FERROFED_CONFORMANCE_UPDATE is 1. Case 02 (FROM ENDPOINT) is refused until #70 and is not counted as passing. The static badge row gains the image-pulls badge for ghcr.io/ferrohealth/ferrofed.

  • The versions guard holds the book’s “Pinned versions” page to docs/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, an edition 2024 or an abbreviated commit, must be the one those rows pin; a pin the guard cannot read fails rather than passing unread. The page now lists all five openehr-* crates, the vendored PIXm and PDQm packages and the specification’s source commit as rows of their own. versions.sh --self-test proves 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 beside Prefer: wait, gets the ordinary synchronous answer under the same budget: never a 202 or a Content-Location, and Preference-Applied never names respond-async (RFC 7240 §2, §3). A bounded OFFSET page carries no cursor handle or expiry in meta.federation and runs the fan-out on every request.

Changed

  • A gateway with registry.document set now refuses to boot without federation.id, the federation’s own identifier that OPTIONS {base}/ names (#73; §7a.2, N30). Add id = "<your federation>" to [federation]; an empty id is refused.

  • The openehr-* family moves from 0.0.78 to 0.0.79 (#234). The openehr-rm attribute model now holds the BASE primitives, the Ordered marker 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 under DISTINCT. openehr-federation is 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 own Authorization composition (FerroEHR #3535), for each endpoint and each PIX Manager alike. A bearer token must now be the b64token of 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 by config check and 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-federation is 0.0.26.

  • The openehr-* family moves from 0.0.76 to 0.0.77 (#195). openehr-query now 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 the CONCAT, CONCAT_WS and SUBSTRING folding and the DISTINCT cut check read the crate’s classification too. Every openehr-its client is built with redirects off (FerroEHR #3531), so a node’s 3xx answer is never followed: the node is node-error.

  • Incompleteness is carried by meta.federation.complete alone, and the gateway emits no FHIR OperationOutcome (#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-REST RESULT_SET whose federation additions live under meta.federation. The conformance matrix gives CP-12 that reason beside its scored status codes, a test asserts that a best-effort 200 and an all-or-nothing 424 each carry complete: false, validate against federated-result-set.schema.json and hold no OperationOutcome, 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-style citation checks read the conformance tables under conformance/: their # comment lines and their reason column, 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 version docs/VERSIONS.md pins, with no second copy of it in scripts/conformance/matrix.sh. openehr-federation 0.0.16 marks its local list of AQL function names for removal (#195).

  • The comment-style citation checks cover the rest of the tree (#178): the full-line comments of the workflow and composite-action YAML under .github/, the echo and printf text of a workflow run: block, clippy.toml with its reason strings, and the quickstart TOML under docker/. 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 their PROVENANCE.md.

  • The comment-style citation checks refuse a citation of any markdown file under docs/ outside the vendored docs/specs/ tree (#184): a path that opens a parenthetical or is followed by section or §. Naming docs/VERSIONS.md as the file a script or test reads still passes. The checks also read every YAML description: 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 them fuzz.yml, the setup-rust action description, the version and corpus scripts and the CI rule. The crate manifests’ comment on the 0.0.0 name reservation changes with them, so openehr-federation moves to 0.0.15, ihe-iti to 0.0.7 and nl-generic-functions to 0.0.4 with no change to their code.

  • The comment-style citation checks also refuse a citation of a README.md of the tree, at the root or in a member, outside the vendored docs/specs/ and vendor/ trees (#198), in the same citation form. Naming a README as a file a script reads still passes. fuzz.yml cites 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 plan accepts, and finalize-release publishes only when every build leg succeeded. The branches for a tag with no workspace, which plan now refuses, are gone. The book’s claims page links to the pinned-versions page for the openehr-* pin instead of restating the number, and docs/ci-cd.md names all four self-tests of the tracker-helpers job, rel.sh among them.

  • scripts/gh/fields.sh checks its arguments before any gh call (#198). No argument, an unknown command, a wrong operand count or --help prints the usage to stderr and exits 2, with no network call and no token needed. fields.sh --self-test proves it against a stub that records every call.

  • The per-edit comment-style hook also reads the conformance/*.tsv tables (#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’s 500) answer the ITS-REST Error shape with code and request_id, where they named the code in an error member, 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); a 424 or 504 under all-or-nothing stays the §11.4 result set and echoes the client’s own q (N17). A node row shorter than the dispatched query selects is now that node’s node-error (424, or reported under partial), where the whole query answered 502 (§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 an additional_properties map (FerroEHR #3526), so every error body is the generated ITS-REST Error with code and request_id in 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 with docker 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 . in PARTY_REF.namespace, which openEHR BASE admits (#118). In compose.yaml the services are ferroehr-a and ferroehr-b, each on its own database and with its own system_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’s FERROEHR_PORT is now FERROEHR_A_PORT). Every end-to-end case seeds the patient’s subject on both nodes, and the e2e (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, enhancement and P0 to P3 labels are retired, the bug and feature forms set the issue type, and scripts/gh/fields.sh and scripts/gh/migrate-fields.sh carry 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 to scripts/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 the tracker-helpers job, the self-tests of the scripts/gh helpers, 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 DISTINCT query with ORDER BY and LIMIT (or a bounded OFFSET page) that selects a path whose value AQL defines no order for is refused 400 (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 and Ordered types” (AQL master03-syntax §ORDER BY), so a whole RM object (SELECT DISTINCT c), a data value that is not a DV_ORDERED (c/name, a DV_TEXT), the DATA_VALUE of an ELEMENT, a collection and a path the RM does not resolve are not keys. The rewrite decides it by walking the path through openehr-rm’s static model from the class FROM binds, asking the model’s is_primitive and conforms_to_ordered lookups and following a reference to the target class the model names (FerroEHR #3537). Under DISTINCT only the selected paths to a primitive or a DV_ORDERED are pushed as ORDER BY keys, so the same query without a LIMIT is answered and the other paths are compared at the Tier alone. A path to a primitive value, such as c/name/value or …/value/magnitude, and an ordered data value, such as c/context/start_time, are pinned as before. The aql feature of openehr-federation now depends on openehr-rm; the crate is 0.0.28 and adds Refusal::IncomparableDistinctKey.
  • A query with ORDER BY and LIMIT (or TOP, the fetch member, or a bounded OFFSET page) ordered on a path whose value AQL defines no order for is refused 400 with the new code incomparable-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 and Ordered types (AQL master03-syntax §ORDER BY), and §11.6.1 holds the federated first n rows in each node’s first n only “under a total order”, so a node’s cut on ORDER BY c/name LIMIT 10, a DV_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 no LIMIT is answered: every row reaches the Tier, which orders them all under its own total order, the same on every repeat. Under DISTINCT the refusal stays incomparable-distinct-key. openehr-federation is 0.0.29 and adds Refusal::IncomparableOrderKey.
  • Under version-identity dedup, SELECT DISTINCT compares the version uid without regard to case, as the dedup and the Tier order already do (#234; §10.2, CP-8, CP-9; BASE master05-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 its LIMIT that returns both is refused as node-error. Outside dedup a uid compares as sent, in step with the Tier order.
  • A SELECT DISTINCT query with ORDER BY and LIMIT (or a bounded OFFSET page) whose selected function column the selected paths do not fix is refused 400 (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 under DISTINCT the 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 as LENGTH(c/name/value) beside c/name/value, is answered as before; a call that reads a path the query does not select, a clock function such as NOW(), and TERMINOLOGY are refused. The same query without a LIMIT is answered. openehr-federation 0.0.22 adds Refusal::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 an ORDER BY key and as the tie-break, so an ORDER BY with LIMIT and a page under dedup stay exact for them. Every row keeps its uid as its node sent it. The comparison is openehr-base’s composite_id_key. A node cut at its LIMIT that orders uids byte for byte and returns them in an order the Tier’s disagrees with is refused as node-error, since the Tier cannot change how a node sorts. Outside dedup the uid orders as before, by code point. openehr-federation is 0.0.23.
  • An onward credential the Authorization header cannot carry is refused at configuration load, by config check and at boot with exit code 78, in place of failing every query to its endpoint with a 500 (#231). A bearer token, inline or read from bearer_token_file, is checked as the Bearer <token> value the node client sends, by the same http parse 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 _file sibling, .user or .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 whose system_id differs from the version’s creating_system_id only 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 is openehr-base’s composite_ids_equal and composite_id_key, which the registry identifiers already use, and the session-scoped resolution bindings now key on EhrId in place of their own case fold. openehr-federation is 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, which OutboundId::mint makes 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 same request_id as 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 planned again 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 and 409 half is #66. The dedup tests carry CP-9 alone. scripts/checks/crate-version-guard.sh run 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 merge module doc of openehr-federation is wrapped like the rest of the crate’s docs, and openehr-federation moves to 0.0.19 with no change to its code.
  • An ORDER BY with LIMIT query whose rows carry no uid, such as one that selects from EHR alone, 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 first COMPOSITION or VERSION as before, else <ehr>/ehr_id/value, else the uid of an EHR_STATUS or EHR_ACCESS; the key travels as a hidden column and never reaches columns[]. A node cut at LIMIT n, or at the LIMIT k + n of a bounded OFFSET page, then keeps the same tied rows each time, so two pages agree on the rows tied across their edge. A patient query, scoped to one ehr_id per node, gets no EHR key; FOLDER is never a key; a DISTINCT query 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-federation 0.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), in SELECT or WHERE is refused 400 (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, and TERMINOLOGY) are still sent to every node as written, and the five aggregates keep the decomposition rules. openehr-federation 0.0.14 adds aql::refusal::Refusal::UndefinedFunction.
  • A query that uses TOP together with a LIMIT clause is refused 400 (top-with-limit), whether or not the two counts agree, because AQL forbids the pair (#162; AQL §TOP, §LIMIT). A TOP query sent with the ITS-REST fetch member is refused 400 (top-with-fetch), because ITS-REST says fetch “cannot be combined with AQL-top”. TOP n alone is still read as LIMIT n.
  • A federated query that leaves no registry member in scope, because every endpoint is excluded (suspended by the operator, for example), answers 404 and asks no node, where it answered 200 with empty rows (#166; §11.1, §11.2, §11.3). A candidate set that localization left empty is not that case: every member is not-localized, and the answer stays 200 with complete: true (§14.1). A patient who is not-resolved at every member in scope still answers 200.
  • A gateway that federates refuses to boot, and config check refuses the file, unless server.request_timeout_ms exceeds federation.overall_timeout_ms by 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’s 408, with no body, cut a slow fan-out instead of the 504 that carries meta.federation.
  • The documentation describes the gateway that exists (#163). The openehr-federation README names both subject carriers as resolution input. The book’s client contract and configuration pages name POST /v1/query/aql as served, and the _file secrets 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.txt describes 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 the research label no longer describe the repository as being in a design phase.
  • scripts/gh/labels.sh prints its usage and exits 2 on any argument it does not know, --help included, before it calls gh (#180). It ignored such an argument and wrote the label taxonomy to the repository. Its --self-test proves the refusal.
  • The ferrofed-engine crate 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 the openehr-* pin as 0.0.76 and the PIXm and PDQm packages as vendored.
  • scripts/gh/rel.sh prints its usage and exits 2 before it calls gh on a usage error: no argument, an unknown command, a wrong operand count, a flag other than --replace, or --help (#191). It exited 1 with no argument, 0 on --help, and resolved the repository through gh first. Its new --self-test, run by the CI tracker-helpers job, proves each write’s endpoint and each refusal.

Security

  • The ask-all probe no longer sends a path ehr_id that is not a bare UUID to every member (#259; §5.4.1, §12.5.1, N33, CP-26). The path ehr_id was checked only as a HIER_OBJECT_ID, a form that admits a bare national number as a one-arc ISO OID, so a patient identifier in the ehr_id slot of a read nothing routed was broadcast to the whole federation. A read whose ehr_id is an ISO OID, an internet id or a UUID with an extension, and that no targeting header, resolution binding or ehr_id index entry routes, is now refused with the new code probe-requires-uuid (400), and no member is asked. The same ehr_id with the endpoint header is forwarded to the named node alone, and the index then routes it, since a member may mint ehr_ids in a scheme other than UUID (§12b.2, N42a). The refusal is logged as the security event ehr-id-probe-refused, which never names the ehr_id. Track 10 gains the identifier in the ehr_id slot. ferrofed-registry adds EhrId::is_uuid, and ferrofed-engine’s probe::Probe carries a ProbedEhrId, 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 serve used 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 through tracing, “a thread panicked”, with the source location and, inside a request, the gateway’s request_id, and never the message. It writes nothing to stderr. A panicking handler still answers 500, 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-server adds panic::install_hook and request_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-Id no 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 a client_named flag 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-engine adds outbound_id::OutboundId, which only OutboundId::mint makes, and DispatchOptions::with_request_id and fanout::fan_out take 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, feature pdqm (#119; PDQm 3.2.0, ITI TF-2 §2:3.78). pdqm::PdqmClient posts a PatientQuery to [base]/Patient/_search as a form body, so no URL carries a demographic value, and reads the searchset into the matching Patients (FHIR R4 Patient from fhir-types resources), the total, each match’s score and match-grade, the OperationOutcome warnings and the next page link, which it follows only on the Supplier’s origin. A 404 with a not-found issue 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 under docs/specs/ihe-pdqm/ by scripts/vendor/ihe-pdqm.sh. The OperationOutcome issue type moves to ihe_iti::outcome::IssueType, shared by PIXm and PDQm; ihe-iti is 0.0.5.
  • The identity-lifecycle hook for track 8 (#48): ResolutionBindings::identity_changed drops every resolution binding a merge or split at the identity source could have made stale, by ehr_id in every session or all of them for an unscoped change, and Federation::identity_changed is 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 an ehr_id, and the others are not-resolved without failing the query (N6, N8). The selection is named in the startup log; the OPTIONS self-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::PixManager is 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 conditional PUT Patient?identifier= held to the IHE.PIXm.Patient minimums, plus the Remove Patient Option’s conditional DELETE) and answers ITI-83 $ihe-pix from what was fed, with each case of ITI TF-2 §3.83.4.2.2. The seed builder feeds it a synthetic patient in the urn:oid:2.999 arc with one ehr_id per 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 Source CapabilityStatement, the Patient profiles 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_id literal the rewrite scoped the query to, so a short identifier that occurs inside the node’s own ehr_id is answered. Every patient predicate the rewrite strips, every refused query and every gate stop is a security event under ferrofed::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-federation 0.0.5 adds Refusal::kind, Refusal::at and PatientQuery::stripped for 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 the ehr_id domain 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 is not-resolved in meta.federation, a patient known nowhere is a 200 with no rows and complete: false, and a Manager that cannot answer fails the query 424 with 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 by federation.binding_ttl_ms (default 15 minutes), and nothing derived from a patient identifier is stored or logged.
  • The PIXm ITI-83 client (#42): ihe-iti 0.0.3, feature pixm, asks a PIX Manager’s Patient/$ihe-pix for the identifiers other domains hold for a patient, held to the vendored PIXm 3.1.0 OperationDefinition and 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). A 404 without a not-found issue never reads as “patient unknown”. Identifier values are redacted in Debug and carried by no error. The IHE PIXm 3.1.0 package artefacts of ITI-83 are vendored under docs/specs/ihe-pixm/ by scripts/vendor/ihe-pixm.sh, pinned by version and tarball sha256, and the FHIR R4 model comes from fhir-types 0.1.107.
  • The fuzz lane (#134): four cargo fuzz targets over the untrusted inputs (the AQL rewrite over arbitrary text and parameters, the ITS-REST AdhocQueryExecute body through the façade’s intake, a federated RESULT_SET with its meta.federation, and the OPTIONS {base}/ body), with seeds generated from the vendored golden cases and the specification’s JSON examples by scripts/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.yml runs 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-level subject predicate, …/subject/identifiers/id, is resolution input on equal terms with EHR_STATUS.subject.external_ref, with the DV_IDENTIFIER issuer or type as its issuing namespace. It is consumed and stripped exactly as external_ref is, 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 a 400, as are the assigner, a predicate on the identifier list, an ENTRY-level external_ref, and the carrier selected, ordered on or inside a function. openehr-federation is 0.0.4, and the interim Refusal::EntrySubject is gone.
  • The release lane at SLSA Build Level 3 (#31): the reusable release-build.yml builds ferrofed per target with cargo auditable, writes a CycloneDX and a syft SBOM, and attests the tarball’s provenance and both SBOMs; the reusable release-image.yml builds the linux/amd64 and linux/arm64 image from the attested musl binaries, pushes it to ghcr.io/ferrohealth/ferrofed by 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/aql answers one ITS-REST RESULT_SET over every member of the registry, with no federation syntax needed (N1, CP-1).
    • The gateway types the query_parameters, analyses the query with openehr-federation’s aql rewrite, resolves the patient at every member through the configured cross-reference, and sends each member that knows the patient standard AQL keyed on its own ehr_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.federation names every endpoint (N16): a member that does not know the patient is not-resolved, a suspended endpoint and a second endpoint of one member are excluded.
    • A refused query is a 400 ITS-REST error that locates the fault and quotes nothing (§5.4.3). Without a cross-reference a patient query fails closed with 424 (decision A17), and without a registry the route stays 501.
    • The configuration gains profile, [registry] document, [federation] (per_node_timeout_ms, overall_timeout_ms below the request timeout, default_namespace) and the [[dev.crossref]] rows of the development profile. config check loads the registry too.
    • The compose quickstart mounts docker/quickstart/registry.toml and ferrofed.toml, and the README shows one federated query returning the EHR of both nodes. The end-to-end test runs it against FerroEHR and EHRbase behind their capturing proxies, and no node request carries the patient identifier in any carrier (N33).
  • The fan-out engine, first increment (#37): ferrofed-engine’s fanout module sends one request per in-scope node at once, each under a per-node deadline cut to the overall budget, with no retry and no hedging (§11.5, N38). A node still outstanding when the overall budget runs out is abandoned and reported time-out, without touching any other request, and its late answer contributes nothing. meta.federation is built from every outcome before the decision, so a failing answer carries it, with the effective timeout budget and complete: false. Under the all-or-nothing default an offline or time-out node fails the query 504, a node-error fails it 424, 504 taking precedence; not-resolved and consent-denied fail nothing, so a patient found nowhere is a 200 with empty rows (§11.3, §11.4, N6, N37). Every endpoint record carries its node, system_id, managing organisation, base URL and latency_ms (§9.5, N40), and the rows of the active nodes are concatenated in endpoint id order until the merge (#52).
  • The crates.io lane behind the workspace publish switch (#32): publish-crates.yml runs on every v* tag, reads the publishable set from cargo metadata, packages it and publishes it in dependency order through crates.io Trusted Publishing, and is a successful no-op while the root [workspace.package] publish is false. The publish-dry-run job packages every library crate on every pull request, and scripts/release/publish-crates.sh is the shared implementation.
  • Node dispatch (#34): ferrofed-engine’s dispatch module builds one openehr-its client per registry endpoint, rooted at the endpoint’s base URL as the registry holds it plus the ITS-REST v1 segment (N28), and sends each node query as the generated POST {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 to active, a refused connection or broken stream to offline with its reason, a passed deadline to time-out, and a documented error, an undocumented status or a body that is not a result set to node-error carrying 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 aql feature of openehr-federation 0.0.3 parses a façade query with openehr-query, binds its query_parameters before analysis, and consumes the EHR_STATUS.subject.external_ref patient predicate with its namespace into a redacted Subject (§5.2). Each node receives the query scoped to its own ehr_id (§7.1, N7, N29); a selected subject or namespace column is re-injected after the merge (N5); and columns[] is rendered from the façade query alone (N17). The rewrite refuses with a typed 400, never quoting the identifier: a predicate outside the top-level AND chain 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-node OFFSET (§11.6.2), an undirected aggregate (N14), and the identifier anywhere else in the query (§5.4.1), including rebuilt by CONCAT, CONCAT_WS or SUBSTRING over 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-registry 0.0.1 loads the federation’s membership from a reviewed TOML bootstrap document ([[organisation]], [[node]] with its system_id and [[node.identifier]], [[endpoint]]) with deny_unknown_fields throughout into an immutable RegistrySnapshot. A document with a dangling reference, a duplicate id, a system_id shared by two nodes (compared without ASCII case), an endpoint without exactly one managing organisation, a connection type other than openehr-rest-query, an unusable base URL or a node without an endpoint refuses to load (N19, N20, N21, §12b.2). NodeId, EndpointId and SystemId are distinct types with no conversion between them (N32, §12a.1).
  • The resolver seam and the development cross-reference (#36): ferrofed-identity 0.0.1 carries PatientRef, the patient identifier redacted in every rendering and never serialized (§5.4, N33), the Resolver trait (N3, §5.2), and StaticResolver, a fixed table from a synthetic identifier to each member’s ehr_id that a configuration can enable only under profile = "development". It is FerroFED’s own testing device, not an identity binding.
  • The container image: docker/Dockerfile puts the ferrofed musl binary on distroless static, digest-pinned, as the numeric non-root user 65532, with no shell and a read-only root; scripts/release/stage-dist.sh stages the binaries of a published release after checking their checksums. compose.yaml starts the gateway beside two member CDRs, FerroEHR 4.3.1 and EHRbase 2.36.0, every image pinned by digest in docs/VERSIONS.md and 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 the FERROFED_E2E gate; 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 the urn:oid:2.999 example arc. CI runs the container suite in its own e2e (containers) job, and the versions guard holds the image pins to docs/VERSIONS.md.

Fixed

  • ihe-iti 0.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/ferrofed and the crates’ repository follow 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-federation 0.0.1 (the Federation Tier wire types, formerly ferrofed-wire, with the aql and merge features), ihe-iti 0.0.1 (features pixm, pdqm, mcsd, pmir, xcpd) and nl-generic-functions 0.0.1 (features nvi, mitz, lrza, nuts-auth). The names are held on crates.io by 0.0.0 placeholders. ferrofed-registry, ferrofed-identity and ferrofed-engine move under app/ and are never published. A new CI job lints every feature of the published crates on its own, and the architecture test also fails when a binding crate depends on FerroFED.
  • The openehr-* family moves to 0.0.74, the lockstep release with the AST visitor, spans, parameter binding and the federation directive in openehr-query, and the router builder, operation matcher, credentials provider and per-call options in openehr-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 the config check output and the boot failure. The refusal now names the file, the line and column, the key path (dev alone 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 = false switch every library crate inherits; the openehr-* family declared as one lockstep pin group at 0.0.72, with the versions guard failing when one member moves alone; deny.toml; the serde_json::Value ban in clippy.toml; and every crate of the architecture’s crate map as a documented placeholder, with the ferrofed binary over a thin library run path and the testkit’s pin-matrix reader asserting each crate’s specification constant against docs/VERSIONS.md.

  • The server binary shape (#29): ferrofed serve and ferrofed config check; configuration from a TOML file and FERROFED__ environment overrides with unknown keys refused, _file siblings for every secret, outbound credentials per endpoint id, and a typed refusal (exit 78) on any bad value, a broken log filter included; auto, json and pretty console formats; GET /, GET /health and GET /health/readiness over an indicator registry; the request id, the panic catch (a 500 that carries neither the panic message nor anything the client sent), the request timeout and the body ceiling; a bounded drain on SIGTERM; and 501 for 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-only offset and fetch, 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.federation with complete derived from the endpoint statuses (§11.4), the per-endpoint record whose shape carries the N40 error and latency_ms obligations of each §11.1 status, the OPTIONS {base}/ self-description with its two schema conditionals (§7a.2), the federation header names, and the one seam into the ITS-REST ResultSetMetadata, 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.tsv with every conformance point of section 17, conformance/tracks.tsv with the section 16.3 tracks (track 8 deferred as provisional) and conformance/requirements.tsv with the reachability of all 46 requirements, derived from the vendored specification by scripts/conformance/matrix.sh; the // conformance: test marker; the tier-1 conformance-matrix guard; 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.sh no longer exits early on the change that adds the root Cargo.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 published openehr-* crates as the openEHR surface at the planned 0.0.74 pin, the request pipeline, the AQL rewrite and identifier hygiene on openehr-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 the LIMIT agreement check, the hand-written wire types, the crate map with the publish switch, 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 from main by docs.yml, with the pinned docs toolchain, the vendored mermaid assets, the favicon set and its favicon-sync guard, and the README badge block (#12).
  • The release lane, .github/workflows/release.yml: a signed v* tag is checked against CITATION.cff and the product row of docs/VERSIONS.md, the version’s CHANGELOG.md section 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.md is 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-guard check (#3).
  • The working discipline under .claude/: the rules, the hooks (no AI attribution, dangerous-command guard, format and comment-style on edit, the SessionStart tracker summary), the issue-loop skills, the spec-researcher and implementer agents, and the tracked project memory. CLAUDE.md states the product, the design-phase status and the hard rules (#6), with the identifier-hygiene rule for everything the gateway composes for a node.
  • The vendored specification corpora under docs/specs/, each with a PROVENANCE.md and a fetch script under scripts/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, and scripts/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, one conclusion check, CodeQL, Scorecard and SonarQube Cloud), and docs/ci-cd.md, the design of the workflows and the repository settings (#9, #10, #11).
  • The community and governance set: CODE_OF_CONDUCT.md, GOVERNANCE.md, SUPPORT.md, AI_STATEMENT.md, CITATION.cff, llms.txt, and the root toolchain, format and lint configuration (#15).