Skip to content

0.0.7

Choose a tag to compare

@github-actions github-actions released this 03 Oct 20:36
· 381 commits to main since this release
Immutable release. Only release title and notes can be modified.
v0.0.7
d7d0b05

The v0.0.7 milestone, definitions and membership (§12.6, §12.7, §12b). A
versioned write goes only to the CDR that created the version. A template goes
to the node you name, or to several on request, with each member's outcome
reported. Stored queries live at the gateway as immutable versions, on an
embedded store, PostgreSQL or a read-only directory, distributed to the
members with drift reported and repaired from the admin listener. A check you
run before admitting a node tests the admission conditions. The gateway
exports metrics through OpenTelemetry, as Prometheus and over OTLP, records
each member's state from the calls it makes, holds every credential in a type
that never renders it, and prints a startup banner. Every release now carries
a compose file that starts the gateway alone in front of the CDRs you already
run. The gateway still authenticates no client; that is v0.0.8 (#80).

Added

  • Every release carries compose.yaml, ferrofed.toml and registry.toml,
    which start the gateway alone in front of the CDRs you already run, with no
    checkout of the repository (#385; no specification governs packaging: our
    own design). You download the three, edit the two TOML files (the gateway
    configuration and the registry document, in their documented formats, every
    value to change marked EDIT), put each credential in a file under
    secrets/, and run docker compose up --wait. The compose file runs the
    published ghcr.io/ferrohealth/ferrofed image at the release's version and
    mounts the two files and secrets/ read-only; its only variables are about
    the container (image tag, host address and port, CPU and memory). The
    gateway runs read-only, as uid 65532, with every capability dropped,
    no-new-privileges, CPU and memory limits, a 20-second stop grace period,
    the ferrofed healthcheck probe and the unless-stopped restart policy, so
    a configuration it refuses (exit 78, naming the key) is retried at Docker's
    doubling delay. The release lane attaches the three files to the draft and
    refuses to publish a draft missing any of them, scripts/checks/versions.sh
    and the release plan hold the image tag to the product version, and the CI
    job release compose renders the compose file and runs ferrofed config check over the two examples as attached. The repository's compose.yaml
    stays the four-node quickstart. The container page of the book has a
    section "The gateway from a release".

  • The operator can send a stored-query version the registry holds to the
    members that missed it (#342; §12.7 stored-query-drift, N44, CP-40; no
    specification gives drift repair a request, so this is our own design).
    POST /admin/stored-queries/{name}/{version}/distribute on the admin
    listener ([metrics] listen), with no body and the members in the
    targeting headers, sends the registry's held copy to them and changes
    nothing at the registry. The answer has the shape and statuses of a first
    distribution, and its new meta.registry member says held, where a
    first distribution's says stored. It is 404 for a version the
    registry does not hold, 400 for a request naming no member, a body, a
    deployment without distribution or an endpoint-targeted definition, and
    405 with an empty Allow at a read-only registry. Without the admin
    listener the action does not exist. Every second PUT of a held version
    stays 409 (stored-query-held), the same query naming members
    included. The book's stored-query page has a "Repairing drift" section.

  • A metrics surface (#281; no specification governs metrics). One
    OpenTelemetry meter provider counts the integrity incidents by kind
    (ferrofed_integrity_incidents_total), the requests sent to each member
    endpoint by the §11.1 outcome the per-endpoint report gives them
    (ferrofed_node_requests_total{endpoint, outcome}), how long each member
    took to answer (ferrofed_node_request_duration_seconds{endpoint}), and
    the registry reloads by result, applied or refused
    (ferrofed_registry_reloads_total). [metrics] listen serves the
    Prometheus text exposition at GET /metrics on an admin listener of its
    own, never the gateway's listener, and [metrics] otlp_endpoint pushes the
    same metrics to an OTLP gRPC collector; both are off by default. serve
    and config check refuse a listener on a non-loopback address unless
    [metrics] allow_remote = true is set, a listener on server.listen, and
    a collector that is no http:// URL. Every label value is drawn from a
    closed set or the registry document, never from a request (§5.4.1, N33).
    The book's operate section has a Metrics page listing every metric and its
    labels. The integrity incident webhook the architecture named is removed
    from the design: alert on the counter.

  • The stored-query registry runs on one of three backends, chosen with
    [stored_queries] backend (#268; §12.7, N44, CP-40). redb, the default,
    is the embedded file for one gateway, as before. postgres, behind the
    ferrofed-server cargo feature of the same name and off by default, keeps
    the definitions in one PostgreSQL database every replica shares: the
    primary key on the name and version with INSERT … ON CONFLICT DO NOTHING
    stores exactly one of two racing PUTs of a new version, the other is a
    409 (stored-query-held), and a read or an invocation reads the store
    again first, so every replica serves what any of them stored. Its
    connection string is a secret, url or url_file, TLS is rustls with the
    platform's roots, and the schema ferrofed and its table are created when
    absent. files is read-only: one file per
    {qualified_query_name}/{version}.aql, loaded and admitted at start; a
    PUT is a 405 with the new code stored-query-read-only and Allow: GET, OPTIONS, OPTIONS lists no PUT, and a malformed file refuses the
    start and config check, naming the file and never its content. The
    startup banner and log line name the backend kind and never its path or
    connection string.

  • PUT {base}/v1/ehr/{ehr_id} is checked against what the gateway already
    knows of the ehr_id before anything is sent (#289; §12.4, §12.5.2, N23,
    N42, CP-15; ITS-REST 1.1.0 ehr_create_with_id). When a resolution
    binding of the session or the ehr_id index places the ehr_id at a
    member other than the one the targeting headers name, the create is a
    409 with the new code ehr-id-held, no node is sent it, and the
    gateway logs a warning naming endpoint ids only; the message names the
    holding endpoints and the targeted one, never the ehr_id. A create at
    the member that holds the ehr_id is forwarded, so that node answers its
    own 409, and an ehr_id nothing places is forwarded as before and,
    once created, indexed at the targeted member, where a racing create at
    another member raises the index-insert alarm of §12b.2.

  • Stored-query definition fan-out, opt-in with [federation] fan_out_stored_queries beside the registry (#78; §12.7, N44, N43, CP-40).
    Off by default, refused by config check and at start without
    [stored_queries], and declared in OPTIONS {base}/ as
    definition.stored_query_fan_out, true only beside the registry. Where
    it is on, a stored-query PUT whose openEHR-federation-endpoint is *
    or whose targeting headers name members is stored at the registry first,
    then sent to each member as the registry's canonical AQL through
    openehr-its's generated client, and answered with the registry's
    StoredQuery and meta.federation on the template fan-out's statuses:
    200, 207 with complete false, or 424 and 504, the registry
    holding the definition whatever the members answered and nothing rolled
    back. A definition carrying a FROM ENDPOINT or ORGANISATION directive
    is refused for distribution with 400 definition-endpoint-targeted
    before anything is stored, and stays storable and executable federated.
    A GET of a version naming members reports drift per member: active
    where its copy is the same query, and node-error with
    error.code definition-differs or definition-missing otherwise, 207
    unless every named member matches. An invocation always runs the
    registry's AQL, never a member's copy. With the setting off, a registry
    PUT or version GET carrying openEHR-federation-endpoint or
    openEHR-federation-organisation is refused with
    400 stored-query-fan-out-unsupported, storing and reading nothing,
    where the header was ignored before.

  • The federated AQL answer carries the provenance headers (#288; §7a.3,
    N31, CP-24). openEHR-federation-endpoint lists the endpoints that
    contributed rows and openEHR-federation-system-id their nodes'
    system_ids, position for position, in registry order and in the
    comma-separated form of the §8.4 request header. An endpoint contributed
    when it answered active with a row_count above 0; one that answered no
    rows, failed or was never asked is not listed, an answer that fails lists
    none, and meta.federation.endpoints[] still names every endpoint (§11.1).
    The headers carry registry ids and system_ids, never a request value
    (§5.4.1).

  • Fan-out template upload, opt-in with [federation] fan_out_template_upload
    (#76; §12.6, N43, CP-34). Off by default, and OPTIONS {base}/ declares
    the configured value as definition.fan_out_template_upload. Where it is
    on, an ADL 1.4 or ADL 2 template upload whose openEHR-federation-endpoint
    is * (every active member) or whose targeting headers select several
    endpoints is sent to each member independently, byte-identical through the
    routed forwarding path, and nothing is rolled back. The answer is
    meta.federation with one endpoints[] entry per registry member and no
    node's body: 200 when every named member accepted, 207 with
    complete: false when some failed, and 424 or 504 when none accepted.
    The provenance headers list the members that accepted. A plain upload still
    names its one node, and * stays endpoint-unknown on every other
    definition request and whenever the setting is off.

  • ferrofed serve opens with a startup banner when its log renders for a
    person (#330): the FerroFED wordmark, the version, the maintainer and the
    repository, the Federation Tier, ITS-REST, AQL and openehr-* releases it
    serves, then the base path, the listen address, the member and endpoint
    counts of the registry document, and whether the stored-query registry is
    offered. The document is read once, before the banner, and the gateway is
    built over that same read, so the banner counts the registry it serves.
    Under profile = "development" a red notice, in plain words where
    there is no colour, says the deployment must not hold or reach real
    patient data. The banner prints only before the pretty rendering, so a
    log collector reading JSON never receives it, and config check,
    healthcheck and admission check print none. It never shows a
    credential, a URL, a header value or request data. Each pin comes from the
    crate constant scripts/checks/versions.sh holds to docs/VERSIONS.md,
    and the openehr-* family version is a new constant the guard checks.

  • The console honours NO_COLOR (#330, https://no-color.org): set and not
    empty, it switches colour off in the pretty log and the startup banner,
    whatever the format and the terminal.

  • The ITS-REST GET forms of query execution are served (#287; N1, CP-1).
    GET {base}/v1/query/aql and, where the stored-query registry is offered,
    GET {base}/v1/query/{name}[/{version}] carry the request in the query
    string: q (ad hoc only), offset and fetch by name, and every other pair
    a query_parameters member, so a stored GET binds q=… to $q. The
    generated openehr-its parameters decode it (FerroEHR #3540), a + is a
    literal plus, and the request runs the pipeline of the POST form
    unchanged: the rewrite, the outbound gate, the fan-out, the merge and the
    completeness rules, with the same node requests and the same answer. An
    ehr_id is dropped, as from a POST body. A query string the decoder
    refuses is 400 body-invalid and asks nobody; a null parameter is
    400 parameter-invalid, as in a body. OPTIONS on both paths lists GET,
    and the request log records neither q nor a parameter value.

  • The DEMOGRAPHIC area is never federated, and may be routed to one
    declared endpoint that the request names (#68, #311; §7a.1, §12.4, §12.6,
    N23, N32, N31, CP-25). By default every request under
    {base}/v1/demographic/ answers 501 and no node is asked. The new
    [federation] demographic_endpoint setting declares the one registry
    endpoint that serves the area. A DEMOGRAPHIC request names that endpoint
    in openEHR-federation-endpoint and then goes to it alone, through the
    single-node path definition requests use: the body byte-identical, only
    what the ITS-REST operation declares, and the node's answer as the node
    sent it with openEHR-federation-endpoint and
    openEHR-federation-system-id. The setting is never applied as a default:
    a request naming no endpoint is 400 target-required and no node is
    asked, one naming another endpoint is 400 targeting-conflict, and
    several endpoints, * or an unknown id are refused as on any routed
    request. config check refuses an id the registry does not hold, and the
    setting without registry.document. OPTIONS {base}/ declares
    its_rest.demographic as unsupported: 501, or as routed-single-node
    naming the endpoint a request names, and OPTIONS on a DEMOGRAPHIC path
    names its ITS-REST methods only when the area is routed.

  • Definition requests are routed to one explicitly chosen node (#75; §7a.1,
    §12.6, §12.7, N43, N31, N33, CP-34). Every request under
    {base}/v1/definition/, an ADL 1.4 or ADL 2 template upload, list, read or
    example, and stored-query management where the stored-query registry is not
    offered, goes only to the one endpoint the targeting headers name. The body
    reaches that node byte-identical with only what the ITS-REST operation
    declares, and the node's answer, its 404 or validation 400 included,
    comes back as the node sent it with openEHR-federation-endpoint and
    openEHR-federation-system-id. Without a header the request is
    400 target-required; * or an unknown id is 400 endpoint-unknown and
    two endpoints are 400 endpoint-several, so no node is ever picked
    implicitly and no two nodes' templates are combined into one catalogue.
    Where the registry is offered it keeps answering stored-query definitions
    itself. Without the registry both stored-query PUTs are routed, the
    versioned PUT {base}/v1/definition/query/{name}/{version} included
    (#298), and OPTIONS on either path lists PUT.
    OPTIONS {base}/ declares its_rest.definition as routed-single-node,
    and OPTIONS on a definition path names its ITS-REST methods.

  • ferrofed admission check --endpoint <id> exercises one configured
    member against the identifier-integrity conditions of §12b.2 and writes a
    report to standard output (#79; §12b.1, §12b.2, N42a, CP-33a). It creates
    test EHRs on the node (3 by default, --count from 2 to 50) through
    POST {base}/v1/ehr, each for a fresh synthetic subject in
    urn:oid:2.999.1.0, reads each back, and reports every condition as
    pass, fail or cannot-check with its evidence: ehr_id generation
    (a version-4 UUID, and no ehr_id issued twice), system_id uniqueness
    (the system_id the node reports is the one the registry records, which
    the registry holds unique), and the ehr_id exchange (the configured
    cross-reference maps each subject to the ehr_id the node created, or
    cannot-check where the gateway cannot write to it). Reuse across
    restores and adoption of foreign ehr_ids are always cannot-check, with
    the reason. A node the check cannot reach fails every condition it
    exercises. Every request passes the outbound gate with the run's subjects
    withheld (§5.4.1, N33), and the report prints no subject and no node error
    body. The command exits 0 when no condition failed, 1 when one did.
    The book's new page "Admitting a node" states what the check proves and
    what it cannot.

  • The registry reloads on SIGHUP (#282). ferrofed serve reads its
    configuration again from where it started and checks it as at boot; a
    valid one replaces the registry for every request that starts after it,
    while a request in flight finishes on the registry it took. The
    [registry], [credentials], [dev] and [pixm] sections take effect: an
    added endpoint gets its node client and credentials, and a removed one is
    never called again. A learned creating_system_id route the new document
    maps elsewhere is withdrawn with a RegisteredCreatingSystemConflict
    incident, and ehr_id index entries and resolution bindings naming a
    member that left are dropped; one a request in flight learns after the
    swap is dropped at its next lookup, and a read then probes every member
    while a write needs its target, so an entry is never narrowed to the
    claimant that remains (§12.5.2, N42). A change to any other setting is logged as
    needing a restart while the rest applies. A configuration that does not
    load is refused, the running registry stays, and the ERROR line names
    the failure class and the files, never their content. config check is
    unchanged. There is no file watch.

  • GET {base}/v1/ehr?subject_id=…&subject_namespace=…, the ITS-REST read of
    an EHR by subject, is served (#218; §5.2, §5.4.1, §12.5.2, N3, N31, N33,
    CP-24, CP-26). The two parameters are resolution input: the gateway
    resolves the subject through the cross-reference service and sends
    GET {base}/v1/ehr/{ehr_id} to the one member that holds it, under that
    member's own ehr_id, with no query string, and returns the node's answer
    unmodified with openEHR-federation-endpoint and
    openEHR-federation-system-id. No node request, error body or log line
    carries the subject. A subject several members hold is refused with the new
    code 409 subject-several listing the endpoints, unless
    openEHR-federation-endpoint names one of them; a subject no member holds
    is the operation's own 404 no-destination; a cross-reference that cannot
    answer is the new 424 resolution-unavailable. OPTIONS {base}/v1/ehr
    now lists GET.

  • The gateway serves at a base path of the deployment's choosing (#69;
    §4.1, N28, CP-21). [server] base_path, / by default, puts every route
    under it, OPTIONS {base}/, the health family and {base}/v1/… included,
    and every path outside it answers 404. No prefix is reserved, and
    /rest/openehr is served only where a deployment chooses it. A base path
    that does not start with /, ends with /, carries a query or a fragment,
    or has an empty, . or .. segment refuses to boot, naming
    server.base_path. Nodes are still asked at their own base, and a node's
    Location still passes through unmodified (N31).

  • Both AQL forms of N29 scope a query to one ehr_id (#69; §7.1, N29,
    CP-22). FROM EHR e[ehr_id/value='…'] reaches a node as the canonical
    WHERE e/ehr_id/value = '…', so both forms send the same query and answer
    the same rows. An undirected query scoped to one ehr_id goes only to the
    member that owns it, found as a path ehr_id is (§12.5.1, N41): the
    session's binding, the ehr_id index, then the ask-all probe. Every other
    member is reported excluded, the answer names the acting endpoint in
    openEHR-federation-endpoint (N31), two claimants are 409 ehr-id-collision with neither queried (N42), an ehr_id no member holds is
    404 no-destination, and one that is not a bare UUID and no earlier step
    routes is 400 probe-requires-uuid with no member asked (§5.4.1, N33).
    A query directed by FROM ENDPOINT or a targeting header still goes to the
    endpoints it names.

  • Versioned writes reach only their controlling CDR, and a new EHR only an
    explicit target (#65; §12.4, §12a.1, §10.3, N23, N41, CP-15). An update of
    a composition, the EHR_STATUS or the directory, a directory delete (each
    naming its preceding version in If-Match) and a composition delete
    (naming it in the path) route by the path ehr_id as before, and are sent
    only when the registry maps the preceding version's creating_system_id
    to that same node, as the member's own system_id or a [[creating_system]]
    mapping; a learned mapping never counts. Otherwise the write is refused
    409 controlling-system-unreachable, naming the controlling node where the
    registry knows it, and no node is sent it. A versioned write that names no
    single preceding version (no If-Match, several, *, a weak or unquoted
    tag, or no OBJECT_VERSION_ID) is refused with the new code
    400 preceding-version-invalid. POST {base}/v1/ehr is now served, and it
    and PUT {base}/v1/ehr/{ehr_id} go only to the one endpoint the targeting
    headers name: without them the request is 400 target-required, and with
    several endpoints 400 endpoint-several; neither a binding nor the index
    routes a new EHR. OPTIONS {base}/v1/ehr answers POST, OPTIONS.

  • A CONTRIBUTION that amends versions reaches only the CDR that controls
    every one of them (#286; §12.4, §12a.1, §10.3, N23, CP-15).
    POST {base}/v1/ehr/{ehr_id}/contribution routes by its path ehr_id as
    before, and the gateway now reads the body with the ITS-REST
    NewContribution type: each version's preceding_version_uid must be one
    the registry maps to that node, by the rule above, or the request is
    refused 409 controlling-system-unreachable and no node is sent it. A
    CONTRIBUTION of creations alone routes as before. A body that is no
    CONTRIBUTION in the representation its Content-Type selects, a
    preceding_version_uid that is no OBJECT_VERSION_ID, or an XML body, is
    refused 400 preceding-version-invalid. The node receives the body byte
    for byte.

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

  • Integrity incidents for an ehr_id two members claim (#63; §12.5.2,
    §12b.2, N42, CP-33). A request whose ehr_id the session's resolution
    bindings, the ehr_id index or the ask-all probe finds at two members or
    more is refused 409 ehr-id-collision listing the claiming endpoints, and
    the gateway logs one EhrIdCollision incident: an ERROR line under the
    new log target ferrofed::integrity with the ehr_id, the routing step
    that found the claimants (detection) and the claimants' endpoint ids.
    When the index learns an ehr_id it already holds at another member, it
    raises the index-insert alarm of §12b.2 once, an IndexInsertCollision
    incident naming the members, keeps both and routes neither; a held
    collision lasts until the entry is forgotten or the gateway restarts. An
    incident names an ehr_id only when it is a bare UUID, and never a patient
    identifier. The book's configuration page says what an operator sees and
    does, and the metrics surface (#281) counts each incident by kind.

  • The obligations checklist (#278): conformance/obligations.tsv holds one
    row per normative statement of the pinned Federation Tier specification,
    447 across its 26 pages and both JSON schemas, each with the status
    FerroFED holds for it and the test, code, issue or #212 report behind that
    status. 273 are tested, 52 planned, 3 built but untested (#290), 3 missing
    (the GET forms of query execution, #287, and the provenance headers on the
    federated AQL answer, #288), 5 deferred, and 18 are contradictions or
    silences of the text recorded on #212; the other 93 fall on a member node
    or the operator, or are no obligation of the gateway. The book renders the counts and every gap on a new
    page beside the conformance matrix. A new tier-1 guard,
    scripts/checks/obligations.sh, fails on an unknown status, a gap that
    names no issue, a test that does not exist, a point or requirement the
    matrix does not hold, a duplicated row, or a stale page, and it holds a
    digest of each vendored page's keyword lines, so a re-pin fails until the
    changed pages are reclassified. The comment-style guard now reads the
    evidence cells of the conformance tables as it reads their reason
    cells.

  • Container readiness (#303). ferrofed healthcheck asks the gateway on this
    host for GET /health/readiness over loopback, prints one line and exits
    0 only on 200, 1 otherwise; the image carries
    HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3
    over it, and the compose.yaml gateway service the same healthcheck.
    Readiness answers 503 until boot completes and from the moment SIGTERM
    or SIGINT arrives, before the drain starts, and its body names the phase
    and the gateway's own subsystems (the configuration, the registry, the
    outbound clients, the stored-query store). No member node and no identity
    source gates readiness: the new GET /health/dependencies always answers
    200 with the state the gateway last observed of each member endpoint and
    of the resolver (up, failing, down, unknown), from the requests it
    already makes, never a probe of its own, naming endpoint ids and states
    only. deploy/kubernetes/ holds an example ConfigMap, Deployment, Service
    and PodDisruptionBudget, validated in CI by a digest-pinned kubeconform
    in strict mode. No specification governs health probes: our own design.

  • The supported platforms are stated (#307): FerroFED runs on Unix only.
    The server drains on SIGTERM and reloads on SIGHUP through Unix
    signals, and every release binary and the container image are Linux. The
    book says so under Operate, and building ferrofed-server for a non-Unix
    target, such as Windows, stops at a compile_error! that names the reason.
    No specification governs this: our own design.

Changed

  • CI checks every internal link and anchor of the published site (#377; no
    specification governs this: our own design). The new tier-1 job
    site-links, in the required conclusion, assembles the site as the Docs
    workflow publishes it and runs lychee 0.24.2, pinned by version and
    SHA-256, with --offline --include-fragments over every page, and over
    README.md against the repository tree, so a link to a page or an anchor
    that does not exist fails the change and no request leaves the runner.
    scripts/checks/site-links.sh runs the same check locally, with a
    self-test, and scripts/site/assemble.sh takes SITE_ROADMAP=off to leave
    the roadmap block unrendered. docs/VERSIONS.md pins lychee, and the
    versions and pin-freshness guards hold the pin.
  • The errors page names the requests that answer 501 not-implemented
    (#378; §7a.1, N32): every path under {base}/v1/, and OPTIONS {base}/,
    without a registry; the DEMOGRAPHIC API unless its endpoint is declared;
    the ADMIN API; stored-query execution without the stored-query registry; a
    path ITS-REST does not define or a method it does not declare; and
    OPTIONS on an unserved path. The errors test now sends each of them.
  • The quickstart's compose.yaml runs only the published
    ghcr.io/ferrohealth/ferrofed image: its build: block is gone, so
    docker compose up --build no longer builds the gateway, and
    scripts/checks/release-compose.sh fails when any compose file in the
    repository carries build: (#385; no specification governs packaging: our
    own design). The gateway service now has a 20-second stop grace period,
    longer than its 10-second drain, so docker compose down no longer cuts a
    drain off at Docker's default 10 seconds.
  • The README, the landing page and the book are rewritten against what the
    gateway does on main (#363; no specification governs the website). The
    book's claims page lists what v0.0.3, v0.0.6 and v0.0.7 carry and what
    v0.0.8 and v0.0.9 plan, by issue; the operate pages state that the gateway
    authenticates no client yet and which onward credentials it sends, that no
    session-scoped resolution binding is held until client authentication
    (#80), and that a node's consent refusal is reported node-error until
    #83. Two pages are new: Identity resolution, covering [pixm], [dev],
    profile and federation.default_namespace, and Health probes, moved out
    of the container page. Prefer: wait is documented beside the budgets.
    The landing page's quickstart runs the four-node compose stack with its
    seed script, and its cards name what each release shipped.
  • The ask-all probe is timed (#366; no specification governs metrics): each
    member's probe now carries its latency, so
    ferrofed_node_request_duration_seconds times it beside its count in
    ferrofed_node_requests_total, and a probe still waiting when the overall
    budget runs out is a time-out timed to that moment. The book's Metrics
    page lists which calls each outcome and the histogram cover.
  • The book's client contract and configuration pages are split along their
    own sections (#348): the client contract continues on Follow-ups,
    Templates, definitions and demographics, and Stored queries, and the
    configuration on The registry, and Queries and API areas. The file-length
    guard now holds the book's Markdown pages to the same limits as the Rust
    sources.
  • A node's error in meta.federation.endpoints[] follows one rule on every
    path that reports a member: a federated query, the fan-out template upload
    and the stored-query distribution and drift check (#343; §9.5, §11.1,
    §12.6 item 2, N40). A node-error carries the node's HTTP status, then
    an excerpt of the node's own message: at most 512 characters, control
    characters and invisible format marks turned into spaces, and the patient
    identifier the query resolved on replaced by [withheld] wherever the
    node echoed it, the whole message when the node echoed it percent-encoded
    (§5.4.1, N33). The HTTP client's reason an offline or time-out node
    was not reached is held to the same rule. The two fan-outs carried the
    node's status alone before,
    and a query copied the node's message with no cleaning and no masking.
  • A recombined AVG over integers answers an integer (#309; AQL 1.1.0
    §3.9.1.5, "it will also determine the return type"). When every node's
    SUM is an integer, the gateway returns the integer nearest the exact
    quotient of the federation's sum and count, a tie going to the even one,
    so 5 / 2 is 2 and 7 / 2 is 4; it rounds once, after adding every
    node's sum and count, never per node. AQL states no rounding rule, so the
    rounding is FerroFED's own. When a node's SUM is a real, AVG is the
    decimal mean written as the nearest JSON number, as before.
    openehr-federation is 0.0.33.
  • The openehr-* family moves to 0.0.81 (FerroEHR #3548, #3551, #3552), and
    openehr-federation to 0.0.35 with it (#329).
  • A body routed to one node without a Content-Type travels with the first
    media type the operation lists, application/json wherever ITS-REST lists
    several, where it was a 415 for an operation whose body is declared in
    more than one (#329). ITS-REST 1.1.0 makes Content-Type optional with no
    default, so the first listed is the gateway's own choice, as for a missing
    Accept. The Content-Type of a body is held to the media types the
    operation's body is declared in, and a CONTRIBUTION is read for its
    preceding versions by the request body reader of openehr-its.
  • The compose quickstart runs four FerroEHR nodes, ferroehr-a to
    ferroehr-d on ports 8081 to 8084, each with its own system_id (#322).
    They share one FerroEHR PostgreSQL container, ferroehr-postgres, with a
    database per node owned by its own role; the image's init script runs once
    per node through docker/postgres/20-ferrofed-node-databases.sh, so the
    per-node ferroehr-a-postgres and ferroehr-b-postgres services and their
    volumes are gone (docker compose down -v removes the old ones). Each node
    database admits only its own node's role: CONNECT is revoked from
    PUBLIC, so one node's role is refused on another node's database. The
    gateway's quickstart configuration is a development profile whose static
    cross-reference maps four synthetic patients in urn:oid:2.999.1.1: one at
    all four nodes, one at two, one at one and one at none, and
    scripts/quickstart/seed.sh creates exactly those EHRs and a composition in
    each over ITS-REST. A patient query now resolves in the quickstart instead
    of failing closed with 424. The book's container page walks through a full
    federated query, a patient missing at some nodes and a query directed at one
    node, and states the measured memory use: 238 MiB after
    docker compose up --wait, 425 MiB after the seed and the queries.
  • The end-to-end harness starts one FerroEHR PostgreSQL container per topology
    with a database per node, through the same init script, instead of one
    container per node (#322, #320). CI keeps two nodes.
  • scripts/conformance/obligations.sh and scripts/checks/obligations.sh
    are executable, as every other script is (#320).
  • The query string of a stored-query PUT {base}/v1/definition/query/{name}/{version}
    is decoded by the generated openehr-its parameters (#292, FerroEHR
    #3540), so a + is a literal plus. A query_type given twice, or a pair
    that does not percent-decode to UTF-8 text, is now 400 body-invalid and
    nothing is stored; an undeclared parameter stays
    400 query-parameter-refused.
  • The openehr-* family moves to 0.0.80 (FerroEHR #3539 to #3541, #3543), and
    openehr-federation to 0.0.31 with it. The fuzz crate is now held to the
    family pin by scripts/checks/versions.sh.
  • An ehr_id that the session's resolution bindings or the ehr_id index
    hold at two members is now refused 409 ehr-id-collision listing the
    claimants, on a write as on a read (#63; §12.5.2, N42). A read no longer
    goes on to the ask-all probe, and a write is no longer 400 target-required. Naming the node in openEHR-federation-endpoint still
    routes the request to it (§12.5.1 step 1, N41). The integrity incidents of
    the creating_system_id map are logged under ferrofed::integrity too.
  • The 409 controlling-system-unreachable message now names the controlling
    system in every case (#66; §10.3 copy-write-reject, N36, CP-29). Where
    the registry routes the version's creating_system_id to another member,
    it names that creating_system_id as the registry spells it, with the
    member's node and endpoint. Where no member is known to control the
    version, it points at the place in the request that names the version
    (If-Match, the path, or a CONTRIBUTION version by position), and still
    quotes nothing of the request. CP-29 is scored end to end: a write against
    a row that version-identity dedup kept, sent through the copy's ehr_id,
    is refused 409 whether the creating node is down or up, the copy's node
    is sent nothing and the creating node is never tried, and a write through
    the creating node's own ehr_id reaches it alone and its answer names no
    copy.
  • A body routed to one node always travels with a Content-Type its
    ITS-REST operation declares (#298; §12.6, N43, CP-34). The gateway reads
    each operation's request-body media types from openehr-its
    (request_media): a client Content-Type for an operation that declares
    no Content-Type parameter, such as the versioned stored-query PUT, is
    composed as the listed media type it names, or refused 415 media-type-unsupported; a body sent without one travels with the one
    media type the operation's body is declared in, text/plain for a stored
    query and application/json for a commit, and an operation whose body is
    declared in several is 415. A forwarded body no longer reaches a node
    without a Content-Type.
  • Each path identifier of a routed request is parsed as the identifier class
    openehr-its states for it, and the follow-up routing table reads a
    version from the path parameters of that class, with no name list of the
    gateway's own (#291; §5.4.1, §12.2, N33, CP-26). The uid_based_id of a
    DELETE is an OBJECT_VERSION_ID, as ITS-REST declares: a DEMOGRAPHIC
    delete addressing a HIER_OBJECT_ID is 400 parameter-value-invalid,
    and a composition delete stays 400 preceding-version-invalid, with
    nothing sent.

Fixed

  • Every link in llms.txt is now an absolute URL (#392). The file is
    served at the site root, where its repository-relative links resolved to
    nothing.

  • A fan-out template upload, a stored-query distribution or repair, and a
    stored-query drift check fail with a 500 when the task for one member
    panics (#380; no specification governs the metrics or health probes). Such
    a task was reported as abandoned by the member: counted as a time-out in
    ferrofed_node_requests_total, marked down on
    GET /health/dependencies, and listed as time-out in meta.federation,
    blaming the node for a defect in the gateway. The probe and the federated
    query already failed this way, and none of these calls records a member
    for a request it fails.

  • The admission check reports an EHR call whose deadline passed before it
    left the gateway as a call never sent (#379; §11.5), where it said the
    node did not answer before the deadline.

  • A request whose deadline passed before it left the gateway is no longer
    counted as a node time-out (#374; §11.5; no specification governs the
    metrics or health probes). A request routed to one node, an ask-all probe,
    a fan-out template upload, a stored-query call and a federated query member
    the budget overtook before sending are now neither counted in
    ferrofed_node_requests_total nor timed in
    ferrofed_node_request_duration_seconds, and leave the member's state on
    GET /health/dependencies as it was, where they counted as time-out and
    set the member down. A fan-out whose overall budget had already run out
    when it started reported every member as asked and silent; it now reports
    none as asked. What the client receives is unchanged: the routed request
    and the probe answer 504 (node-timeout), and the member record in
    meta.federation says time-out.

  • GET {base}/health/dependencies records each member a fan-out template
    upload, a stored-query distribution or repair, or a stored-query drift
    check asked (#366; no specification governs health probes), where a member
    those calls found down kept the state an earlier request left. One rule
    now holds for every call: the state is the member's reachability and
    health, never whether the request was valid. Any answer below 500 is
    up, a 5xx is failing, and no answer is down, read from the node's
    own HTTP status whatever the §11.1 record says. A query member that
    answered 400, a refused store, a node that refused the gateway's onward
    credentials, and a drift check whose copy differs or is missing are up
    where some of them were failing; a request that never left the gateway
    changes nothing.

  • ferrofed_node_requests_total and ferrofed_node_request_duration_seconds
    count only requests that left the gateway (#366). A member a fan-out
    template upload or a stored-query call could not send a request to stays
    offline in meta.federation, which §11.1 leaves no other status for,
    and is no longer counted and timed as an offline node request; nor is a
    federated query member whose deadline passed before its request left.

  • A request the gateway routes or answers under {base}/v1/ is logged under
    the path template of the ITS-REST operation it addresses, such as
    /v1/ehr/{ehr_id}/composition/{uid_based_id}, where its request line named
    <unmatched> (#219). The template comes from openehr-its's route table,
    so the line never carries the ehr_id or version uid of the path, and the
    configured base path appears in front of it once. An OPTIONS request is
    logged under the template of the resource it describes. A path that names
    no route, or any other method its operation does not declare, is still
    logged as <unmatched>.

  • POST {base}/v1/query/aql and the stored-query POST {base}/v1/query/{name}
    hold their Content-Type to application/json, the media type ITS-REST
    1.1.0 lists for both (#269). Another media type, or a parameter other than
    charset=utf-8, is 415 media-type-unsupported, and no node is asked
    (RFC 9110 §15.5.16); a body sent as text/plain used to be read as JSON.
    A body sent without a Content-Type is read as JSON, the first listed
    media type, as for a missing Accept, since ITS-REST and RFC 9110 §8.3
    name no default. The stored-query definition
    PUT {base}/v1/definition/query/{name}/{version} the registry answers
    holds its Content-Type to text/plain under the same rule: another media
    type is 415 media-type-unsupported and nothing is stored.

  • The query-parameter-refused security event says "a request carried a
    query parameter the gateway does not admit for its operation, and was
    refused", which is true of a routed request and of the stored-query
    definition PUT that raises it too (#269).

  • OPTIONS {base}/ declares the EHR area as the gateway routes it (#290;
    §7a.2, N30, CP-23). its_rest.ehr now says that a new EHR, by
    POST {base}/v1/ehr or by PUT {base}/v1/ehr/{ehr_id}, goes only to the
    endpoint the targeting headers name, and that the PUT is refused when
    another member holds its ehr_id. It no longer names the session's
    resolution binding as a routing step, because without client
    authentication no request has a session. Each of the four its_rest
    declarations is now tested against the behaviour of its area in every
    configuration mode.

  • A query dispatched to a single node, directed at one endpoint by the
    openEHR-federation-endpoint header or FROM ENDPOINT, or undirected with
    the patient resolving at one member alone, answers with
    openEHR-federation-endpoint and openEHR-federation-system-id naming that
    endpoint and its node's system_id, as N31 requires of a request
    dispatched to a single node (#319; §7a.3, CP-24). Zero rows, a 424 or a
    504 from that node carries them too; a query that asked no node carries
    neither.

  • A request routed by its target alone (POST {base}/v1/ehr and a definition
    request) checks its declared header and query values before its target, as
    the EHR route does: a malformed value is 400 parameter-value-invalid, and
    an Accept or Content-Type the operation does not list is 406 or 415,
    before 400 target-required (§5.4.1, N33).

  • A [credentials."<endpoint id>"] key is held to the registry's endpoint id
    rule when the configuration resolves, with or without a registry (#272).
    Configuration used to accept 1 to 128 printable ASCII characters and left
    the registry's rule to the federation load, so a key such as node:a,
    -node or a 65-character id passed when no registry was configured. The
    one rule is now the registry's: 1 to 64 ASCII letters, digits, ., -
    and _, starting with a letter or digit, and the refusal (exit 78) names
    the key. The CI container job now runs the ENDPOINT
    attribute test against both FerroEHR nodes, and a new tier-1 guard,
    scripts/checks/e2e-placement.sh, refuses a container test placed where
    that job never selects it.

Security

  • A request routed to one node no longer forwards client text in a path
    identifier, a structured query value or a negotiation header (#261;
    §5.4.1, N33, CP-26). The routed path resolves no patient, so the outbound
    gate had nothing to compare a client's version_at_time, Accept or
    path version_uid against, and a patient identifier written there
    reached the node. The gateway now works from what the ITS-REST operation
    declares in openehr-its's parameter table. It composes Accept,
    Content-Type and Prefer itself, so the node receives a value the
    operation lists, in its own spelling: Accept is negotiated as an
    RFC 9110 §12.5.1 media-range list, */* or no Accept sending the first
    listed type, and one that admits no listed type is refused with the new
    code media-type-not-acceptable (406); Content-Type must name a
    listed type, with charset=utf-8 accepted and dropped, or it is refused
    with the new code media-type-unsupported (415); Prefer keeps only
    the listed preferences and ignores the rest (RFC 7240 §2). Each path
    identifier parses as the openEHR identifier it names with openehr-base
    (version_uid an OBJECT_VERSION_ID, a text uid_based_id a
    UID_BASED_ID, a uuid-format parameter a canonical UUID), and the path
    then travels byte for byte (N22). Each query value matches its declared
    kind: a date-time is an extended ISO 8601 date-time read by
    openehr-base's Iso8601_date_time (ITS-REST Overview, "Datetime
    format"). A malformed value is refused with the new code
    parameter-value-invalid (400), naming the header, or the parameter by
    position and declared name, never the value; no node is asked, the
    ask-all probe included, and the refusal is logged as the security event
    parameter-value-refused. A composition update must address the
    versioned object's UUID, as ITS-REST declares. The free text the table
    states no kind for (If-Match, openehr-audit-details,
    openehr-item-tag, openehr-template-id, openehr-version,
    openehr-version-item-tag, the item-tag key, path, tag_key,
    tag_value, tag_target_path) still travels as sent; whether N33 covers
    a forwarded client value is recorded on #212. Track 10 gains the
    identifier in a date-time parameter, an Accept parameter and a path
    uid. ferrofed-engine adds the declared module, which takes mime for
    the media types, and ForwardError::Value.
  • The Debug output of the configuration no longer prints an inline
    credential (#364). An inline bearer_token or password of a
    [credentials] section or of a PIX Manager was a plain string, so any
    {:?} of the configuration, in a log line, a panic message or a test
    failure, showed it in clear, and a PIX Manager URL or a registry endpoint
    URL showed the user name and password of its userinfo. Every credential
    is now held in one of two types of the new ferrofed_registry::secret
    module: a Secret, which Debug, Display and Serialize render as
    ***, or a SecretUrl, which they render with the userinfo and the query
    replaced by ***, and as *** whole for a libpq key/value connection
    string. The stored-query store's PostgreSQL url and url_file and
    metrics.otlp_endpoint are SecretUrls too. A _file sibling is read
    straight into the same type, and the text read is zeroed once it is
    trimmed, so the resolved settings, the composed PIXm configuration and
    the registry document carry them as well. What reaches a node, a PIX
    Manager, the store or the collector is unchanged. ferrofed-identity
    adds PixmConfigError::BaseUrl for a Manager base URL that does not
    parse.
  • A PIX Manager url that carries a user name or a password is refused by
    config check and at start, naming pixm.manager[N].url and never
    quoting it, as an endpoint URL in the registry document already is (#364).
    Its credentials go in [pixm.manager.credentials]. A configuration that
    put them in the URL must move them there.
  • The Debug output of ihe-iti's types no longer prints a credential
    (#370). PixmClient and PdqmClient derived Debug, so a base URL that
    carried a user name and password showed both in clear, and the HTTP
    client they hold showed its default headers, an Authorization header
    among them. Each client now shows its URLs with the userinfo and the
    query replaced by ***, and leaves the HTTP client out. mCSD's
    DirectoryOrganization and DirectoryEndpoint show their fullUrl and
    the endpoint address the same way, and the endpoint leaves out the rest
    of its resource, whose header list may hold a credential. The crate
    carries this redaction itself and depends on nothing in FerroFED. Every
    redacted value in ihe-iti now shows the family's ***, where the
    identifiers, the matched Patients and the page links showed [REDACTED],
    and so do ferrofed-identity's PatientRef and the admission probe's
    synthetic subject.
    ihe-iti is 0.0.9.
  • A URL whose password holds an unencoded /, ? or #, such as
    https://user:pa/ss@host, no longer shows that password in the rendering
    of a ferrofed_registry::secret::SecretUrl or of an ihe-iti type
    (#370). The userinfo was taken to end at the first /, ? or #, so no
    @ was found and the text showed as written. Text that parses as a URL
    is now read as the URL parser reads it, so an @ in a path or a query
    is not userinfo, and text that does not parse is redacted up to its last
    @.