Skip to content

Releases: FerroHEALTH/FerroEHR

4.3.6

Choose a tag to compare

@github-actions github-actions released this 08 Oct 11:38
Immutable release. Only release title and notes can be modified.
v4.3.6
82ef693

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, 0 turns it off). A finding is logged at ERROR, counted in
    atna_audit_chain_findings and reported as the audit_chain indicator.

  • The running server states its support period (#3639): five years from
    the release month (CRA Art. 13(8)) on the boot banner, in ferroehr --version, on GET /management/info and on the REST status document
    (support.status is supported, ended or unreleased). 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, CONTRIBUTION reads with or without resolve_refs,
    VERSIONED_COMPOSITION reads, and EHR Extract export and import records;
    EHR_ACCESS is classified none.

  • 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 with deployment_profile = "production",
    [audit.categories] and retention_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 the openehr-* crates that point 8(f)
    asks for.

  • The technical documentation of EHDS Art. 37 and CRA Annex VII (#3616)
    is kept in docs/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.md names 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 second Contact: 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 register docs/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> or none. An access the
    map cannot classify is recorded unclassified with its ids and is never
    refused. The map's digest is on the boot line, on GET /management/info and
    on every record, and the FHIR AuditEvent carries the classification as
    entity details.

  • The retention register can key a period on an EHDS priority category
    (#3621, Annex II 3.4): category on PUT /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 to retention_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 by scripts/vendor/law-eu.sh with 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.md says 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 under docs/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.md and 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> and latest tags of the server, viewer and PostgreSQL
    images that still point at it to the co...

Read more

4.3.5

Choose a tag to compare

@github-actions github-actions released this 08 Oct 10:37
Immutable release. Only release title and notes can be modified.
v4.3.5
e42494f

Security

  • auth.oidc.jwks_json refuses 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 by ferroehr config check and
    GET {base}/admin/config, so a set carrying a kty: "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 and kid, 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 in auth.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.example that 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/…) in auth.oidc.issuer, a terminology
    provider url or OAuth2 token_url, authz.abac.remote.server,
    telemetry.otlp_endpoint or multimedia.endpoint was shown in clear by
    GET {base}/admin/config and ferroehr config check. Every string value in
    those views and in GET /management/env now passes one masker, so each URL
    keeps its host and path with the userinfo shown as ***.
  • hickory-resolver 0.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

Choose a tag to compare

@github-actions github-actions released this 06 Oct 06:23
Immutable release. Only release title and notes can be modified.
v4.3.4
410f41a

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-commercial or
    commercial, never the licence id), the deployment kind (helm, compose,
    binary or unknown), 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 value usageReport.enabled=false. The new [usage_report]
    section also takes endpoint and slow_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 the usage_report_instance
    and usage_report_window tables.

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 BUSL LICENSE copies 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.md and SUPPORT.md say 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
    LICENSE files 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, every SPDX-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::DpopProof says whether a send went out (#3565). Its new
    sent field is false when the first proof failed and nothing was sent,
    and true when the proof for a retry or for the re-send answering a DPoP
    use_dpop_nonce challenge failed, after the service had received the first
    request.
  • A CONTRIBUTION version no longer borrows the change set's committer
    (#3550). A versions[i].commit_audit, or an attestation, that names no
    committer used to be committed under the CONTRIBUTION audit.committer.
    ITS-REST UpdateAudit.yaml requires committer on every commit audit, so
    such a version is now refused with 400. A CONTRIBUTION without an audit,
    or whose audit has no committer or no change_type, is a 400 too (it
    was a 422), and so is a committer that is not a PARTY_PROXY object at
    all; a PARTY_PROXY that breaks its own invariants stays a 422. A version
    that names its own committer keeps it. The server still fills one
    time_committed for every audit in the set, and copies the CONTRIBUTION
    audit's system_id into a version that omits one. This applies to the EHR
    and the demographic CONTRIBUTION alike. Migration: send committer on every
    audit and attestation, and change_type on the CONTRIBUTION audit.
  • An attestation member of a CONTRIBUTION must state its lifecycle_state
    (#3590). A 666|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 with 400 like every other member, and a
    stated code outside the openEHR version_lifecycle_state group is a 422.
    Migration: send lifecycle_state on every 666|attestation| member.

Fixed

  • The terminology overlay switches on the /terminology/* API it describes
    (#3476). docker-compose.terminology.yml said the CDR's /terminology/*
    routes answer from FerroTERM, but it left [terminology] api_enabled at its
    false default, so every route answered 404. 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 --update regenerates 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 running helm-docs by hand.

4.3.3

Choose a tag to compare

@github-actions github-actions released this 05 Oct 07:39
Immutable release. Only release title and notes can be modified.
v4.3.3
4b271bc

Added

  • The openehr-its client sends DPoP-bound tokens (#3558).
    Credentials::Dpop writes Authorization: DPoP <token> (RFC 9449 §7.1),
    and a DpopProver given with Client::with_dpop_prover signs the DPoP
    proof over each request's final method, URL and access token. A
    use_dpop_nonce challenge (RFC 9449 §8 and §9) is answered with one re-send
    carrying the supplied nonce.
  • openehr-sdt prints a SMART scope back in the grammar (#3557).
    SmartScope and ResourceScope implement Display, writing the canonical
    form (permissions in c/r/u/d/s order), and
    SmartScope::format_all prints a scope list as the space-delimited string
    SmartScope::parse_all reads.

Changed

  • ClientError::DeadlineElapsed says whether a send went out (#3560).
    Its new sent field is false when the deadline passed before anything was
    sent, and true when it passed before a retry or the re-send answering a
    DPoP use_dpop_nonce challenge, after the service had received the request.
  • The container images name FerroHEALTH as vendor and author (#3561).
    org.opencontainers.image.vendor is FerroHEALTH and
    org.opencontainers.image.authors is
    FerroHEALTH <https://github.com/FerroHEALTH> on every image.
  • openehr-its types the Prefer-dependent response bodies (#3548). A
    response the OAS declares as oneOf a resource and Identifier (the
    201 and 200 answers of every create and update: EHR, EHR_STATUS,
    COMPOSITION, DIRECTORY, CONTRIBUTION and the demographic parties) is now
    rest::runtime::Representation<T>, either Full(T) or
    Identifier(Identifier), in place of serde_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_DETAILS audit
    (#3547). UpdateVersion.data is optional, because a
    logical deletion commits a version with no data (RM common master06
    §Logical Deletion; ORIGINAL_VERSION.data is 0..1), and UpdateAudit
    reads _type: "AUDIT_DETAILS" as its base, as the contribution_create
    docs text tells servers to.
  • ferroehr-rest decodes 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 typed NewContribution
    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, a Payload of the canonical or the Simplified Formats
    envelope) and is the decoding the generated router itself runs. The body
    decoding moved to rest::decode, so it needs only the rest feature.
  • ROUTE_REQUEST_MEDIA admits every media type the Content-Type parameter
    declares
    (#3552). The released OAS lists only application/json under
    requestBody.content where the operation's Content-Type parameter and the
    docs text also admit XML and both Simplified Formats; request_media now
    carries the union, and a test holds it equal to the parameter.
  • The openehr-* crates step to 0.0.81.
  • openehr-its names the openEHR identifier class of every path
    parameter
    (#3539). rest::routes::Param gains identifier, an
    IdentifierClass whose as_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_uid and contribution_uid carry HIER_OBJECT_ID,
    version_uid carries OBJECT_VERSION_ID, and uid_based_id keeps its
    per-operation class: UID_BASED_ID on a read, HIER_OBJECT_ID on an
    update, OBJECT_VERSION_ID on 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 *Params struct decodes a matched request (#3540).
    from_request(matched, query, headers) takes the RouteMatch from
    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 AQL query_parameters read as JSON
    where they parse, + as a literal plus, and a 400 naming 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 from rest::server to the new
    rest::decode, which needs only the rest feature, not the axum server
    half.
  • The ITS-REST route table names each operation's request-body media
    types
    (#3543). Each API group carries ROUTE_REQUEST_MEDIA, aligned with
    ROUTES, holding the OAS requestBody.content keys, and RouteMatch
    carries them as request_media. definition_query_version_store, which
    declares no Content-Type parameter, now names text/plain, so a
    forwarding intermediary can set the header.
  • A Simplified Formats CONTRIBUTION has a typed envelope (#3541). The
    generated NewContribution is generic over its version content,
    NewContribution<T = Versionable>, so the bare name stays the canonical
    body and NewContribution<serde_json::Value> reads the envelope of a FLAT
    or STRUCTURED CONTRIBUTION, whose envelope stays canonical while each
    versions[i].data is simplified (ITS-REST contribution_create
    §Simplified Formats). The generated server admits
    application/openehr.wt.flat+json and
    application/openehr.wt.structured+json on every operation whose
    Content-Type parameter declares them, and hands the implementation a
    rest::runtime::Payload (Canonical, Flat, Structured); XML and
    any other type stay 415.
  • The openehr-* crates step to 0.0.80.
  • The openehr-rm attribute 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), the Ordered and Numeric markers and
    the containers. Each class carries its BMM package, whether the
    generated crates carry a type for it (generated_type), and its full BMM
    ancestry as ancestors, so DV_ORDERED carries Ordered. The new
    is_primitive, is_foundation_type and conforms_to_ordered lookups
    answer the AQL ORDER BY question ("primitives and Ordered types", 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)
    carries ref_target, the class the RM text says it points to
    (EHR.ehr_status → EHR_STATUS, PARTY_RELATIONSHIP.target →
    VERSIONED_PARTY), or None where 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-its exposes the Authorization value it sends (#3535).
    Credentials::header_value is 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
    b64token syntax of RFC 6750 §2.1. A refusal is a typed
    InvalidCredentials, which never carries the secret; a request whose
    credentials break these rules fails with ClientError::InvalidCredentials
    before anything is sent.
  • The openehr-* crates step to 0.0.78.
  • openehr-query classifies AQL function calls (#3529). A call naming
    one of the built-in functions in QUERY master03 §Functions parses to
    FunctionCall::Builtin with a typed BuiltinFunction (StringFunction,
    NumericFunction, DateTimeFunction); any other name parses to
    FunctionCall::Other. Both keep the name as written, so printer::to_aql
    returns it unchanged, CONTAINS included. FunctionCall::Named is gone, and
    the Token::Contains lexer token now carries its spelling. The CDR's AQL
    engine uses this classification instead of a function list of its own.
  • openehr-its names every operation's declared parameters (#3530).
    Each generated *Params struct carries a PARAMS table 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 carries ROUTE_PARAMS, aligned with
    ROUTES, and routes::lookup returns the matched operation's parameters
    with query_param, query_key and header_param lookups, so an
    intermediary can pass on exactly what an operation declares.
  • openehr-its ReqwestTransport constructors take a
    reqwest::ClientBuilder
    (#3531). ReqwestTransport::new and the new
    with_builder_timeout (replacing with_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 pin 0.1.6@sha256:7ec4c962…, and the book links
    https://github.com/FerroHEALTH/FerroTERM.

Fixed

  • AQL honours the per-EHR EHR_ACCESS decision (#3562). A query could
    return rows from an EHR ...
Read more

4.3.2

Choose a tag to compare

@github-actions github-actions released this 01 Oct 18:43
Immutable release. Only release title and notes can be modified.
v4.3.2
d7af7da

Added

  • openehr-query can rewrite, locate and bind a query (#3505, #3506,
    #3507, #3508). visit::Visit and visit::VisitMut walk every node of the
    AST, so a consumer finds, replaces or removes a WHERE condition without
    its own recursive walk. Every IdentifiedPath and every WHERE condition
    carries an ast::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::bind substitutes ITS-REST query_parameters into 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-in federation
    feature adds federation::parse_federated, which lifts a
    FROM ENDPOINT p [ … ] or ORGANISATION [ … ] directive out of a query
    and parses the rest as strict AQL. Breaking: WhereExpr::Identified and
    IdentifiedPath gained the span (WhereExpr::identified and
    IdentifiedPath::new build them without one).

  • openehr-its publishes the server router and the intermediary seams
    (#3509, #3510, #3511, #3512, #3514). Under rest-server, each API group's
    server module carries the trait, one success enum per operation (the
    documented 2xx answers with their Location, ETag and other headers)
    and router(api), an axum router binding every ROUTES entry to its trait
    method. A method refuses with rest::runtime::Refusal, an ApiError plus
    the headers its answer needs: the current ETag on a 412, or the
    OpenAPI-declared headers of a demographic 409/412 through the generated
    headers structs (Refusal::with_headers). Either renders as the ITS-REST
    Error JSON body. rest::server::router(api) mounts all six groups at
    once, and rest::server::with_fallbacks makes an unknown path answer 404
    and an undeclared method 405 with Allow, both with the Error body.
    rest::routes::lookup matches a method and path to its operation and path
    parameters without reading the body, and Client::forward sends a request
    once and returns every answer as received, so an intermediary passes bodies,
    Location and ETag through byte for byte. CredentialsProvider resolves
    the credential before every attempt and hears about a 401, for tokens that
    refresh. CallOptions gives a generated client a deadline and extra headers
    per call (with_options); the remaining budget reaches the engine as
    RequestTimeout, and an elapsed deadline is ClientError::DeadlineElapsed.
    Breaking: the server traits moved into <group>::server and return
    Result<success enum, Refusal>, and the response headers structs moved
    from client to the group module. The nine openehr-* crates step to 0.0.74.

  • The openehr-its ITS-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 documented 4xx
    outcome carries the answer body as an ErrorBody: the bytes as received,
    decoded as the ITS-REST Error when they are one, so a 422's
    validationErrors reach the caller; ClientError::Unauthorized,
    Forbidden, ServiceFailure and UndocumentedStatus carry the same body.
    A 400 whose body carries message alone decodes as the Error with an
    empty validationErrors. A path parameter keeps : literal, so a version
    uid is sent as the specification writes it. Credentials hold their secret
    in a secrecy::SecretString (redacted Debug, zeroed on drop), with
    Credentials::basic and Credentials::bearer constructors. The nine
    openehr-* crates step to 0.0.72.

  • openehr-its ships an ITS-REST client (#3485). The new rest-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: the Transport engine trait, the shipped reqwest engine,
    Basic and Bearer credentials, the retry budget and ClientError). Each
    operation takes its parameter struct, with Prefer, If-Match and Accept
    as fields, and answers an outcome with one variant per status the OpenAPI
    documents for it; declared response headers such as ETag and Location
    come back in a typed struct. Any other status is
    ClientError::UndocumentedStatus, and 401, 403 and 5xx are their own
    errors. Idempotent requests are retried after a transport failure or a
    5xx; a POST is sent once. A create operation also answers NoContent,
    the 204 the ITS-REST overview prescribes under Prefer: return=minimal
    although the OpenAPI lists 201 alone. 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
    new rest feature over serde and http alone, which both rest-server and
    rest-client build on, so a client never compiles axum. full now
    includes rest-client. The nine openehr-* 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.sh refuses a normal or build dependency,
    or a feature, from openehr-base, openehr-lang, openehr-term,
    openehr-rm, openehr-am or openehr-its on openehr-query,
    openehr-adl or openehr-sdt, and a license field that changes side.

Changed

  • The repository moved to the FerroHEALTH organization
    (https://github.com/FerroHEALTH/FerroEHR, #3516); the old
    rubentalstra/FerroEHR URLs redirect. Images and the Helm chart now publish
    to ghcr.io/ferrohealth/ferroehr, ghcr.io/ferrohealth/ferroehr-viewer,
    ghcr.io/ferrohealth/ferroehr-postgres and
    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 name rubentalstra/FerroEHR as the
    repository that built it. The roadmap board is now
    https://github.com/orgs/FerroHEALTH/projects/2. The nine openehr-* crates
    step to 0.0.75 for their new repository field.

  • The dependency set moves forward in one step. OpenTelemetry goes to
    0.33 (opentelemetry, opentelemetry_sdk, opentelemetry-otlp,
    opentelemetry-prometheus) with tracing-opentelemetry 0.34; the OpenAPI
    stack to utoipa 6, utoipa-axum 0.3 and utoipa-swagger-ui 10;
    jsonschema to 0.58, sevenz-rust2 to 0.23, lapin to 4.12 and config
    to 0.15.27. The bundled PostgreSQL image is rebuilt on the current
    postgres:18.6 upstream digest (sha256:5a5a84b1…), a same-version respin
    with rebuilt base packages. The nine openehr-* crates step to 0.0.73.

  • FerroTERM moves to 0.1.5 everywhere the product pins it. The quickstart
    overlay docker-compose.terminology.yml, the hosted sandbox compose file, the
    Helm chart (terminology.image.digest and 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,
    _summary and _elements on read, a 406 on an unacceptable Accept, refuses
    a write that would duplicate a canonical, and states the position of a
    refused expression constraint on the OperationOutcome. The chart version
    becomes 10.1.3; an install that pins the chart keeps running the image it
    pinned.

  • openehr-its is split in two, and the wire layer is Apache-2.0 again
    (#3482). The new crate openehr-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-its keeps 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 import openehr_sdt::flat, openehr_sdt::rm_instance and
    openehr_sdt::smart_scopes in place of openehr_its::flat,
    openehr_its::rm_instance and openehr_its::rest::smart_scopes. The flat
    and cache features left openehr-its for openehr-sdt; a
    wasm32-unknown-unknown consumer of openehr-its now selects
    default-features = false, features = ["opt14"], and openehr-sdt with
    default-features = false compiles to the scope grammar alone, with no
    dependency. Published versions keep their licence: openehr-its 0.0.60 to
    0.0.67 stay BUSL-1.1 AND Apache-2.0.

  • The published openehr-* crates are nine, and all step to 0.0.69 (#3482).
    openehr-sdt was 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-its and before openehr-adl.

  • FerroTERM moves to 0.1.4 everywhere the product pins it (#3474). The
    quickstart overlay docker-compose.terminology.yml, the hosted sandbox
    compose file, the Helm chart (terminology.image.digest and 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...

Read more

4.3.1

Choose a tag to compare

@github-actions github-actions released this 16 Sep 21:28
Immutable release. Only release title and notes can be modified.
v4.3.1
4229db8

Added

  • A fourth backup CronJob for the audit domain (#3399). The Helm chart
    renders backup.audit as soon as database.audit.existingSecret gives the
    audit trail a database of its own, with its own claim, credential and
    schedule, and drops --schema=audit from 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_RELATIONSHIP erased 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 answer 403 with 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/restriction and
    POST {base}/admin/restriction/lift drive 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 the ehr_id or 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 = true where 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_days above 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, so 20210102XYZ, 2021-01-02 BC, 2021-01-02 Europe/Amsterdam and
    2021a compared and ordered as dates in AQL. BASE foundation_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-t variants are unchanged.

Changed

  • The release profile links with thin LTO (#3098). [profile.release]
    gains lto = "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 = 1 was 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_VERSION under CONTAINS is one key probe per object (#3453).
    The AQL emitter used to express "this row is the current trunk version" as
    two correlated EXISTS subqueries over the head table, which let the planner
    reach the version row by vo_id alone 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-s population CONTAINS read. 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 from uuid-ossp, pgcrypto or
    pg_trgm, so the PostgreSQL image no longer installs them and the CI step no
    longer creates them. btree_gist backs the temporal key on
    linkage.subject_ehr and 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.md names 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: LICENSE and 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 OCI authors label and
    the chart maintainers carry, and the urn:rubentalstra:ferroehr conformance
    identifier is an identifier rather than a holder statement and stays.
  • The production deployment profile refuses the two compatibility
    defaults
    (#3323). The per-EHR EHR_ACCESS default is open and 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 that deployment_profile = "production" now refuses to start while either
    stands, naming the key, the value and the remedy, unless the deployment
    accepts it by name in deployment_accepts — the mechanism the profile
    already uses. The two new gap tokens are open_ehr_access_default and
    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_cluster now 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_timestamp refuses a zone name and a BC era (#3427).
    PostgreSQL's own date/time input reads 2021-01-02T10:30:45 Europe/Amsterdam and 2021-01-02T10:30:45 BC, and the separator-position
    gate in front of it let both through. BASE foundation_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...
Read more

4.3.0

Choose a tag to compare

@github-actions github-actions released this 14 Sep 09:12
Immutable release. Only release title and notes can be modified.
v4.3.0
4e85322

Added

  • The Kubernetes deployment probe observes the FerroTERM workload (#3328).
    scripts/deploy-probe-k8s.sh gains a terminology stage (P-K8S-TERM-*) that
    reads the chart's terminology.enabled claims 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 when K8S_TERM_INDEX_CLAIM names 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-kvnr refuses the unchangeable part of a
    Krankenversichertennummer (a letter, eight digits and the GKV-Spitzenverband's
    check digit), ch-ahvn13 the AHV number in its plain and dotted display forms
    (AHVV Art. 133; the BSV's Wegleitung check digit) and ch-epd-pid the EPD
    patient identification number (EPDV-EDI Anhang 1, the fixed 76133761 prefix
    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 audit retention_days under 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 in LICENSES/, 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.enabled renders 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.external at its Service, so archetype bindings resolve at
    commit and AQL TERMINOLOGY() 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: a default provider of your own, an external.enabled: false,
    a fail_on_error disagreeing with terminology.failOnError, and a provider
    with no external.routes entry 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
    to strategy: 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.extraIngressFrom admits a peer you write, and the
    values schema refuses an empty one (a bare namespaceSelector: {} selects
    every namespace). Above one replica, the pods spread one per node and
    terminology.podDisruptionBudget guards them through a drain.

Fixed

  • Under multi-tenancy an unset ferroehr.tenant_id no 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 new event_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_cursor table moves into the registry with its watermark
    (#3330).

4.2.5

Choose a tag to compare

@github-actions github-actions released this 12 Sep 18:57
Immutable release. Only release title and notes can be modified.
v4.2.5
ad41e6e

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: apgar binds fifteen codes without their check digit
    (LA6713 … LA6727) and braden_scale binds the mistyped LA9605-4. The
    sources stay verbatim, the manifest records the adjudication, and the
    getting-started page counts 233 archetypes.

4.2.4

Choose a tag to compare

@github-actions github-actions released this 12 Sep 16:59
Immutable release. Only release title and notes can be modified.
v4.2.4
ad7c69b

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 about system=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's OperationOutcome decides 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 with 422. Verified against a
    real FerroTERM in the test suite.

4.2.3

Choose a tag to compare

@github-actions github-actions released this 12 Sep 14:56
Immutable release. Only release title and notes can be modified.
v4.2.3
31ca40a

Added

  • FerroTERM ships with the quickstart and runs beside the sandbox CDR
    (#3304). docker-compose.terminology.yml starts the Ferro family's FHIR
    terminology server next to the CDR and switches [terminology.external] on,
    serving the licence-free shaped seed out of the ferroehr image by default and
    any edition you build into its index volume (ferroterm-build, the
    terminology-build profile). 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 own HEALTHCHECK (ferroterm healthcheck), so both compose files
    start the CDR only once the terminology server reports healthy (#3307).