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. 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
  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, routed on creating_system_id (§12).
  • Some things are refused, not approximated: federated demographics, object creation across nodes, cross-node OFFSET paging 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 BY with LIMIT, 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

PointActorRequirementsTracksStatusIssuesReason
CP-1GatewayN11planned#38-
CP-2GatewayN2, N51, 2planned#35, #38-
CP-38GatewayN332, 10planned#44-
CP-3GatewayN32planned#42-
CP-4GatewayN72planned#35, #38-
CP-5GatewayN4, N103, 4planned#46, #85-
CP-6GatewayN113planned#70-
CP-7GatewayN52planned#35, #38-
CP-8GatewayN135planned#52, #55-
CP-9GatewayN155planned#56-
CP-10GatewayN145planned#54-
CP-11GatewayN164planned#49-
CP-12GatewayN6, N164planned#43, #50, #58-
CP-13GatewayN19, N20, N216planned#36, #67, #74-
CP-14GatewayN226planned#64-
CP-15GatewayN236planned#65-
CP-16GatewayN247planned#82-
CP-17GatewayN257planned#80, #81-
CP-18NodeN267node-profile#83, #93scored against the member CDRs, not the gateway (section 16.2)
CP-19NodeN27, N27a7node-profile#83, #93scored against the member CDRs, not the gateway (section 16.2)
CP-20OperatorN196operator#74verified at admission or in the registry, not on a request (section 16.2)
CP-21GatewayN289planned#69-
CP-22GatewayN299planned#69-
CP-23GatewayN309planned#73-
CP-24GatewayN319planned#61-
CP-25GatewayN329planned#68-
CP-26GatewayN3310planned#45, #90-
CP-27NodeN3410node-profile#93scored against the member CDRs, not the gateway (section 16.2)
CP-28GatewayN353planned#71-
CP-29GatewayN365, 6planned#56, #66-
CP-30GatewayN374planned#37, #50-
CP-31GatewayN38, N404planned#49, #51-
CP-32GatewayN39, N14, N95planned#52, #53, #54-
CP-33GatewayN41, N426, 11planned#62, #63, #91-
CP-33aOperatorN42a11operator#79, #91verified at admission or in the registry, not on a request (section 16.2)
CP-34GatewayN439planned#75, #76-
CP-35GatewayN17, N181planned#33, #38, #72-
CP-36GatewayN82, 7planned#43, #83-
CP-37GatewayN123planned#72-
CP-39OperatorN257operator#84verified at admission or in the registry, not on a request (section 16.2)
CP-40GatewayN449planned#77, #78-

Test tracks

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

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-29-direct
N37CP-30-direct
N38CP-31-direct
N39CP-32-direct
N40CP-31-direct
N41CP-3311direct
N42CP-3311direct
N42aCP-33a11direct
N43CP-349direct
N44CP-409direct

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.

ItemPinWhy
Federation Tier with AQL0.9.0, release candidate, commit 7162d0cthe governing specification; re-pinned when 1.0 is published
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-its0.0.72the published crates the gateway builds on, moved together as one family
Rust1.98.1, edition 2024the 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

RoleWhat it doesProposed binding
Member CDRsanswer standard AQL scoped to one ehr_id, and the follow-up reads and writes routed to themopenEHR ITS-REST 1.1.0
Identifier cross-referencemaps a patient identifier to each node’s local ehr_id, or reports it not foundIHE PIXm
Localization (optional)returns the candidate communities for a patient; without it the gateway asks every known nodeIHE XCPD
Addressingresolves each community to its cross-reference service and CDR base URLsIHE mCSD
Authentication and authorizationauthenticates the client, and the gateway to each nodethe 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-out or not-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

RouteAnswers
GET /the product name and version
GET /health200 while the process is up
GET /health/readiness200 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 path404

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’s runAsNonRoot accepts 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 through FERROFED__SERVER__LISTEN;
  • starts ferrofed serve as PID 1, so SIGTERM reaches 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
ServiceWhat it isOn the host
ferrofedthe gateway127.0.0.1:8080
ferroehr, ferroehr-postgresmember node A, FerroEHR on its own PostgreSQL image127.0.0.1:8081/ferroehr/rest/openehr/v1
ehrbase, ehrbase-dbmember node B, EHRbase on its companion PostgreSQL 16.2 image127.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:

MilestoneScope
v0.0.1the repository setup and the research program that writes the architecture of record
v0.0.2the Cargo workspace and the first federated query over two nodes
v0.0.3identity resolution outside AQL (§5)
v0.0.4the federated answer: completeness, timeouts, ordering, de-duplication (§9 to §11)
v0.0.5the ITS-REST surface and follow-up routing (§7a, §12)
v0.0.6targeting and self-description (§8, §7a.2)
v0.0.7definitions and membership (§12.6, §12.7, §12b)
v0.0.8security and the bindings (§13 to §15, Annex A, Annex B)
v0.0.9conformance: 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 of ci.yml that 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:

CheckWhat it guards
zizmorworkflow security
actionlintworkflow correctness
shellcheckevery first-party shell script
hadolintevery first-party Dockerfile
comment-stylethe comment budgets of the Rust sources
file-lengthno hand-written Rust file over 1000 lines
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

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.