Repository navigation
Releases: FerroHEALTH/FerroEHR
Release list
4.3.6
Added
-
The server verifies the audit hash chain on a schedule (#3640, CRA
Annex I Part I(2)(f)):[audit.store] verify_interval_seconds(default one
day,0turns it off). A finding is logged at ERROR, counted in
atna_audit_chain_findingsand reported as theaudit_chainindicator. -
The running server states its support period (#3639): five years from
the release month (CRA Art. 13(8)) on the boot banner, inferroehr --version, onGET /management/infoand on the REST status document
(support.statusissupported,endedorunreleased). Once the period
has ended it logs a WARN at boot and daily (CRA Art. 13(19)). -
More access records carry EHDS priority categories (#3653): logical
composition deletes,CONTRIBUTIONreads with or withoutresolve_refs,
VERSIONED_COMPOSITIONreads, and EHR Extract export and import records;
EHR_ACCESSis classifiednone. -
FerroEHR's intended purpose is stated (#3641). The new book page
Intended purpose is Cadasto B.V.'s statement of who FerroEHR is for, the
data it is designed to process (with the EHDS Art. 14(1) priority
categories), the environment it runs in, the security environment it
assumes (TLS termination, an identity provider, database protection) and the
misuse it foresees (CRA Art. 3(23), Annex II point 4; EHDS Annex III 1(a)). -
A CRA cybersecurity risk assessment (#3635). The new book page CRA risk
assessment rates FerroEHR's cybersecurity risks, states for each point of
CRA Annex I Part I(2) whether it applies, how it is implemented, the
evidence and the open issue, justifies the limbs that do not apply
(automatic updates, application-level encryption at rest, configuration
integrity), and lists every outbound data flow, the usage report included
(CRA Art. 13(2) to (4)). -
A hazard log for the access log (#3618). The new book page Hazard log
lists nine patient-safety hazards of the European logging software
component, each with its control, the tests that show it holds and what
remains (EHDS Annex II 1.1). -
A claims review (#3619). The new book page Claims review records the
reading of every public text against EHDS Art. 28, release by release, and
re-assesses EHDS Annex II 2.5 against the authorisation defaults. -
An information sheet and instructions for use accompany every release
(#3617). The new book pages Information sheet (every point of EHDS
Art. 38(2): the manufacturer, the name, version and release date, the
intended purpose, the Art. 14(1) data categories, and the standards and
their versions) and Instructions for use (what FerroEHR runs on, an EU
deployment's configuration withdeployment_profile = "production",
[audit.categories]andretention_years, the maintenance it needs and how
often, and the known limitations EHDS Art. 28(b) asks to be stated, with the
CRA Annex II user information mapped point by point). The release lane
attaches both, cut from the tag, to each GitHub release
(scripts/release/accompanying-documents.sh). -
The CRA information and instructions to the user, linked from every
release's notes (#3650). The new book page CRA information and
instructions to the user carries each point of CRA Annex II in order (the
manufacturer, the vulnerability contact, identification, intended purpose
and security properties, the risks, the declaration of conformity, the
support period, the instructions of point 8(a) to (f) and the SBOMs), each
with its source. The release notes link the copy frozen for the tag, and
the release lane refuses notes without that link or the support period
(scripts/checks/release-notes.sh, CRA Art. 13(18)). Rust crates gains
the information for integrators of theopenehr-*crates that point 8(f)
asks for. -
The technical documentation of EHDS Art. 37 and CRA Annex VII (#3616)
is kept indocs/technical-documentation/, one file per Annex III element
and per CRA Annex VII point that adds one, each citing its evidence. A
release that changes the logging component updates it
(scripts/checks/technical-documentation.sh --since <previous tag>, EHDS
Art. 30(2)), and the tree states how each release's documentation is kept
for ten years, where the declaration of conformity will be published, and
how a translation is requested. -
SECURITY.mdnames how to hear of new releases (#3639): subscribe to
the release feed (https://github.com/FerroHEALTH/FerroEHR/releases.atom)
and poll the published advisories
(GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories),
since the server does not look for newer releases itself. -
A Cyber Resilience Act page (#3626) states FerroEHR's position under the
CRA: Cadasto B.V. as manufacturer, why BUSL-1.1 is not free and open-source
software under CRA Art. 3(48), the reporting duty in force since 11
September 2026, the five-year support period, the conformity route through
the EHDS (CRA Art. 32(5a)), the default-product reading beside the competing
Annex III class I reading, the hosted-service case, and the questions for
counsel. -
Vulnerabilities can be reported by email (#3646):
SECURITY.md,
security.txt(a secondContact: mailto:info@cadasto.com) and the
Complaints, incidents and vulnerabilities page name info@cadasto.com
beside GitHub private vulnerability reporting, so a report needs no GitHub
account (CRA Art. 13(17)). An emailed report runs through the same
procedure and starts the same CRA Art. 14 clock. -
Component vulnerabilities are reported upstream (#3648). Cadasto B.V.
reports a vulnerability it finds in an integrated component to that
component's maintainer and offers its fix (CRA Art. 13(6)), and records each
report in the new public registerdocs/registers/upstream-reports.tsv. -
What happens if Cadasto B.V. ceases operations (#3651):
SECURITY.md
and the Complaints, incidents and vulnerabilities page say whom the
manufacturer tells, in which order, that the support periods end on the
cessation date and that every published release stays published (CRA
Art. 13(23)). -
Every access record carries the EHDS priority categories of the data it
served or wrote (#3621, Annex II 3.2(c)). A new[audit.categories]map
classifies an access from the template id, then the root archetype id, as
patient-summary,eprescription,edispensation,imaging,
test-results,discharge-report,national:<code>ornone. An access the
map cannot classify is recordedunclassifiedwith its ids and is never
refused. The map's digest is on the boot line, onGET /management/infoand
on every record, and the FHIRAuditEventcarries the classification as
entity details. -
The retention register can key a period on an EHDS priority category
(#3621, Annex II 3.4):categoryonPUT /admin/retention/policy, echoed by
the policy and due lists. The category map is mirrored into the register at
boot, and an unclassified object takes the longest period configured for its
kind. -
[audit.store] retention_years(#3625) states the access-log horizon in
calendar years as an alternative toretention_days. It is compared exactly
against the retention floors and ceilings, which are now registered in years,
and reaped with calendar arithmetic; setting both keys is a boot error. -
The regulation corpus gains the EHDS implementing acts and the
market-surveillance acts (#3607).docs/law/eu/now carries Commission
Implementing Regulations (EU) 2026/2083 (MyHealth@EU) and 2026/2099
(cross-border identification and authentication), both applying from 26
March 2027; Commission Recommendation (EU) 2019/243 on the European
electronic health record exchange format, which EHDS recital 26 names;
Regulation (EU) 2019/1020 on market surveillance, whose Article 3
definitions EHDS Article 2(1)(d) takes over, at its consolidation of 12
August 2026; Regulation (EC) No 765/2008, whose Article 30 sets the general
principles of the CE marking, at its consolidation of 16 July 2021; and CRA
Implementing Regulation (EU) 2025/2392 on the important and critical product
categories. Each is fetched byscripts/vendor/law-eu.shwith its own
provenance record and digests. -
The CRA record carries the Regulation's three English corrigenda
(#3607). They correct the title, Article 64(10) ("paragraphs 2 to 9", where
the published text says "3 to 9") and the point number Article 67 adds to
Directive (EU) 2020/1828. The vendored OJ text includes none of them, and
docs/law/eu/cra/PROVENANCE.mdsays which file to cite for which provision. -
Complaints, serious incidents and exploited vulnerabilities have a
written procedure (#3613). The new book page Complaints, incidents and
vulnerabilities names FerroEHR's manufacturer, Cadasto B.V., and its single
point of contact, the channels (GitHub private vulnerability reporting;
info@cadasto.com with "FerroEHR incident" or "FerroEHR complaint" in the
subject; a public issue), the public registers of complaints and of
non-conforming versions underdocs/registers/, and what happens on a
non-conformity, an EHDS serious incident (reported within three days of
awareness, EHDS Art. 44(7)) and an actively exploited vulnerability or severe
incident (the CRA Art. 14 early warning within 24 hours, notification within
72 hours and final report), with NIS2 kept separate.docs/post-market.md
is the maintainers' checklist;SUPPORT.mdand the issue chooser point to
the page. -
A non-conforming release can be withdrawn (#3615).
scripts/release/withdraw.sh <version> --to <version>refuses a version the
register of non-conforming versions does not name, moves the
<major>.<minor>andlatesttags of the server, viewer and PostgreSQL
images that still point at it to the co...
4.3.5
Security
auth.oidc.jwks_jsonrefuses a symmetric key or private key material at
boot (#3664, GHSA-r86f-f3vw-j4jr; RFC 7517 §4.1, RFC 7518 §6.4). The
inline key set is shown in clear byferroehr config checkand
GET {base}/admin/config, so a set carrying akty: "oct"key or a private
member (d,p,q,dp,dq,qi,oth,k) is now a boot error
that names the key's position andkid, never its value. A set that does
not parse as a JWK Set is refused with the line and column only. Public key
sets boot as before; a symmetric key belongs inauth.oidc.hmac_secret.- The S3 store's endpoint refusals no longer print URL userinfo (#3656,
GHSA-h2gv-2qpx-4fg8). With[multimedia]configured, an endpoint such as
https://user:password@s3.examplethat failed validation was quoted in full
in the boot error and the log. The endpoint is now validated once, by the
configuration, and every refusal quotes it with the userinfo masked. - The configuration views no longer print URL userinfo (#3629,
GHSA-4jp3-xxj8-vv86). A URL written with credentials
(https://user:password@host/…) inauth.oidc.issuer, a terminology
providerurlor OAuth2token_url,authz.abac.remote.server,
telemetry.otlp_endpointormultimedia.endpointwas shown in clear by
GET {base}/admin/configandferroehr config check. Every string value in
those views and inGET /management/envnow passes one masker, so each URL
keeps its host and path with the userinfo shown as***. hickory-resolver0.26.3 (#3700, GHSA-rjpj-g667-cmvm): the DNS resolver
the server binary links through the AMQP client is updated from 0.26.1. It
resolves the broker's host name when[events]or[fhir.outbound]is
enabled (both off by default), where GHSA-6w6g-hm98-mhgm and
GHSA-6f2x-v7q7-m7m5 reached it for an attacker who controls that name's DNS
answers.
4.3.4
Added
- A usage report to FerroPULSE, on by default (#3578). At each process
start and once a day, an instance sends one small JSON report to
https://report.ferropulse.eu/v1/report, the collector Cadasto B.V. runs in
the EU. It carries a random instance id (created once and stored in the
database, so every replica shares it), the version and commit, the
specification profile, the licence grant type (non-commercialor
commercial, never the licence id), the deployment kind (helm,compose,
binaryorunknown), uptime, whether the database answers and carries
its migrations, the PostgreSQL major version, coarse CPU and memory
buckets, and in the daily report a bucketed request count with latency
histograms, percentiles and 5xx counts per route-template group and for AQL
execution. It never carries patient data, request paths, AQL text, licence
ids or host names. FerroPULSE stores the source IP address of each report
and the country derived from it. Several replicas send one daily report
between them, and a restart loop sends at most one start report per ten
minutes. A slow or unreachable collector affects neither boot nor
readiness; a failed send is logged once at DEBUG and not retried. Every boot
logs one INFO line saying whether the report is on. Switch it off with
[usage_report] enabled = false,FERROEHR__USAGE_REPORT__ENABLED=false,
or the chart valueusageReport.enabled=false. The new[usage_report]
section also takesendpointandslow_aql_ms(default 1000), and
ferroehr usage-report --print [--event start|daily]prints the exact JSON
the instance would send without sending it. The Helm chart (10.2.1) and the
compose files set the deployment kind; the conformance compose files switch
the report off. A new clinical migration adds theusage_report_instance
andusage_report_windowtables.
Changed
- Commercial licensing goes to Cadasto B.V. (#3572). Cadasto B.V., the
Licensor, handles the commercial licence and every other business or
licensing question, at info@cadasto.com or
https://www.cadasto.com/contact/. The BUSLLICENSEcopies name that
contact for alternative licensing arrangements in place of the maintainer,
and the README, the book, the landing page, the BUSL crate READMEs,
MAINTAINERS.md,GOVERNANCE.mdandSUPPORT.mdsay the same. The
maintainer keeps the technical side. The licence terms do not change. The
openehr-*crates step to 0.0.86 in lockstep, because three packaged
LICENSEfiles change. - The Licensor and copyright holder is Cadasto B.V. (#3567). Every
holder statement moves from Vernum Projecten B.V. to Cadasto B.V.:LICENSE
and the crate licence files,REUSE.toml, everySPDX-FileCopyrightText
header, the licensing chapter of the book, the landing page footer and the
conformance statement, whose signatory is now "Ruben Talstra, for Cadasto
B.V." with the date of the change. The licence terms do not change. ClientError::DpopProofsays whether a send went out (#3565). Its new
sentfield isfalsewhen the first proof failed and nothing was sent,
andtruewhen the proof for a retry or for the re-send answering a DPoP
use_dpop_noncechallenge failed, after the service had received the first
request.- A CONTRIBUTION version no longer borrows the change set's committer
(#3550). Aversions[i].commit_audit, or an attestation, that names no
committerused to be committed under the CONTRIBUTIONaudit.committer.
ITS-RESTUpdateAudit.yamlrequirescommitteron every commit audit, so
such a version is now refused with400. A CONTRIBUTION without anaudit,
or whoseaudithas nocommitteror nochange_type, is a400too (it
was a422), and so is a committer that is not aPARTY_PROXYobject at
all; aPARTY_PROXYthat breaks its own invariants stays a422. A version
that names its own committer keeps it. The server still fills one
time_committedfor every audit in the set, and copies the CONTRIBUTION
audit'ssystem_idinto a version that omits one. This applies to the EHR
and the demographic CONTRIBUTION alike. Migration: sendcommitteron every
audit and attestation, andchange_typeon the CONTRIBUTIONaudit. - An attestation member of a CONTRIBUTION must state its
lifecycle_state
(#3590). A666|attestation|member without one was accepted, although SM
master03 §Version Update Semantics says the lifecycle state "must be supplied
in all cases". It is now refused with400like every other member, and a
stated code outside the openEHRversion_lifecycle_stategroup is a422.
Migration: sendlifecycle_stateon every666|attestation|member.
Fixed
- The terminology overlay switches on the
/terminology/*API it describes
(#3476).docker-compose.terminology.ymlsaid the CDR's/terminology/*
routes answer from FerroTERM, but it left[terminology] api_enabledat its
falsedefault, so every route answered404. The overlay now sets
FERROEHR__TERMINOLOGY__API_ENABLED=true; a term, subsumption or value-set
call for a terminology the bundled openEHR terminology does not carry is
answered by FerroTERM. deploy/helm/validate.sh --updateregenerates the chart README (#3476).
It used to restore the stale README and then fail on it, so the drift it
reported could only be fixed by runninghelm-docsby hand.
4.3.3
Added
- The
openehr-itsclient sends DPoP-bound tokens (#3558).
Credentials::DpopwritesAuthorization: DPoP <token>(RFC 9449 §7.1),
and aDpopProvergiven withClient::with_dpop_proversigns theDPoP
proof over each request's final method, URL and access token. A
use_dpop_noncechallenge (RFC 9449 §8 and §9) is answered with one re-send
carrying the supplied nonce. openehr-sdtprints a SMART scope back in the grammar (#3557).
SmartScopeandResourceScopeimplementDisplay, writing the canonical
form (permissions inc/r/u/d/sorder), and
SmartScope::format_allprints a scope list as the space-delimited string
SmartScope::parse_allreads.
Changed
ClientError::DeadlineElapsedsays whether a send went out (#3560).
Its newsentfield isfalsewhen the deadline passed before anything was
sent, andtruewhen it passed before a retry or the re-send answering a
DPoPuse_dpop_noncechallenge, after the service had received the request.- The container images name FerroHEALTH as vendor and author (#3561).
org.opencontainers.image.vendorisFerroHEALTHand
org.opencontainers.image.authorsis
FerroHEALTH <https://github.com/FerroHEALTH>on every image. openehr-itstypes thePrefer-dependent response bodies (#3548). A
response the OAS declares asoneOfa resource andIdentifier(the
201and200answers of every create and update: EHR,EHR_STATUS,
COMPOSITION, DIRECTORY, CONTRIBUTION and the demographic parties) is now
rest::runtime::Representation<T>, eitherFull(T)or
Identifier(Identifier), in place ofserde_json::Value. A generator test
pins the bodies that remain untyped, with their reason.- The generated CONTRIBUTION envelope reads a deleted version and an
AUDIT_DETAILSaudit (#3547).UpdateVersion.datais optional, because a
logical deletion commits a version with no data (RM common master06
§Logical Deletion;ORIGINAL_VERSION.datais0..1), andUpdateAudit
reads_type: "AUDIT_DETAILS"as its base, as thecontribution_create
docs text tells servers to. ferroehr-restdecodes every ITS-REST request through the generated
contract (#3545). Each handler reads its parameters with the generated
*Params::from_request, the CDR's own parameter decoder is gone, and the
Simplified Formats CONTRIBUTION path reads the typedNewContribution
envelope.- Every ITS-REST request body has a public decoder (#3551). Each operation
with a body gets{operation}_request_body(content_type, bytes)in its API
group, which returns the type the server trait receives (for
contribution_create, aPayloadof the canonical or the Simplified Formats
envelope) and is the decoding the generated router itself runs. The body
decoding moved torest::decode, so it needs only therestfeature. ROUTE_REQUEST_MEDIAadmits every media type theContent-Typeparameter
declares (#3552). The released OAS lists onlyapplication/jsonunder
requestBody.contentwhere the operation'sContent-Typeparameter and the
docs text also admit XML and both Simplified Formats;request_medianow
carries the union, and a test holds it equal to the parameter.- The
openehr-*crates step to 0.0.81. openehr-itsnames the openEHR identifier class of every path
parameter (#3539).rest::routes::Paramgainsidentifier, an
IdentifierClasswhoseas_str()is the BASE class name: the
class the ITS-REST OAS states only in the parameter's description, as the
BMM type of the RM attribute the value is "taken from".ehr_id,
versioned_object_uidandcontribution_uidcarryHIER_OBJECT_ID,
version_uidcarriesOBJECT_VERSION_ID, anduid_based_idkeeps its
per-operation class:UID_BASED_IDon a read,HIER_OBJECT_IDon an
update,OBJECT_VERSION_IDon a delete (IdentifierClass::UidBased,
HierObject,ObjectVersion). The table is generated from a
decision map that a codegen test holds to the vendored OAS descriptions.- Every generated
*Paramsstruct decodes a matched request (#3540).
from_request(matched, query, headers)takes theRouteMatchfrom
routes::lookup, the query string and the headers, and runs the decoding
the generated router runs: the declared keys into their fields, the members
of a form-exploded object such as the AQLquery_parametersread as JSON
where they parse,+as a literal plus, and a400naming the parameter
for a missing, repeated or invalid one. The generated handlers call the
same decoding, so an intermediary and the router cannot read a request
differently. The decoding moved fromrest::serverto the new
rest::decode, which needs only therestfeature, not the axum server
half. - The ITS-REST route table names each operation's request-body media
types (#3543). Each API group carriesROUTE_REQUEST_MEDIA, aligned with
ROUTES, holding the OASrequestBody.contentkeys, andRouteMatch
carries them asrequest_media.definition_query_version_store, which
declares noContent-Typeparameter, now namestext/plain, so a
forwarding intermediary can set the header. - A Simplified Formats CONTRIBUTION has a typed envelope (#3541). The
generatedNewContributionis generic over its version content,
NewContribution<T = Versionable>, so the bare name stays the canonical
body andNewContribution<serde_json::Value>reads the envelope of a FLAT
or STRUCTURED CONTRIBUTION, whose envelope stays canonical while each
versions[i].datais simplified (ITS-RESTcontribution_create
§Simplified Formats). The generated server admits
application/openehr.wt.flat+jsonand
application/openehr.wt.structured+jsonon every operation whose
Content-Typeparameter declares them, and hands the implementation a
rest::runtime::Payload(Canonical,Flat,Structured); XML and
any other type stay415. - The
openehr-*crates step to 0.0.80. - The
openehr-rmattribute model covers the BASE foundation types and
names reference targets (#3537). The model (v1_1::model,
v1_2::model) now holds every class of the BASE + RM BMM, including the
primitives (Boolean,Character,Integer,Integer64,Octet,
Real,Double,String,Uri), theOrderedandNumericmarkers and
the containers. Each class carries its BMMpackage, whether the
generated crates carry a type for it (generated_type), and its full BMM
ancestry asancestors, soDV_ORDEREDcarriesOrdered. The new
is_primitive,is_foundation_typeandconforms_to_orderedlookups
answer the AQL ORDER BY question ("primitives andOrderedtypes", QUERY
master03 §ORDER BY). The AQL engine expands a declared type to its
descendants for RM classes only; a foundation type stays a value type. A
reference-typed attribute (OBJECT_REF,PARTY_REF,LOCATABLE_REF)
carriesref_target, the class the RM text says it points to
(EHR.ehr_status→EHR_STATUS,PARTY_RELATIONSHIP.target→
VERSIONED_PARTY), orNonewhere the text names no single class. A
consumer that read "not in the model" as "primitive" now asks
is_primitive. - The
openehr-*crates step to 0.0.79. openehr-itsexposes theAuthorizationvalue it sends (#3535).
Credentials::header_valueis public, so a consumer can validate a
credential at configuration load with exactly the value the client sends.
Basic credentials are checked against RFC 7617 §2 (no colon in the user-id,
no control character in either part) and bearer tokens against the
b64tokensyntax of RFC 6750 §2.1. A refusal is a typed
InvalidCredentials, which never carries the secret; a request whose
credentials break these rules fails withClientError::InvalidCredentials
before anything is sent.- The
openehr-*crates step to 0.0.78. openehr-queryclassifies AQL function calls (#3529). A call naming
one of the built-in functions in QUERY master03 §Functions parses to
FunctionCall::Builtinwith a typedBuiltinFunction(StringFunction,
NumericFunction,DateTimeFunction); any other name parses to
FunctionCall::Other. Both keep the name as written, soprinter::to_aql
returns it unchanged,CONTAINSincluded.FunctionCall::Namedis gone, and
theToken::Containslexer token now carries its spelling. The CDR's AQL
engine uses this classification instead of a function list of its own.openehr-itsnames every operation's declared parameters (#3530).
Each generated*Paramsstruct carries aPARAMStable of the path, query
and header parameters it holds (name as the OAS spells it, location,
required, explode, and the value kind its schema states), written in the
same pass as the struct. Each API group carriesROUTE_PARAMS, aligned with
ROUTES, androutes::lookupreturns the matched operation's parameters
withquery_param,query_keyandheader_paramlookups, so an
intermediary can pass on exactly what an operation declares.openehr-itsReqwestTransportconstructors take a
reqwest::ClientBuilder(#3531).ReqwestTransport::newand the new
with_builder_timeout(replacingwith_client_timeout) build the client
themselves, with redirects switched off.- The
openehr-*crates step to 0.0.77. - FerroTERM moves to 0.1.6 at
ghcr.io/ferrohealth/ferroterm(#3521).
FerroTERM moved to the FerroHEALTH organization, and its releases from 0.1.6
on publish there. The terminology compose overlay, the hosted sandbox, the
Helm chart (terminology.image, chart 10.1.6) and the FerroTERM integration
test pin0.1.6@sha256:7ec4c962…, and the book links
https://github.com/FerroHEALTH/FerroTERM.
Fixed
- AQL honours the per-EHR
EHR_ACCESSdecision (#3562). A query could
return rows from an EHR ...
4.3.2
Added
-
openehr-querycan rewrite, locate and bind a query (#3505, #3506,
#3507, #3508).visit::Visitandvisit::VisitMutwalk every node of the
AST, so a consumer finds, replaces or removes aWHEREcondition without
its own recursive walk. EveryIdentifiedPathand everyWHEREcondition
carries anast::Span, the byte range it was written at, so a diagnostic
can point at a predicate without repeating its literal; spans take no part
in equality.bind::bindsubstitutes ITS-RESTquery_parametersinto the
AST as typed literals the printer escapes, at every position the grammar
admits a parameter, and reports an unbound, unknown, duplicate or
unbindable parameter by name, never by value. The opt-infederation
feature addsfederation::parse_federated, which lifts a
FROM ENDPOINT p [ … ]orORGANISATION [ … ]directive out of a query
and parses the rest as strict AQL. Breaking:WhereExpr::Identifiedand
IdentifiedPathgained the span (WhereExpr::identifiedand
IdentifiedPath::newbuild them without one). -
openehr-itspublishes the server router and the intermediary seams
(#3509, #3510, #3511, #3512, #3514). Underrest-server, each API group's
servermodule carries the trait, one success enum per operation (the
documented2xxanswers with theirLocation,ETagand other headers)
androuter(api), an axum router binding everyROUTESentry to its trait
method. A method refuses withrest::runtime::Refusal, anApiErrorplus
the headers its answer needs: the currentETagon a412, or the
OpenAPI-declared headers of a demographic409/412through the generated
headers structs (Refusal::with_headers). Either renders as the ITS-REST
ErrorJSON body.rest::server::router(api)mounts all six groups at
once, andrest::server::with_fallbacksmakes an unknown path answer404
and an undeclared method405withAllow, both with theErrorbody.
rest::routes::lookupmatches a method and path to its operation and path
parameters without reading the body, andClient::forwardsends a request
once and returns every answer as received, so an intermediary passes bodies,
LocationandETagthrough byte for byte.CredentialsProviderresolves
the credential before every attempt and hears about a401, for tokens that
refresh.CallOptionsgives a generated client a deadline and extra headers
per call (with_options); the remaining budget reaches the engine as
RequestTimeout, and an elapsed deadline isClientError::DeadlineElapsed.
Breaking: the server traits moved into<group>::serverand return
Result<success enum, Refusal>, and the response headers structs moved
fromclientto the group module. The nineopenehr-*crates step to 0.0.74. -
The
openehr-itsITS-REST client carries what a consumer meets on first
adoption (#3487). The commit operations (COMPOSITION, EHR_STATUS, the
directory, EHR creation, the demographic parties and relationships) take
the committal-metadata headers the ITS-REST overview defines and the
OpenAPI declares no parameter for:openehr-version,
openehr-audit-details(one field line per value) and, on the COMPOSITION
and CONTRIBUTION commits,openehr-template-id. Every documented4xx
outcome carries the answer body as anErrorBody: the bytes as received,
decoded as the ITS-RESTErrorwhen they are one, so a422's
validationErrorsreach the caller;ClientError::Unauthorized,
Forbidden,ServiceFailureandUndocumentedStatuscarry the same body.
A400whose body carriesmessagealone decodes as theErrorwith an
emptyvalidationErrors. A path parameter keeps:literal, so a version
uid is sent as the specification writes it.Credentialshold their secret
in asecrecy::SecretString(redactedDebug, zeroed on drop), with
Credentials::basicandCredentials::bearerconstructors. The nine
openehr-*crates step to 0.0.72. -
openehr-itsships an ITS-REST client (#3485). The newrest-client
feature adds a generated client per ITS-REST 1.1.0 API group
(rest::generated::<group>::client) over a hand-written runtime
(rest::client: theTransportengine trait, the shippedreqwestengine,
Basic and Bearer credentials, the retry budget andClientError). Each
operation takes its parameter struct, withPrefer,If-MatchandAccept
as fields, and answers an outcome with one variant per status the OpenAPI
documents for it; declared response headers such asETagandLocation
come back in a typed struct. Any other status is
ClientError::UndocumentedStatus, and401,403and5xxare their own
errors. Idempotent requests are retried after a transport failure or a
5xx; aPOSTis sent once. A create operation also answersNoContent,
the204the ITS-REST overview prescribes underPrefer: return=minimal
although the OpenAPI lists201alone. FerroBRIDGE keeps its hand-written
client until it moves to this one (its issue #276). The DTOs, parameter
structs and route tables move to a
newrestfeature over serde andhttpalone, which bothrest-serverand
rest-clientbuild on, so a client never compilesaxum.fullnow
includesrest-client. The nineopenehr-*crates step to 0.0.71. -
A CI guard keeps the Apache-2.0 crates free of BUSL-1.1 code:
scripts/checks/licence-boundary.shrefuses a normal or build dependency,
or a feature, fromopenehr-base,openehr-lang,openehr-term,
openehr-rm,openehr-amoropenehr-itsonopenehr-query,
openehr-adloropenehr-sdt, and alicensefield that changes side.
Changed
-
The repository moved to the FerroHEALTH organization
(https://github.com/FerroHEALTH/FerroEHR, #3516); the old
rubentalstra/FerroEHRURLs redirect. Images and the Helm chart now publish
toghcr.io/ferrohealth/ferroehr,ghcr.io/ferrohealth/ferroehr-viewer,
ghcr.io/ferrohealth/ferroehr-postgresand
oci://ghcr.io/ferrohealth/charts/ferroehr, and the compose files, the chart
and the book name those references. The v4.3.1 images and chart are copied
there by digest. Everything published up to v4.3.1 stays available under
ghcr.io/rubentalstra, and its signatures namerubentalstra/FerroEHRas the
repository that built it. The roadmap board is now
https://github.com/orgs/FerroHEALTH/projects/2. The nineopenehr-*crates
step to 0.0.75 for their newrepositoryfield. -
The dependency set moves forward in one step. OpenTelemetry goes to
0.33 (opentelemetry,opentelemetry_sdk,opentelemetry-otlp,
opentelemetry-prometheus) withtracing-opentelemetry0.34; the OpenAPI
stack toutoipa6,utoipa-axum0.3 andutoipa-swagger-ui10;
jsonschemato 0.58,sevenz-rust2to 0.23,lapinto 4.12 andconfig
to 0.15.27. The bundled PostgreSQL image is rebuilt on the current
postgres:18.6upstream digest (sha256:5a5a84b1…), a same-version respin
with rebuilt base packages. The nineopenehr-*crates step to 0.0.73. -
FerroTERM moves to 0.1.5 everywhere the product pins it. The quickstart
overlaydocker-compose.terminology.yml, the hosted sandbox compose file, the
Helm chart (terminology.image.digestand the pinned version its tag default
and Artifact Hub annotation read) and the integration test that starts the
real server all name
ghcr.io/rubentalstra/ferroterm:0.1.5@sha256:61a2d4aefc4a48e9e90b9a698a64bdfeb7ef6443744a1fe50972099456891fe9,
the index digest read from GHCR. That release adds the history interaction,
_summaryand_elementson read, a 406 on an unacceptableAccept, refuses
a write that would duplicate a canonical, and states the position of a
refused expression constraint on theOperationOutcome. The chart version
becomes 10.1.3; an install that pins the chart keeps running the image it
pinned. -
openehr-itsis split in two, and the wire layer is Apache-2.0 again
(#3482). The new crateopenehr-sdt(BUSL-1.1) holds the hand-written
Simplified Formats (FLAT, STRUCTURED, Web Template, TDD import), the
template-independent RM-instance validation and the SMART on openEHR scope
grammar.openehr-itskeeps the ITS wire layer: the generated canonical JSON
dispatch, the canonical XML codecs, the ITS-REST contract, the OPT 1.4 and
AOM2 archetype XML codecs, and the hand-written runtimes, canonical JSON entry
points and wire-validation dispatcher they need. From 0.0.69 it is published
under Apache-2.0, so a consumer of canonical JSON takes on no BUSL-1.1 code.
Rust callers importopenehr_sdt::flat,openehr_sdt::rm_instanceand
openehr_sdt::smart_scopesin place ofopenehr_its::flat,
openehr_its::rm_instanceandopenehr_its::rest::smart_scopes. Theflat
andcachefeatures leftopenehr-itsforopenehr-sdt; a
wasm32-unknown-unknownconsumer ofopenehr-itsnow selects
default-features = false, features = ["opt14"], andopenehr-sdtwith
default-features = falsecompiles to the scope grammar alone, with no
dependency. Published versions keep their licence:openehr-its0.0.60 to
0.0.67 stayBUSL-1.1 AND Apache-2.0. -
The published
openehr-*crates are nine, and all step to 0.0.69 (#3482).
openehr-sdtwas first published alone at 0.0.68, built against the 0.0.67
siblings; it joins the lockstep line at 0.0.69 and publishes after
openehr-itsand beforeopenehr-adl. -
FerroTERM moves to 0.1.4 everywhere the product pins it (#3474). The
quickstart overlaydocker-compose.terminology.yml, the hosted sandbox
compose file, the Helm chart (terminology.image.digestand the pinned
version its tag default and Artifact Hub annotation read) and the
integration test that starts the real server all name
`ghcr.io/rubentalstra/ferroterm:0.1.4@sha256:f3b5f35a6...
4.3.1
Added
- A fourth backup CronJob for the audit domain (#3399). The Helm chart
rendersbackup.auditas soon asdatabase.audit.existingSecretgives the
audit trail a database of its own, with its own claim, credential and
schedule, and drops--schema=auditfrom the clinical dump in the same
render. Until then nothing changes: the trail shares the clinical database
and travels with that dump. Before this, an audit database placed elsewhere
was backed up by nothing and the operations page told the operator to dump it
by hand. - An erasure tombstone on the party change-event stream (#3417). A
physically deleted party now leaves one contribution-less row on the party
domain's own outbox before the delete commits, naming the party and every
PARTY_RELATIONSHIPerased with it, so a consumer of the party stream is
told to delete what it derived. GDPR Art. 19 makes the controller
communicate an erasure to each recipient the data reached, and the clinical
outbox has carried the same row since #3416. - A dispatch-only storage benchmark lane (#3370). It runs the storage
benchmark against a PostgreSQL 18 service with durability on, compares every
operation with the record committed for the same schema generation, and fails
the run when a p50 or p99 rose further above it than the dispatched tolerance.
The summary lists each operation's baseline, measurement, delta and verdict
and names both machines; the fresh record leaves as a run artifact and the
committed baseline changes only in a pull request. Exploration, never a
conformance record: it earns no performance class. - Restriction of processing, at whole-EHR or single-object grain (#3324).
A restriction register records who asked, on what ground and when, and the
mark it drives stops every path that would process the object: a point read,
a versioned read and a revision history answer403with a body naming the
restriction, AQL omits it at every scope, an EHR Extract skips it and refuses
outright for a restricted EHR, the change-event stream neither emits nor
delivers an event about it, and a write to it is refused. The stored rows are
untouched — storage is the only processing GDPR Art. 18(2) leaves. Lifting
stamps the register rather than erasing it, so the sequence Art. 18(3) turns
on survives, and a whole-record lift leaves an object-scoped restriction
standing.POST/GET {base}/admin/restrictionand
POST {base}/admin/restriction/liftdrive it, each act an access record of
its own. - A per-EHR objection to research processing (#3325). While it stands, the
record leaves every full-population AQL query, every export and the event
stream, and a query naming theehr_idor a read for care is untouched: GDPR
Art. 21(6) reaches research processing, not the care record. The
public-interest override the article admits is recorded beside the objection
and says on whose authority.POST {base}/admin/research-objection. - A retention register, and a list of what has fallen due (#3346). The
period per content category and jurisdiction with the legal citation it rests
on, a per-EHR anchor, a whole-record hold and a per-object hold, and a view
that lists the EHRs whose period has run with the counts of objects due and
objects exempted. Nothing is deleted on a timer: an openEHR record is
indelible, so acting on the list is the controller's decision, taken through
the physical delete. Five routes under{base}/admin/retention/, and a new
book page, Retention, restriction and
objection. - An access-log retention ceiling beside the floor (#3346). Set
[audit.store] sgb_v_309_controller = truewhere the deploying organisation
is, or acts for, one of the controllers SGB V § 307 names for a German
telematics-infrastructure application, and boot refuses any
retention_daysabove the three-year period § 309 Abs. 1 sets — including
0, keep forever. A floor and a ceiling that contradict each other are
refused outright rather than silently resolved in favour of one.
Fixed
- A date with text after it compares as no date (#3436).
openehr_date_days
read the leading four, six or eight characters of a value and ignored the
rest, so20210102XYZ,2021-01-02 BC,2021-01-02 Europe/Amsterdamand
2021acompared and ordered as dates in AQL. BASEfoundation_types
master06-time_types.adoc §Iso8601_date admits none of them, and each now reads
as NULL, which is a comparison miss rather than an error.2021-01-02T10:30:45
and its space-separated and lowercase-tvariants are unchanged.
Changed
- The release profile links with thin LTO (#3098).
[profile.release]
gainslto = "thin", the whole-binary optimisation the Cargo book describes
for a CPU-bound path; the overflow-checks,panic = "unwind"and line-table
posture is unchanged.codegen-units = 1was tried in the same cycle and
withdrawn: the arm64 release build exhausted its runner twice. A release
build takes longer; the throughput before/after is measured under #3452 once
the box can be idle. LATEST_VERSIONunderCONTAINSis one key probe per object (#3453).
The AQL emitter used to express "this row is the current trunk version" as
two correlatedEXISTSsubqueries over the head table, which let the planner
reach the version row byvo_idalone through the commit-time index and walk
every trunk version of the object for every node row: 75 601 of the 93 255
buffers a class-spopulationCONTAINSread. The head row is now joined on
its primary key and the version row reached through its own,sys_version
bound; the plan-shape tests pin the index and a ceiling of eight buffers per
probe. Results are unchanged.- The server needs only
btree_gist, which it installs itself (#3433). The
second-generation schema calls nothing fromuuid-ossp,pgcryptoor
pg_trgm, so the PostgreSQL image no longer installs them and the CI step no
longer creates them.btree_gistbacks the temporal key on
linkage.subject_ehrand the server creates it at boot, so a plain
PostgreSQL 18 database with a migration credential that may create it serves
with nothing preinstalled. - The conformance statement is signed by a named person for the company
(#3438). A company signs through someone: the party statement's signatory is
now "Ruben Talstra, for Vernum Projecten B.V." with the date of the change,
and the rendered statement follows.AI_STATEMENT.mdnames Ruben Talstra as
author and maintainer, since the owner of the work is the company. - The Licensor and copyright holder is Vernum Projecten B.V. (#3435). Every
statement of the holder names the company:LICENSEand the per-crate
licence texts,REUSE.toml, the SPDX header of every first-party file
(emitted for the generated crates, so they were regenerated), the licensing
page, the landing footer and the conformance party statement, whose rendered
statement and certificate were regenerated with it. The licences themselves
are unchanged — the application and the three hand-written engines stay
Business Source License 1.1 with the same Additional Use Grant, Change Date
and Change License, the five generated model crates stay Apache-2.0, and
releases up to v4.0.17 and crate versions up to 0.0.56 stay MIT as published,
under the holder they were published with. Ruben Talstra remains the
maintainer credit the banner,MAINTAINERS.md, the OCIauthorslabel and
the chart maintainers carry, and theurn:rubentalstra:ferroehrconformance
identifier is an identifier rather than a holder statement and stays. - The
productiondeployment profile refuses the two compatibility
defaults (#3323). The per-EHREHR_ACCESSdefault isopenand the audit
trail fails open, both chosen so the conformance instrument and the
development stacks work out of the box. Neither default changes; what changes
is thatdeployment_profile = "production"now refuses to start while either
stands, naming the key, the value and the remedy, unless the deployment
accepts it by name indeployment_accepts— the mechanism the profile
already uses. The two new gap tokens areopen_ehr_access_defaultand
audit_fails_open. GDPR Art. 25(2) asks that by default personal data not be
made accessible without the individual's intervention to an indefinite number
of natural persons; the obligation binds the controller's production posture,
which is what the profile expresses. - The deployment posture reads the audit pool and every domain database
(#3398).shared_clusternow includes the audit pool's own cluster identity,
so an audit database co-located with a pseudonymisation domain is reported
instead of being invisible (Swiss DSV Art. 4 Abs. 5 keeps the log on a system
separate from the one processing the data).migrate_on_runtime_credential
is evaluated per domain database rather than only from[db] migrate_url: a
domain relocated to a database of its own is prepared on that domain's
runtime DSN, which is the same gap under a different key. ext.openehr_timestamprefuses a zone name and aBCera (#3427).
PostgreSQL's own date/time input reads2021-01-02T10:30:45 Europe/Amsterdamand2021-01-02T10:30:45 BC, and the separator-position
gate in front of it let both through. BASEfoundation_types
master06-time_types.adoc §Iso8601_date_time ends the value at the offset, so
both now read as NULL: an AQL comparison against such a stored value misses
rather than matching a date the record does not carry. The check costs the
helper 93 ms per 50 000 readings against 65 ms without it, measured on the
same corpus where the first generation took 117 ms.- The archive export lists multimedia blobs from the reference index
(#3...
4.3.0
Added
- The Kubernetes deployment probe observes the FerroTERM workload (#3328).
scripts/deploy-probe-k8s.shgains a terminology stage (P-K8S-TERM-*) that
reads the chart'sterminology.enabledclaims off a running cluster: the
absence of every terminology object while the gate is off, the rollout, the
applied runtime posture from the container runtime's own spec, the shaped seed
reaching the process through the ConfigMap volume, the NetworkPolicy refusing a
pod the CDR's selector does not name in this namespace and in another while the
CDR itself is admitted, and a value-set binding resolved through the Service. A
mounted index is probed whenK8S_TERM_INDEX_CLAIMnames an existing claim,
and is declared not exercised by probe id when it does not. - Identifier-scanner rules for Germany and Switzerland, and the Swiss access-log
retention floor (#3318, #3319).de-kvnrrefuses the unchangeable part of a
Krankenversichertennummer (a letter, eight digits and the GKV-Spitzenverband's
check digit),ch-ahvn13the AHV number in its plain and dotted display forms
(AHVV Art. 133; the BSV's Wegleitung check digit) andch-epd-pidthe EPD
patient identification number (EPDV-EDI Anhang 1, the fixed76133761prefix
and its check digit), each transcribed from the issuing authority's own
publication and tested against its worked examples. With a Swiss rule active,
the server refuses to boot with an auditretention_daysunder the one year
DSV Art. 4 Abs. 5 requires. The AHVV joins the vendored corpus at
docs/law/ch/ahvv/. - The regulation corpus gains the German and Swiss layers (#3293,
#3294).docs/law/de/carries the BDSG, SGB V (the ePA and telematics
provisions, §§ 341–355 and 360–363), the GDNG and the StGB (§ 203) as the
XML gesetze-im-internet.de publishes, pinned by the publisher's build stamp,
plus the Digital-Gesetz as its Bundesgesetzblatt issue;docs/law/ch/
carries the DSG/FADP and DSV/DPO in German and the non-binding English, and
the EPDG, EPDV and EPDV-EDI in German, each at its current Fedlex
consolidation. Two vendor scripts, provenance and digests per act, the
publishers' no-copyright articles quoted inLICENSES/, and the compliance
overview gains a Germany and a Switzerland section stating, provision by
provision, what the software ships and what the deploying organisation must
do. - The Helm chart runs FerroTERM beside the CDR (#3305, chart 8.3.0).
terminology.enabledrenders the terminology server as its own workload —
Deployment, ClusterIP Service, ServiceAccount, NetworkPolicy — under the same
Restricted profile as the CDR, and points the CDR's
config.terminology.externalat its Service, so archetype bindings resolve at
commit and AQLTERMINOLOGY()expands through it (the CDR's own
/terminology/*extension routes stay a separate switch,
config.terminology.api_enabled, and are off by default). Every values file
that contradicts that injection is refused at render rather than silently
overwritten: adefaultprovider of your own, anexternal.enabled: false,
afail_on_errordisagreeing withterminology.failOnError, and a provider
with noexternal.routesentry naming it. The licence-free shaped seed ships with the chart and
is mounted as a ConfigMap, so an install from the registry serves terminology
with no checkout; a release you hold a licence for is an index built
off-cluster and mounted from an existing claim named in
terminology.index.persistentVolumeClaim— which also switches the workload
tostrategy: Recreate, because a rolling update's surge pod cannot attach a
ReadWriteOnce volume the outgoing pod still holds. No Ingress is rendered for
it and no value renders one: the CDR is its only caller, and its NetworkPolicy
admits the CDR's pods alone. No boolean opens that port;
terminology.networkPolicy.extraIngressFromadmits a peer you write, and the
values schema refuses an empty one (a barenamespaceSelector: {}selects
every namespace). Above one replica, the pods spread one per node and
terminology.podDisruptionBudgetguards them through a drain.
Fixed
- Under multi-tenancy an unset
ferroehr.tenant_idno longer resolves to the
reserved default tenant: the server stamps the tenancy posture at boot, every
connection it opens declares its tenant explicitly, and the tenant reader
refuses a connection that declares none. Single-tenant deployments are
unchanged (#3341). - Under multi-tenancy the AMQP outbox drainer and the FHIR outbound emitter
drain every registered tenant's rows in turn, with a delivery cursor per
tenant, instead of the default tenant's alone (#3355). - The event-outbox retention prune stops at the lowest cursor of every active
reader instead of deleting on age alone: the FHIR outbound emitter (and any
future cursor reader) now owns a row in the newevent_outbox_reader
registry, reconciled from the configuration at boot, and a published row it
has not reached survives the retention window. The singleton
fhir_outbound_cursortable moves into the registry with its watermark
(#3330).
4.2.5
Changed
- The sandbox dataset pins 231 accepted / 91 refused ADL 2 archetypes
(#3315). Beside a terminology server that serves LOINC, two archetypes of the
2013 CKM corpus are refused by the VETDF check for LOINC answer codes that do
not exist:apgarbinds fifteen codes without their check digit
(LA6713…LA6727) andbraden_scalebinds the mistypedLA9605-4. The
sources stay verbatim, the manifest records the adjudication, and the
getting-started page counts 233 archetypes.
4.2.4
Fixed
- ADL 2 archetypes with SNOMED CT or LOINC term bindings upload against a
terminology server that does not serve those code systems (#3311). The
VETDF check asked the server aboutsystem=SNOMED-CT&code=<the binding URI>
and read every miss as "the term does not exist", so with FerroTERM beside
the CDR six archetypes of the sandbox dataset were refused and the seed
failed. A binding target in the IHTSDO URI model (http://snomed.info/id/…,
http://loinc.org/id/…) is now taken apart into the FHIR system and code the
server is asked about, and the server'sOperationOutcomedecides the
outcome: a code system it does not serve leaves the binding unverified and
the archetype accepted with a warning, as AOM2's VETDF prescribes; a code
absent from a served system is still refused with422. Verified against a
real FerroTERM in the test suite.
4.2.3
Added
- FerroTERM ships with the quickstart and runs beside the sandbox CDR
(#3304).docker-compose.terminology.ymlstarts the Ferro family's FHIR
terminology server next to the CDR and switches[terminology.external]on,
serving the licence-free shaped seed out of theferroehrimage by default and
any edition you build into its index volume (ferroterm-build, the
terminology-buildprofile). The hosted sandbox runs the same pair on one
machine with the terminology server reachable only from inside the stack,
which is the posture the SNOMED CT Affiliate Licence asks of a public system,
and its dataset gains a composition whose coded text resolves through it. The
deployment probe observes the overlay off, on and resolving, and the server
down under both fail postures. FerroTERM is pinned at 0.1.3, whose image
carries its ownHEALTHCHECK(ferroterm healthcheck), so both compose files
start the CDR only once the terminology server reports healthy (#3307).