Introduction
FerroFED is a pure-Rust openEHR federation gateway, one of the FerroHEALTH family. A record held by another organisation is out of reach today. FerroFED is meant to close that gap as a transparent ITS-REST intermediary: a client sends it an ordinary AQL query and never learns it was federated. The gateway resolves the patient first, outside the query, so no directly identifying identifier travels to a node. It then sends standard AQL to each node, scoped to that node’s own EHR id, and merges what comes back with each node’s provenance. It holds no clinical data of its own.
FerroFED follows the openEHR Federation Working Group’s Federation Tier with AQL specification, a release candidate at v0.9.0 with a 1.0 release expected.
Where the project is
FerroFED is in its design phase. The repository, its gates and its vendored specifications exist; the design does not yet. It is the output of a research program on the tracker (#16), which writes the architecture of record before any code is scaffolded. There is no Cargo workspace, no release and no binary to run. Nothing in this book describes software you can download today, and every page says which parts are settled and which are still open.
How this book is organised
The four parts follow what you came to do.
- Evaluate answers whether FerroFED fits your problem: what the Federation Tier is, what FerroFED will and will not claim, the version pins, and the licence.
- Operate covers what a running gateway will need around it.
- Integrate covers what a client sends and what it gets back.
- Contribute covers 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 roadmap is the public view of both.
The Federation Tier with AQL
This page is a plain account of the specification FerroFED implements. It
cites the sections of the vendored text at
docs/specs/federation-spec/
and adds nothing of FerroFED’s own; the next page does that.
Three tiers
The specification adds a Federation Tier to openEHR querying, so that a client can run one query across several openEHR CDRs as though they were a single repository (§1). The system has three tiers (§3.1):
- the application tier, where a client sends AQL and reads the combined result;
- the Federation Tier, one or more gateways that act as the intermediary;
- the node tier, member CDRs that run ordinary openEHR queries scoped to a single EHR and need not know they belong to a federation.
The gateway is a technically transparent intermediary (§3.2). The client interface is a conformant openEHR Query API, and a conformant ITS-REST surface generally, so a basic patient query needs no federation-specific syntax.
The flow
Federation keys on each node’s local ehr_id. Patient identity is resolved
outside the query, and each node receives standard AQL scoped to its own
ehr_id (§4).
flowchart LR
C[Client] -->|"AQL with a subject predicate"| G[Gateway]
G -->|"patient identifier"| X[Cross-reference service]
X -->|"ehr_id per node, or not found"| G
G -->|"standard AQL scoped to ehr_id A"| A[Node A]
G -->|"standard AQL scoped to ehr_id B"| B[Node B]
A -->|rows| G
B -->|rows| G
G -->|"one RESULT_SET with meta.federation"| C
- The façade. The client sends
POST {base}/v1/query/aqland may identify the patient the openEHR way, withWHERE e/ehr_status/subject/external_ref/id/value = <patientId>(§4.1, §7). - Resolution. The gateway resolves the identifier to a set of
{node, ehr_id}pairs through a localization service, a directory and an identifier cross-reference service. The proposed bindings are IHE XCPD, mCSD and PIXm (§4.1, §5, Annex A). Without a localization service the gateway asks every known node (§4.3). - Fan-out. Each node with a resolved
ehr_idreceives the node AQL: the subject predicate replaced bye/ehr_id/value = '<resolved>', every other patient identifier removed or the query refused, and everything else forwarded unchanged (§7.1). - Combine. The gateway concatenates the rows, applies
DISTINCTandORDER BYacross nodes, and reports every node it asked undermeta.federation.endpoints[]with a status (§9, §11.1).
What the specification insists on
- No identifier leaves the gateway. The patient identifier used for resolution is consumed at the gateway and never reaches a node, in the query, its path or its headers (§5.4, N33).
- A partial answer is never presented as a whole one. When a node that was
asked does not answer, the default is to fail the query, and every response
carries
meta.federation.complete(§3.2, §11.4). - Consent stays at the node. A node enforces consent before it releases data; a gateway pre-filter is optional and never replaces that check (§1, N27).
- Follow-up reads and writes go to the owning CDR, routed on
creating_system_id(§12). - Some things are refused, not approximated: federated demographics,
object creation across nodes, cross-node
OFFSETpaging and undirected aggregates (§2.3, §3.2).
The specification closes with a consolidated list of numbered conformance points (§17) and a Connectathon-style test approach (§16).
What FerroFED will claim
Until the architecture of record exists, FerroFED claims only what is decided. This page lists the decided parts and the open ones, so a reader can tell the two apart.
Decided
- 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 recorded as FerroFED’s own and labelled that way in the code and the documentation.
- The openEHR surface is not reimplemented. The ITS-REST contract, the
AQL parser and printer, the RM and the typed identifiers come from the
published
openehr-*crates. A gap in one of them is fixed in that crate, not worked around in the gateway. - No clinical data of its own. The gateway holds the registry, the 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 gets a negative test.
- Pure Rust, a single binary, with the generated layer kept apart from the hand-written one, as across the FerroHEALTH family.
Open, on the research program
The research program (#16) answers these with cited evidence before the workspace exists:
- how the AQL rewrite sits on
openehr-query’s typed AST, checked against the reference implementation’s golden cases; - which identity, localization and addressing bindings ship first, and the seam between them;
- where the registry lives and how it is stored;
- the fan-out and merge design: timeouts, completeness,
ORDER BYwithLIMIT,OFFSET,DISTINCT, aggregates and de-duplication; - the security handoff from client to gateway to node;
- what is generated from the specification’s two JSON schemas and what is written by hand;
- the crate layout, and whether the library crates are published;
- the conformance instrument that scores every conformance point.
Not claimed
FerroFED does not claim conformance to any conformance point today, and will not until a test scores it. The specification is a release candidate; when 1.0 is published, the vendored text is re-pinned and every citation on the tracker is checked against it (#17).
The conformance matrix records where each point stands: every point, its requirements and tracks as the specification states them, and the issue that scores it.
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: 0 of 35 covered, 35 planned, 0 deferred.
The other 6 points belong to a member node or to the federation operator, and a gateway is never marked down for them (section 17).
Conformance points
| Point | Actor | Requirements | Tracks | Status | Issues | Reason |
|---|---|---|---|---|---|---|
| CP-1 | Gateway | N1 | 1 | planned | #38 | - |
| CP-2 | Gateway | N2, N5 | 1, 2 | planned | #35, #38 | - |
| CP-38 | Gateway | N33 | 2, 10 | planned | #44 | - |
| CP-3 | Gateway | N3 | 2 | planned | #42 | - |
| CP-4 | Gateway | N7 | 2 | planned | #35, #38 | - |
| CP-5 | Gateway | N4, N10 | 3, 4 | planned | #46, #85 | - |
| CP-6 | Gateway | N11 | 3 | planned | #70 | - |
| CP-7 | Gateway | N5 | 2 | planned | #35, #38 | - |
| CP-8 | Gateway | N13 | 5 | planned | #52, #55 | - |
| CP-9 | Gateway | N15 | 5 | planned | #56 | - |
| CP-10 | Gateway | N14 | 5 | planned | #54 | - |
| CP-11 | Gateway | N16 | 4 | planned | #49 | - |
| CP-12 | Gateway | N6, N16 | 4 | planned | #43, #50, #58 | - |
| CP-13 | Gateway | N19, N20, N21 | 6 | planned | #36, #67, #74 | - |
| CP-14 | Gateway | N22 | 6 | planned | #64 | - |
| CP-15 | Gateway | N23 | 6 | planned | #65 | - |
| CP-16 | Gateway | N24 | 7 | planned | #82 | - |
| CP-17 | Gateway | N25 | 7 | planned | #80, #81 | - |
| CP-18 | Node | N26 | 7 | node-profile | #83, #93 | scored against the member CDRs, not the gateway (section 16.2) |
| CP-19 | Node | N27, N27a | 7 | node-profile | #83, #93 | scored against the member CDRs, not the gateway (section 16.2) |
| CP-20 | Operator | N19 | 6 | operator | #74 | verified at admission or in the registry, not on a request (section 16.2) |
| CP-21 | Gateway | N28 | 9 | planned | #69 | - |
| CP-22 | Gateway | N29 | 9 | planned | #69 | - |
| CP-23 | Gateway | N30 | 9 | planned | #73 | - |
| CP-24 | Gateway | N31 | 9 | planned | #61 | - |
| CP-25 | Gateway | N32 | 9 | planned | #68 | - |
| CP-26 | Gateway | N33 | 10 | planned | #45, #90 | - |
| CP-27 | Node | N34 | 10 | node-profile | #93 | scored against the member CDRs, not the gateway (section 16.2) |
| CP-28 | Gateway | N35 | 3 | planned | #71 | - |
| CP-29 | Gateway | N36 | 5, 6 | planned | #56, #66 | - |
| CP-30 | Gateway | N37 | 4 | planned | #37, #50 | - |
| CP-31 | Gateway | N38, N40 | 4 | planned | #49, #51 | - |
| CP-32 | Gateway | N39, N14, N9 | 5 | planned | #52, #53, #54 | - |
| CP-33 | Gateway | N41, N42 | 6, 11 | planned | #62, #63, #91 | - |
| CP-33a | Operator | N42a | 11 | operator | #79, #91 | verified at admission or in the registry, not on a request (section 16.2) |
| CP-34 | Gateway | N43 | 9 | planned | #75, #76 | - |
| CP-35 | Gateway | N17, N18 | 1 | planned | #33, #38, #72 | - |
| CP-36 | Gateway | N8 | 2, 7 | planned | #43, #83 | - |
| CP-37 | Gateway | N12 | 3 | planned | #72 | - |
| CP-39 | Operator | N25 | 7 | operator | #84 | verified at admission or in the registry, not on a request (section 16.2) |
| CP-40 | Gateway | N44 | 9 | planned | #77, #78 | - |
Test tracks
| Track | Title | Requirements | Points | Status | Issues | Reason |
|---|---|---|---|---|---|---|
| 1 | Transparency | N1, N2, N17, N18 | CP-1, CP-2, CP-35 | planned | #38, #72, #92 | - |
| 2 | subject → ehrId resolution | N3, N5, N7, N33 | CP-2, CP-38, CP-3, CP-4, CP-7, CP-36 | planned | #42, #44, #45, #92 | - |
| 3 | Directed / endpoint pin | N10, N11 | CP-5, CP-6, CP-28, CP-37 | planned | #70, #71, #72, #92 | - |
| 4 | Partial results | N6, N16 | CP-5, CP-11, CP-12, CP-30, CP-31 | planned | #49, #50, #51, #92 | - |
| 5 | Dedup + DISTINCT | N13, N15 | CP-8, CP-9, CP-10, CP-29, CP-32 | planned | #52, #53, #54, #55, #56, #92 | - |
| 6 | Follow-up read/write routing | N21, N22, N23 | CP-13, CP-14, CP-15, CP-20, CP-29, CP-33 | planned | #64, #65, #66, #67, #92 | - |
| 7 | Auth conveyance + consent-deny | N24, N25, N26, N27, N27a | CP-16, CP-17, CP-18, CP-19, CP-36, CP-39 | planned | #80, #81, #82, #83, #92 | - |
| 8 | PMIR merge/split (ITI-93/94) (provisional) | N3 | - | deferred | #48 | provisional in section 16.3, and section 18 lets its subject matter be deferred |
| 9 | REST surface & self-description | N28, N43, N44 | CP-21, CP-22, CP-23, CP-24, CP-25, CP-34, CP-40 | planned | #61, #68, #69, #73, #75, #77, #92 | - |
| 10 | Identifier leakage (adversarial) | N33, N34, N5 | CP-38, CP-26, CP-27 | planned | #45, #61, #90 | - |
| 11 | Integrity: ehr_id collision & admission conditions | N41, N42, N42a | CP-33, CP-33a | planned | #63, #79, #91 | - |
Requirements
46 requirements: 46 reached by a conformance point, 0 by a test track only, 0 by neither.
| Requirement | Points | Tracks | Reachability |
|---|---|---|---|
| N1 | CP-1 | 1 | direct |
| N2 | CP-2 | 1 | direct |
| N3 | CP-3 | 2, 8 | direct |
| N4 | CP-5 | - | direct |
| N5 | CP-2, CP-7 | 2, 10 | direct |
| N6 | CP-12 | 4 | direct |
| N7 | CP-4 | 2 | direct |
| N8 | CP-36 | - | direct |
| N9 | CP-32 | - | direct |
| N10 | CP-5 | 3 | direct |
| N11 | CP-6 | 3 | direct |
| N12 | CP-37 | - | direct |
| N13 | CP-8 | 5 | direct |
| N14 | CP-10, CP-32 | - | direct |
| N15 | CP-9 | 5 | direct |
| N16 | CP-11, CP-12 | 4 | direct |
| N17 | CP-35 | 1 | direct |
| N18 | CP-35 | 1 | direct |
| N19 | CP-13, CP-20 | - | direct |
| N20 | CP-13 | - | direct |
| N21 | CP-13 | 6 | direct |
| N22 | CP-14 | 6 | direct |
| N23 | CP-15 | 6 | direct |
| N24 | CP-16 | 7 | direct |
| N25 | CP-17, CP-39 | 7 | direct |
| N26 | CP-18 | 7 | direct |
| N27 | CP-19 | 7 | direct |
| N27a | CP-19 | 7 | direct |
| N28 | CP-21 | 9 | direct |
| N29 | CP-22 | - | direct |
| N30 | CP-23 | - | direct |
| N31 | CP-24 | - | direct |
| N32 | CP-25 | - | direct |
| N33 | CP-38, CP-26 | 2, 10 | direct |
| N34 | CP-27 | 10 | direct |
| N35 | CP-28 | - | direct |
| N36 | CP-29 | - | direct |
| N37 | CP-30 | - | direct |
| N38 | CP-31 | - | direct |
| N39 | CP-32 | - | direct |
| N40 | CP-31 | - | direct |
| N41 | CP-33 | 11 | direct |
| N42 | CP-33 | 11 | direct |
| N42a | CP-33a | 11 | direct |
| N43 | CP-34 | 9 | direct |
| N44 | CP-40 | 9 | direct |
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 summarises the pins that shape the design.
| Item | Pin | Why |
|---|---|---|
| Federation Tier with AQL | 0.9.0, release candidate, commit 7162d0c | the governing specification; re-pinned when 1.0 is published |
| openEHR ITS-REST | 1.1.0 | the façade a client sees and the API each node exposes |
| openEHR AQL | 1.1.0 | the query language on both sides of the gateway |
openehr-query, openehr-its | 0.0.72 | the published crates the gateway builds on, moved together as one family |
| Rust | 1.98.1, edition 2024 | the toolchain, once the workspace exists |
The identity and directory bindings (IHE PIXm, PDQm, PMIR, mCSD, XCPD and the
Dutch Generic Functions) are listed in docs/VERSIONS.md with the latest
published version, but none is pinned. Choosing them is part of the research
program.
Vendored specifications
The specification, its reference implementation, the ITS-REST OpenAPI
documents and the AQL source 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. The
authoritative text is
LICENSE, with
the notice in
NOTICE. This
page summarises it; where the two differ, the licence text wins.
What you may do without asking
Read, build, modify and redistribute the source, with no fee and no conversation. That covers every non-production use: development, testing, evaluation and prototyping.
Production use is free for Non-Commercial Purposes, which the licence defines as personal use, academic or scientific research, teaching, and use by a non-profit organisation or public body that is not acting in the course of a business, does not deliver a service for payment, and is not seeking commercial advantage.
What needs a commercial licence
Any other production use, including the delivery of health care or any other service for payment. 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 needs one in every case. So does selling, sublicensing or distributing it for a fee, on its own or inside another product.
A commercial licence starts with a conversation with the maintainer named in
MAINTAINERS.md.
The Licensor is Vernum Projecten B.V.
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 and third-party material
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, recorded by a checkbox in the pull request.
Vendored specifications and third-party material keep their upstream terms,
recorded in a PROVENANCE.md beside each vendored tree.
What FerroFED runs beside
The binary runs its process shape today (Configuration), and the federation surface follows. This page describes what a running gateway will need around it, taken from the roles the specification names, so an operator can see the shape before the federation surface exists.
The services a gateway consumes
| Role | What it does | Proposed binding |
|---|---|---|
| Member CDRs | answer standard AQL scoped to one ehr_id, and the follow-up reads and writes routed to them | openEHR ITS-REST 1.1.0 |
| Identifier cross-reference | maps a patient identifier to each node’s local ehr_id, or reports it not found | IHE PIXm |
| Localization (optional) | returns the candidate communities for a patient; without it the gateway asks every known node | IHE XCPD |
| Addressing | resolves each community to its cross-reference service and CDR base URLs | IHE mCSD |
| Authentication and authorization | authenticates the client, and the gateway to each node | the security profiles of §13 |
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.
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). Where that state lives and how it is stored is open
on the research program.
Failure behaviour you should know before you run it
- When a node that was asked does not answer, the default is to fail the query. A client that wants flagged partial rows has to ask for best-effort explicitly (§11.4).
- Every response, a failing one included, reports each node in scope with a
status such as
active,offline,time-outornot-resolved(§11.1). - Consent is enforced by each node before it releases data. A node’s refusal is reported; the gateway never treats its own pre-filter as the only gate (N27).
Configuration
The ferrofed binary reads one TOML file and the environment. It serves the
process shape today (health, readiness, the request log, graceful shutdown);
the federation surface under /v1/ answers 501 until the façade lands.
Running it
ferrofed serve --config /etc/ferrofed/ferrofed.toml
ferrofed config check --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, prints
one line and exits, so a deployment pipeline can test a file without binding a
socket.
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, and a credentials section
that names no scheme or two. It never falls back to a default for a value you
set.
The file
[server]
listen = "127.0.0.1:8080" # the socket address to bind
request_timeout_ms = 30000 # a request past this answers 408
shutdown_timeout_ms = 10000 # the drain after SIGTERM is bounded by this
body_limit_bytes = 1048576 # a body past this answers 413
[telemetry]
format = "auto" # auto, json or pretty; auto is json unless stdout is a terminal
filter = "info,hyper=warn,tower=warn,h2=warn"
# Outbound credentials, one section per endpoint id. Each section names one
# scheme: a bearer token, or a user and a password.
[credentials."hospital-a"]
bearer_token_file = "/run/secrets/hospital-a-token"
[credentials."clinic-b"]
user = "ferrofed"
password_file = "/run/secrets/clinic-b-password"
Every secret has a _file sibling, read once at boot and trimmed, so a secret
can come from a mounted file and never sit in the configuration or the
environment. The credentials are read and checked at boot; the node dispatch
hands them to each endpoint once it lands.
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__BEARER_TOKEN_FILE=/run/secrets/token
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.
The HTTP surface
| Route | Answers |
|---|---|
GET / | the product name and version |
GET /health | 200 while the process is up |
GET /health/readiness | 200 when every registered indicator is up, 503 with each indicator’s state otherwise |
any path under /v1/ | 501 until the façade lands |
| any other path | 404 |
Every response carries an x-request-id: the client’s value when it is short
printable ASCII, a fresh UUID otherwise.
What the log records
One line per request: the method, the matched route, the status, the latency
and the request id. A façade query carries the patient identifier, so the line
never carries a request body, the AQL text, a header value, a path no route
matched (it is logged as <unmatched>), or a query value other than the
ITS-REST paging parameters offset and fetch, and those only when they are
digits. A handler panic answers 500 and is logged without its message, which
could quote a value the handler held.
The container image and the quickstart
FerroFED ships one static binary, ferrofed, and an image that carries it on
distroless static. The repository’s compose.yaml starts that image beside
two member CDRs of different products, FerroEHR and EHRbase, so the topology a
federated query runs over is up in one command.
The image
docker/Dockerfile copies the release lane’s musl binary for the target
platform onto gcr.io/distroless/static-debian13:nonroot, pinned by the
digest of its image index. The image:
- runs as the numeric user
65532:65532, so an orchestrator’srunAsNonRootaccepts it; - has no shell and no package manager;
- needs no writable path, so it runs with a read-only root filesystem and every capability dropped;
- binds
0.0.0.0:8080(the binary’s own default is loopback, which no container can publish), set throughFERROFED__SERVER__LISTEN; - starts
ferrofed serveas PID 1, soSIGTERMreaches the server and it drains before it exits.
The image declares no HEALTHCHECK. The base has no HTTP client and the
binary has no probe subcommand, so probe it from outside: GET /health
answers 200 while the process is up, and GET /health/readiness answers
200 when every registered indicator is up.
The image lane publishes it as ghcr.io/ferrohealth/ferrofed. To build it
yourself from the binaries of a published release:
scripts/release/stage-dist.sh 0.0.1
docker buildx build -f docker/Dockerfile --platform linux/arm64 \
-t ghcr.io/ferrohealth/ferrofed:0.0.1 --load .
The stage script checks every tarball against the .sha256sum published
beside it before it unpacks a byte.
The quickstart
scripts/release/stage-dist.sh 0.0.1
docker compose up --build --wait
curl http://127.0.0.1:8080/health
| Service | What it is | On the host |
|---|---|---|
ferrofed | the gateway | 127.0.0.1:8080 |
ferroehr, ferroehr-postgres | member node A, FerroEHR on its own PostgreSQL image | 127.0.0.1:8081/ferroehr/rest/openehr/v1 |
ehrbase, ehrbase-db | member node B, EHRbase on its companion PostgreSQL 16.2 image | 127.0.0.1:8091/ehrbase/rest/openehr/v1 |
Each node runs its product’s documented image, database included, which is why
EHRbase keeps PostgreSQL 16.2: the PostgreSQL 18 rule covers FerroFED’s own
database only. Both nodes use their products’ quickstart Basic-auth user,
ferroehr / ferroehr, a development credential that must not reach anything
real. Every image is pinned by tag and digest, and docs/VERSIONS.md carries
each pin.
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.
Today the gateway serves its process shape: /, the health family, and 501
under /v1/. The registry document that names the two nodes and the federated
query over them land with the v0.0.2 milestone, and the nodes above are the
ones that query reaches.
docker compose down -v stops the stack and removes its volumes.
The client contract
A client of a federation gateway is an ordinary openEHR client. This page sets out what the specification promises that client; FerroFED has no endpoint to call yet.
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).
An optional AQL extension, FROM ENDPOINT … and ORGANISATION …, pins a
query to named systems for a client that wants it (§8).
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).
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. A follow-up read
or write sent to the gateway is routed to that CDR (§7.2, §12). A new object
is always created on one node the client names; creation across nodes is
refused (§2.3, N23).
Self-description
OPTIONS {base}/ returns the gateway’s self-description, including what it
refuses rather than approximates, validated against the specification’s
options-root.schema.json (§7a.2).
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. The planned line:
| Milestone | Scope |
|---|---|
| v0.0.1 | the repository setup and the research program that writes the architecture of record |
| v0.0.2 | the Cargo workspace and the first federated query over two nodes |
| v0.0.3 | identity resolution outside AQL (§5) |
| v0.0.4 | the federated answer: completeness, timeouts, ordering, de-duplication (§9 to §11) |
| v0.0.5 | the ITS-REST surface and follow-up routing (§7a, §12) |
| v0.0.6 | targeting and self-description (§8, §7a.2) |
| v0.0.7 | definitions and membership (§12.6, §12.7, §12b) |
| v0.0.8 | security and the bindings (§13 to §15, Annex A, Annex B) |
| v0.0.9 | conformance: every conformance point scored (§16, §17) |
The project board shows the same issues by status.
Labels
Each issue carries one type label (bug, enhancement, documentation,
chore, refactor, perf, test, ci), one priority from P0 to P3, and
the specification it touches: spec:federation, spec:openEHR, spec:IHE or
spec:NL-GF. Design-phase investigations carry research; their deliverable
is cited evidence, not code.
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 body accepts the contribution licence terms through its checkbox
(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.
Two required checks
A pull request merges into main only when two checks pass:
conclusion, the one job ofci.ymlthat passes when every other job in it passed or was skipped;contribution-licence-guard, which reads the licence checkbox in the pull request body.
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:
| Check | What it guards |
|---|---|
| zizmor | workflow security |
| actionlint | workflow correctness |
| shellcheck | every first-party shell script |
| hadolint | every first-party Dockerfile |
| comment-style | the comment budgets of the Rust sources |
| file-length | no hand-written Rust file over 1000 lines |
| versions | every repeated pin agrees with docs/VERSIONS.md |
| favicon-sync | the book’s favicons match the brand mark |
| conformance-matrix | the conformance matrix agrees with the specification and with the tests that claim each point |
The second tier is the Rust lane: formatting, clippy, tests, rustdoc,
cargo deny, the MSRV build and dependency review. It turns itself on when a
root Cargo.toml exists; until then each job reports skipped.
Running them locally
bash scripts/checks/versions.sh
bash scripts/checks/comment-style.sh --all
bash scripts/checks/file-length.sh
bash scripts/checks/favicon-sync.sh
bash scripts/checks/conformance-matrix.sh
find scripts .claude/hooks -name '*.sh' -exec shellcheck --severity=style {} +
actionlint
zizmor --min-severity=low .github/
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.