Skip to content

0.0.6

Choose a tag to compare

@github-actions github-actions released this 03 Oct 06:33
· 546 commits to main since this release
Immutable release. Only release title and notes can be modified.
v0.0.6
52cb38b

The v0.0.4, v0.0.5 and v0.0.6 milestones in one release (no v0.0.4 or v0.0.5
tag was cut). The federated query now shapes its answer across nodes the way
a single CDR would: ORDER BY with LIMIT re-applied at the Tier, bounded
OFFSET pages, DISTINCT, decomposable aggregates and opt-in
version-identity dedup, each refused with a reason where it cannot be exact.
Every failure carries its §11.2 status and a stable error code. A request
under {base}/v1/ehr/{ehr_id} is routed to the one node that holds that EHR,
byte-identical, and every answer teaches the gateway which system created
each version. A client names nodes through the FROM ENDPOINT /
ORGANISATION directive or the targeting headers and selects their ENDPOINT
attributes, the registry also loads as FHIR Endpoint and Organization
resources, stored queries are held at the gateway as immutable versions, and
OPTIONS {base}/ declares all of it. The identifier-hygiene property is now
held by the track 10 leakage suite against both nodes: no client-chosen
request id, targeting header or path segment carries a patient identifier to
a node or into the log.

Added

  • The follow-up routing table learns from every answer (#64; §12.2, N21,
    CP-13). Each version uid in a federated query's rows, and each one a
    routed read names or answers with in its ETag, teaches a route for a
    creating_system_id the registry document does not map; one seen at two
    nodes raises the LearnedCreatingSystemConflict integrity incident and is
    routed on by neither. CP-13 is now scored. A follow-up read of a version
    under {base}/v1/ehr/{ehr_id}/… routes by the path ehr_id in the order
    of N41, never by the version's creating_system_id (§12a.1, §12.5.1, N22,
    N42a), and reaches the node byte-identical.

  • The federated stored-query registry (#77; §12.7, N44, N33, CP-40,
    CP-28). With [stored_queries] path set, the gateway holds stored queries
    itself in an embedded redb file that survives a restart, and
    OPTIONS {base}/ declares definition.stored_query_registry: true.
    PUT {base}/v1/definition/query/{name}/{version} stores the AQL on
    ITS-REST's semver segment: the name is [{namespace}::]{query-name}, the
    version major.minor.patch, and the text is analysed as an inline query
    would be, with each $parameter standing in for a bound value, and held
    as its canonical print. A definition that names the patient by a literal
    is refused 400 subject-literal, so no patient identifier is held at
    rest; a second PUT of a held name and version is refused
    409 stored-query-held and the held text stands, across a restart too.
    GET on the same path reads the ITS-REST StoredQuery back, and
    GET {base}/v1/definition/query/{pattern} lists the versions of every
    name the pattern starts. POST {base}/v1/query/{name}[/{version}] runs
    the stored query over every member exactly as if its text were sent
    inline, with the client's offset, fetch and query_parameters, the
    targeting, completeness and dedup headers and the budget; without a
    version the highest runs, and a {major} or {major}.{minor} prefix runs
    the highest it matches. The answer carries ITS-REST's name naming the
    gateway's definition. New codes: query-name-invalid,
    query-version-invalid, query-version-required, query-type-unsupported,
    subject-literal (each 400), stored-query-held (409) and
    stored-query-unknown (404). Without [stored_queries] the definition
    routes still answer 501. openehr-federation is 0.0.30 and adds
    aql::definition::Definition, the admission analysis of a stored
    definition. Definition fan-out to the nodes is #78.

  • Routing a path ehr_id in the order of §12.5.1 (#62; §12.5, N41,
    CP-33). A request to {base}/v1/ehr/{ehr_id} or below it goes to the node
    the targeting headers name, then the node a resolution binding of the
    client session names, then the node the new ehr_id index names, and for
    a read only, the one member an ask-all probe finds. The probe sends
    GET {base}/v1/ehr/{ehr_id} to every member at once within the per-node
    timeout and the overall budget (§11.5); a read of the EHR itself is
    answered from the owner's probe answer. A read no member holds is
    404 no-destination, one two members hold is 409 ehr-id-collision
    listing the claimants, and one a member did not answer for is 504
    (node-timeout, node-unreachable) or 424 (node-refused, and the new
    code node-error), naming the member: a member that gave no answer may
    hold the ehr_id, so the owner is unknown. A read that names no node no
    longer answers 501. A write none of the first three steps routes stays
    400 target-required, and nothing is probed. A step that names two
    members names none, and no later step picks one of them. The path
    ehr_id must be an openEHR HIER_OBJECT_ID, and any other value is 400
    with the new code
    ehr-id-invalid before any routing. The index learns from resolutions and
    from members' successful answers, holds ehr_ids and member ids only, in
    memory, and forgets the least recently used ehr_id past
    federation.ehr_index_capacity (default 100000; 0 is refused). Session
    bindings answer once client authentication lands (#80); the integrity
    incident of a collision is #63.

  • OPTIONS {base}/, the gateway's self-description (#73; §7a.2, N30,
    CP-23). The body validates against the vendored options-root.schema.json
    and is built from the running configuration: the federation id, the
    major.minor of the pinned specification, aql.fan_out from the node
    selection, the dedup default, modes and request header, the per-node and
    overall budgets, all-or-nothing with the best-effort opt-in when it is
    offered, the OFFSET strategy (bounded with its max_window, or
    reject, never a cursor), the decomposable aggregates (an empty list when
    none), nothing offered in the definition area, localization.on_failure: "closed", the ITS-REST areas, and every registry endpoint with its
    organisation, membership status, node, system_id, and product and
    version where the registry holds them. It declares no targeting mechanism,
    no resolution carrier, no asynchronous queries and no JWKS location, and it
    needs no patient identifier. OPTIONS on a path under {base}/v1/
    answers 204 with the methods served there in Allow, without asking a
    node, and 501 where the gateway serves nothing.

  • ENDPOINT attributes in rows (#72; §9.2, §9.3, §9.4, N12, N17, N18,
    CP-35, CP-37). A directed query that selects p/id or p/endpoint_id,
    p/organisation or p/organization_id, p/system_id or p/url through
    the FROM ENDPOINT variable gets each value in every row, as a string, from
    the registry entry of the endpoint the row came from: the values
    meta.federation.endpoints[] reports for it. No node is asked for an
    attribute, and a query that selects none keeps the row shape of a single
    CDR. The §9.4 example answers the §9.4 columns and rows, with columns[]
    paths in the ITS-REST form (/id, /system_id, /uid/value). An alias on
    an attribute keeps it apart from an EHR-derived column of the same name;
    giving both the same name is refused 400 with the new refusal
    endpoint-name-collision, and a path through the variable that is no §9.3
    attribute with endpoint-attribute-unknown. Under DISTINCT the
    attributes take part in which rows are equal. An attribute beside an
    aggregate recombined across endpoints is refused 400
    indecomposable-aggregate, and ordering on an attribute stays refused 400
    endpoint-variable. The 501 not-implemented answer for these queries is
    gone. openehr-federation 0.0.27 adds the attribute module with
    EndpointAttribute, ColumnSource::Endpoint(EndpointAttribute),
    Analysis::attributes, NodeAnswer::with_attributes and
    Merged::attributes; the engine adds Plan::annotating and
    FederatedAnswer::attributes. CP-37 is covered.

  • Track 10, the adversarial identifier-leakage suite, judged on node-side
    wire capture (#90; §16.3 track 10, §5.4, N33, N34, N5, CP-26). One
    synthetic patient identifier is supplied in each of the four positions the
    track names: an EHR_STATUS.subject.external_ref predicate, a
    PARTY_IDENTIFIED/DV_IDENTIFIER predicate, a SELECT projection, and
    the client's query string or headers (X-Request-Id, Authorization,
    Prefer, the targeting and completeness headers, and a free-form header).
    Each runs on the fan-out, on a query directed at one node, and on the
    single-node route, and the journal of the capturing proxy in front of each
    node must hold no occurrence of the identifier, its namespace or any of its
    fragments in the path, the query string, a header or the dispatched AQL,
    raw or percent-decoded; a refusal must be the 400 the specification
    names, with no node asked. Every dispatched request must locate its node by
    the node's own ehr_id alone, and a projected subject column must be the
    re-injected identifier. The converse check commits a COMPOSITION with
    the identifier in a DV_IDENTIFIER and finds it byte-identical at the
    node, compared by bytes and by digest. The gateway's own log (the request
    lines, the security events and the panic line) is searched too and must
    hold none of it. The suite runs against two mock nodes in the normal suite
    and against the two FerroEHR nodes of the harness behind FERROFED_E2E,
    and the conformance matrix records track 10 as covered for the gateway's
    half; CP-27, the node's own obligation, stays with the node profile (#93).
    ferrofed-testkit adds the leak module, the search over the proxy
    journal.

  • The openEHR-federation-endpoint and openEHR-federation-organisation
    request headers, the targeting mechanism beside the AQL (#71; §8.4,
    §8.4.1, N35, CP-28). Each carries a comma-separated list of registry ids,
    over one field line or several, and selects nodes exactly as the
    FROM ENDPOINT and ORGANISATION directive does: a listed endpoint where
    the patient is not known is not-resolved, every other endpoint is
    excluded, an identifier the registry does not know, or a header with no
    identifier in it, is a 400 (endpoint-unknown, organisation-unknown),
    and a selection of no endpoint is 404 no-destination. A query directed at
    one endpoint by the header may be an aggregate, as one directed by the AQL
    may. The headers apply to the federated query and to a request routed to
    one node, where together they select exactly one endpoint. When the
    directive and a header, or the two headers, appear in one request, the
    same node set proceeds and different sets are refused 400 with the new
    code targeting-conflict, whose message names both sets by their registry
    endpoint ids; the gateway never merges them and never picks one. No query
    parameter targets anything: ?endpoint= and ?organisation= have no
    effect on the federated query, and on a routed request they are refused
    400 query-parameter-refused like any parameter the operation does not
    declare. Neither header reaches a node: both join Authorization and
    X-Request-Id in the set a routed request always withholds.

  • The registry document in FHIR form (#74; N19, N20, N21, §15.2, CP-13,
    CP-20). With registry.format = "fhir", registry.document names a FHIR R4
    JSON Bundle of Organization and Endpoint resources, the shape an mCSD
    directory delivers, which loads into the same members and routes
    identically to the native TOML form. The registry's ids travel as
    identifiers in FerroFED's systems under https://ferrofed.eu/fhir/sid/,
    and an endpoint's connectionType is openehr-rest-query in
    https://ferrofed.eu/fhir/CodeSystem/connection-type. config check
    refuses, naming the resource, an endpoint whose connectionType is
    hl7-fhir-rest, an informal string or any other code, an endpoint whose
    managing organisation is missing or not in the Bundle, an endpoint no
    organisation or two organisations operate, an id that is missing or
    repeated, and a node its endpoints disagree on. ihe-iti 0.0.8 reads the
    directory content under its mcsd feature, resolving references inside the
    Bundle as FHIR R4 §2.36.4.1 does.

  • The FROM ENDPOINT and ORGANISATION directive in AQL (#70; §8.1,
    §8.4.1, N11, N19, N20, CP-6). FROM ENDPOINT p ["node-a-pub", …] asks
    exactly the listed endpoints, and FROM ORGANISATION ["org-a"] asks every
    endpoint the registry lists as managed by each organisation. The
    identifiers are registry ids, never URLs. The directive does not replace
    resolution: each listed endpoint is still asked about its own ehr_id, a
    listed one where the patient is not known is not-resolved and fails
    nothing, and every endpoint the directive does not list is excluded. The
    directive is parsed by openehr-query's federation feature and never
    reaches a node: each node receives the same standard AQL as for the
    undirected query. An identifier the registry does not know is refused 400
    with the new codes endpoint-unknown and organisation-unknown, which
    locate it by its place in the list and never quote it, and an organisation
    that manages no endpoint answers 404 no-destination. A query directed at
    one endpoint dispatches an aggregate or a function AQL does not define
    unchanged (N14, §11.6.3). The directive's variable may only be selected, as
    an ENDPOINT attribute, which #72 adds to the rows, and any other use is
    refused 400 with the new refusal endpoint-variable. openehr-federation adds
    aql::directive::FacadeQuery, Context::with_targeting,
    Analysis::sources and ColumnSource::Endpoint. Golden case 02 now
    passes, and CP-6 is covered. The endpoint and organisation headers of §8.4
    are #71.

  • The registry maps every observed creating_system_id (#67; §12.2, N21,
    the mapping half of CP-13): a [[creating_system]] entry in the registry
    document maps a creating_system_id that is no member's own system_id to
    an endpoint, and a member's own system_id maps to that member without one.
    The document is refused, by config check too and naming the
    creating_system_id, when a mapping names an undeclared endpoint, maps one
    id twice (ASCII case aside), or maps a member's own system_id.
    ferrofed-registry adds the learned map: the first sighting of an id the
    document does not route learns a read route to the endpoint it was seen
    at, and a learned mapping never overrides the document. A sighting at a
    second node, or a learned mapping the document contradicts, withdraws it
    and raises an integrity incident, logged at ERROR with a stable kind and
    routing ids only. An id nothing routes is a typed miss, never a default
    endpoint. The follow-up read routing that consumes the table is #64.

  • Opt-in version-identity dedup (#56; §10, N15, N36, CP-9, CP-29): a request
    that sends openEHR-federation-dedup: version-identity gets one copy of a
    version held at several endpoints, keyed on the full OBJECT_VERSION_ID
    of the row's COMPOSITION, else VERSION, uid as openehr-base reads
    it. The copy kept is the one from the endpoint whose registry system_id
    is the version's creating_system_id, else the one from the lowest
    endpoint id. Two versions of one object are two rows, and a row with no
    version uid is never suppressed. meta.federation.dedup records the mode
    on every answer, none and failing 424 and 504 envelopes included,
    and beside the rows suppressed_rows and suppressed_endpoints[], counted
    before DISTINCT, OFFSET and LIMIT. Every node is asked the version
    uid, as a hidden column when the client does not select it (under
    DISTINCT only a selected uid is the key), and a query with a LIMIT and
    no ORDER BY is ordered on it. Under the mode a tie on the ORDER BY
    keys breaks on the uid before endpoint_id, which keeps a per-node
    LIMIT n (or k + n for a page) exact after suppression (§11.6.1). The
    default stays none (§10.1), which none states explicitly; any other
    value, or a repeated header, is 400 dedup-invalid. A node whose version
    uid is not an OBJECT_VERSION_ID is node-error, and the query fails
    424 under all-or-nothing. A recombined aggregate under the mode is 400
    indecomposable-aggregate (§11.6.3). openehr-federation 0.0.20 adds
    dedup::DedupMode, aql::Context::with_dedup,
    order::ResultOrder::with_version_key, merge::NodeAnswer::with_system_id,
    merge::Suppressed, merge::Disagreement::VersionId and
    aql::refusal::Indecomposable::Dedup.

  • SELECT DISTINCT at the Tier (#55; N13, CP-8, CP-32): a row two nodes
    return is answered once, compared on the columns the client selected under
    the Tier comparator (2 and 2.0 are one value, two spellings of one
    instant are two), and the duplicates are removed before the LIMIT and
    the OFFSET (AQL 1.1.0 §LIMIT), so a duplicate no longer takes two slots
    of a LIMIT n answer or of a bounded OFFSET page. The copy kept is the
    first under ORDER BY, then endpoint_id. A re-injected subject column
    and a column the gateway adds never make two rows distinct. Each
    endpoint's row_count stays what it contributed (§9.5). A node that
    returned its full LIMIT with two rows the Tier holds equal is
    node-error, because a distinct row can lie past its cut, and the query
    fails 424 under all-or-nothing. openehr-federation 0.0.18 adds
    order::ResultOrder::with_distinct and distinct, and
    merge::Disagreement::Distinct.

  • Single-node routing for the EHR area (#61; §7a.1, §7a.3, §9.6, §11.2, N22,
    N31, N33, CP-24): every ITS-REST operation under {base}/v1/ehr/{ehr_id},
    named by openehr-its's routes::lookup, is forwarded once to the node
    the openEHR-federation-endpoint header names, through Client::forward.
    The body reaches the node byte for byte, a DV_IDENTIFIER in a committed
    COMPOSITION included, and the node's status, body, Location and ETag
    come back unmodified, a 404 or 500 included. Every routed answer names
    the acting endpoint and its node's system_id in
    openEHR-federation-endpoint and openEHR-federation-system-id. Only the
    request headers and query parameters the matched ITS-REST operation
    declares travel, read from openehr-its's per-operation parameter table
    (FerroEHR #3530): If-Match reaches the node on a PUT and never on a
    GET. The client's Authorization and X-Request-Id never travel, and
    the node receives the request's minted X-Request-Id. An undeclared query
    parameter, and subject_id or subject_namespace anywhere, is refused
    before dispatch. New codes: target-required (a write that names
    no node, 400), endpoint-unknown and endpoint-several (400),
    query-parameter-refused (400), node-timeout and node-unreachable
    (504) and node-refused (424, a node that refused the gateway's onward
    credentials). A read that names no node still answers 501 until #62
    routes it. A node's 3xx is its answer, passed on with its Location
    unmodified, and no request is re-sent to a host outside the registry.

  • Decomposable aggregates (#54; §11.6.3, N14, N39, CP-10, CP-32): an
    undirected COUNT, SUM, MIN, MAX or AVG is sent to every node and
    answered with one recombined row in the client's columns, never one row
    per node. Counts and sums add exactly, reals in decimal arithmetic; MIN
    and MAX are re-applied over numbers and complete date-times; AVG is
    asked of each node as its SUM and COUNT. A node value the
    recombination cannot use is node-error, so the query fails 424 with no
    value. DISTINCT, COUNT(DISTINCT …) and a plain column beside an
    aggregate are refused 400 (indecomposable-aggregate), and a partial
    request for a recombined aggregate is refused 400 (partial-aggregate).
    The new [federation] decomposable_aggregates list (all five by default,
    [] for none) declares the functions, and an undeclared one is still
    undirected-aggregate. A directed single-node aggregate is sent unchanged.
    openehr-federation 0.0.13 adds the aggregate module,
    aql::Context::with_decomposable_aggregates and merge::combine, and
    takes rust_decimal 1.43.0, already in the tree through openehr-rm.

  • The full per-endpoint report (#49; §9.5, §11.1, N16, N40, CP-11, CP-31):
    every registry member appears in meta.federation.endpoints[], an endpoint
    a directed request did not name as excluded with the reason, which stays
    out of scope, so it neither clears complete nor fails the query. A
    [[node]] of the registry document may record its CDR product and
    version, which the report carries only from there and omits when the
    registry does not say. latency_ms appears exactly for the endpoints the
    gateway dispatched to, and row_count counts what each node contributed
    before any federation-level DISTINCT, dedup or LIMIT.

  • Completeness (#50; §11.2 to §11.4, N6, N37, CP-30): all-or-nothing stays
    the default, and a request opts into best-effort with
    openEHR-federation-completeness: partial. Under best-effort the gateway
    answers 200 with the rows of the nodes that answered, names every other
    node with its status, and sets complete: false. A cross-reference that
    cannot answer is reported there and is not a 424. all is accepted
    explicitly. Any other value, a repeated header, or partial where
    federation.best_effort = false withdraws the mode is a 400 that asks no
    node and never quotes the value. The not-resolved and consent-denied
    carve-outs and the scope rule hold in both modes. complete is always
    derived from the statuses.

  • Timeouts (#51; §11.5, N38, CP-31; RFC 7240): a client shortens the budget
    with Prefer: wait=<seconds>, and a longer wait leaves the configured
    budget in force. The effective budget is the one reported in
    meta.federation.timeout, and a wait that set it is echoed in
    Preference-Applied. Only the first wait counts; a malformed one is
    ignored and never refused. wait=0 asks no node and reports each one
    time-out. The overall budget runs from the request's arrival, so the
    patient resolution and the fan-out share it and the gateway answers inside
    it, and abandoning one node never aborts a request in flight to another.

  • ORDER BY with LIMIT re-applied at the Tier, with a deterministic
    tie-break (#52; §11.6.1, N9, N13, N39, CP-8, CP-32). Every node is sent the
    client's LIMIT n unchanged, with the row's uid appended as the last
    ORDER BY key; an ORDER BY path the query does not select travels as a
    hidden column the client never sees. The gateway merges the node answers
    under one total order (null greatest, numbers exactly, complete date-times
    by instant, strings by code point, DV_ORDERED values through the openEHR
    RM's own comparison), breaks ties on the endpoint id and then the uid, and
    cuts the result at n. A node that returned n rows out of that order, or
    more than n, is reported node-error, so the query fails 424 under
    all-or-nothing. TOP n is read as LIMIT n; TOP n BACKWARD, a TOP beside a
    LIMIT clause, and a DISTINCT query ordered on a path it does not
    select are refused 400. A query with LIMIT and no ORDER BY now returns
    at most n rows across all nodes.

  • OFFSET paging across a fan-out (#53; §11.6.2, N9, N39, CP-32). OFFSET
    never reaches a node. Under the default federation.offset_strategy = "bounded", ORDER BY … LIMIT n OFFSET k asks each node for LIMIT k + n,
    checks each node's visible order as for LIMIT n, merges in the federation
    order and returns rows k to k + n. A page whose k + n is past
    federation.max_offset_window (1000 rows per node by default; 0 refuses to
    boot), an OFFSET with no LIMIT, and an OFFSET with no ORDER BY are
    refused 400, the first naming the bound. offset_strategy = "reject"
    refuses every OFFSET past zero 400. The ITS-REST offset and fetch
    members follow the same strategy.

  • The comment-style guard checks citations (#173). It fails on a Rust
    comment, doc comment or lint reason that cites docs/architecture.md, a
    path under .claude/ or a rule file by name, or that names a
    decision-register entry such as decision A17. It applies the same check to
    the full-line # comments of the shell scripts under scripts/ and of every
    Cargo.toml. comment-style.sh --self-test proves each refused form and
    its near misses, and CI runs it before the full-tree pass. Every comment that
    cited one of these now cites the specification section it rests on, or says
    that no specification governs it.

  • The error vocabulary (#57; §11.2, N32, N36, N37, N42, CP-12, CP-30): every
    failure the gateway reports on its own behalf answers the ITS-REST Error
    body with a stable code and the request_id, and its status follows one
    table. A refused query is a 400 named by its refusal (not-aql,
    unreducible, offset-unsupported, and the rest), an unknown path a 404
    not-found, an unexposed ITS-REST area a 501 not-implemented, and the
    gateway's own fault a 500 internal, and a request with no member in
    scope a 404 no-destination; ehr-id-collision and
    controlling-system-unreachable (409) are fixed for follow-up routing.
    The codes are API and are only ever added. The book's "Errors and status
    codes" page lists them all, and a test holds the page to the gateway's
    table.

  • The versions guard holds two hand-typed version facts to their sources
    (#181). The landing page's release note and status panel must name the
    newest ## [x.y.z] release of CHANGELOG.md, so a release cut that forgets
    the page fails. Each specification row of docs/VERSIONS.md must name a
    crate constant (FEDERATION_SPEC, ITS_REST, AQL) that carries the
    version the row pins. versions.sh --self-test proves both checks, and CI
    runs it before the full pass.

  • Generated conformance badges on the README (#175), as shields.io endpoint
    files under conformance/badges/: the Gateway points of §17 covered out of
    the Gateway total, the Node and Operator point counts, each labelled with the
    pinned specification version and linked to the book's conformance page, and
    the AQL golden cases passed out of the vendored corpus, linked to its pass
    list. scripts/conformance/matrix.sh --badges-write writes the files and the
    README block between conformance:begin and conformance:end, and the
    conformance-matrix guard fails when either drifts from the matrix or the
    pass list. The golden test fails when a case in
    conformance/aql-golden/pass-list.txt stops passing or an unlisted case
    passes, and rewrites the list when FERROFED_CONFORMANCE_UPDATE is 1.
    Case 02 (FROM ENDPOINT) is refused until #70 and is not counted as
    passing. The static badge row gains the image-pulls badge for
    ghcr.io/ferrohealth/ferrofed.

  • The versions guard holds the book's "Pinned versions" page to
    docs/VERSIONS.md (#198). Each row of its pin table names its matrix rows
    by their exact names, and every pin it states, a version, a package, an
    edition 2024 or an abbreviated commit, must be the one those rows pin;
    a pin the guard cannot read fails rather than passing unread. The page now
    lists all five openehr-* crates, the vendored PIXm and PDQm packages and
    the specification's source commit as rows of their own. versions.sh --self-test proves each drift.

  • Tests that pin the two optional facilities FerroFED does not offer (#59,
    #60; §11.6.4, §11.7, CP-31, CP-32). A query sent with Prefer: respond-async, alone or beside Prefer: wait, gets the ordinary
    synchronous answer under the same budget: never a 202 or a
    Content-Location, and Preference-Applied never names respond-async
    (RFC 7240 §2, §3). A bounded OFFSET page carries no cursor handle or
    expiry in meta.federation and runs the fan-out on every request.

Changed

  • A gateway with registry.document set now refuses to boot without
    federation.id, the federation's own identifier that OPTIONS {base}/
    names (#73; §7a.2, N30). Add id = "<your federation>" to [federation];
    an empty id is refused.

  • The openehr-* family moves from 0.0.78 to 0.0.79 (#234). The
    openehr-rm attribute model now holds the BASE primitives, the Ordered
    marker and the target class of a reference-typed attribute (FerroEHR
    #3537), which the AQL rewrite reads to decide which selected paths a node
    can order under DISTINCT. openehr-federation is 0.0.28.

  • The openehr-* family moves from 0.0.77 to 0.0.78 (#237), and an onward
    credential is checked at configuration load by the node client's own
    Authorization composition (FerroEHR #3535), for each endpoint and each
    PIX Manager alike. A bearer token must now be the b64token of RFC 6750
    §2.1 (letters, digits, -, ., _, ~, + and /, then any =
    padding), so a token holding a space, a quote or any other character
    outside that set is refused by config check and at boot with exit code
    78, naming its key and never its value; it used to pass and then fail at
    the node. The basic rules of RFC 7617 §2 are unchanged.
    openehr-federation is 0.0.26.

  • The openehr-* family moves from 0.0.76 to 0.0.77 (#195). openehr-query
    now classifies every AQL function call (FerroEHR #3529): a string, numeric,
    or date and time function of AQL 1.1.0 is a built-in, and any other name is
    not. The rewrite reads that classification where it kept a list of the 16
    function names of its own, so an undirected query that calls a function
    AQL does not define is refused exactly as before (undefined-function;
    §11.6.3, N14), and the CONCAT, CONCAT_WS and SUBSTRING folding and
    the DISTINCT cut check read the crate's classification too. Every
    openehr-its client is built with redirects off (FerroEHR #3531), so a
    node's 3xx answer is never followed: the node is node-error.

  • Incompleteness is carried by meta.federation.complete alone, and the
    gateway emits no FHIR OperationOutcome (#58; §9.1, §11.4, N17, CP-12).
    §11.4, CP-12 and track 4 ask for the resource only for a FHIR-facing
    consumer, and the answer is an ITS-REST RESULT_SET whose federation
    additions live under meta.federation. The conformance matrix gives CP-12
    that reason beside its scored status codes, a test asserts that a
    best-effort 200 and an all-or-nothing 424 each carry complete: false,
    validate against federated-result-set.schema.json and hold no
    OperationOutcome, and the client contract in the book says so. Where the
    resource would travel for a gateway with FHIR-facing consumers is recorded
    as an upstream report (#204).

  • The conformance record's loose ends (#196). The comment-style citation
    checks read the conformance tables under conformance/: their # comment
    lines and their reason column, which the book renders. The tables now
    cite §16.2, §16.3, §16.4 and §17 where they cited an internal file or a
    decision-register entry, or say that no specification governs the choice.
    The CP-10 and CP-32 rows name #187. The book page links the specification
    site at the version docs/VERSIONS.md pins, with no second copy of it in
    scripts/conformance/matrix.sh. openehr-federation 0.0.16 marks its
    local list of AQL function names for removal (#195).

  • The comment-style citation checks cover the rest of the tree (#178): the
    full-line comments of the workflow and composite-action YAML under
    .github/, the echo and printf text of a workflow run: block,
    clippy.toml with its reason strings, and the quickstart TOML under
    docker/. The per-edit hook runs the guard on every file kind CI checks.
    Every comment, lint reason and printed line there that cited an internal
    file or a decision-register entry now cites the specification section or
    official documentation it rests on, or says that no specification governs
    it. The six vendor scripts no longer write an internal path into their
    PROVENANCE.md.

  • The comment-style citation checks refuse a citation of any markdown file
    under docs/ outside the vendored docs/specs/ tree (#184): a path that
    opens a parenthetical or is followed by section or §. Naming
    docs/VERSIONS.md as the file a script or test reads still passes. The
    checks also read every YAML description: scalar and the trailing #
    comment after a YAML or TOML value, outside quoted strings and block
    scalars, and the guard runs the same under mawk. The citations this
    catches cite the GitHub documentation or say that no specification
    governs them, among them fuzz.yml, the setup-rust action
    description, the version and corpus scripts and the CI rule. The crate
    manifests' comment on the 0.0.0 name reservation changes with them, so
    openehr-federation moves to 0.0.15, ihe-iti to 0.0.7 and
    nl-generic-functions to 0.0.4 with no change to their code.

  • The comment-style citation checks also refuse a citation of a
    README.md of the tree, at the root or in a member, outside the vendored
    docs/specs/ and vendor/ trees (#198), in the same citation form. Naming
    a README as a file a script reads still passes. fuzz.yml cites the
    libFuzzer documentation for how a crashing input is kept, and the scripts
    that cited a README no longer do.

  • The release lane fails a tag whose tree has no root Cargo.toml (#198),
    with a message saying the tag cannot be checked against the workspace
    version. It used to skip that check.

  • The release lane has one path (#201): the binaries and the image build for
    every tag plan accepts, and finalize-release publishes only when every
    build leg succeeded. The branches for a tag with no workspace, which plan
    now refuses, are gone. The book's claims page links to the pinned-versions
    page for the openehr-* pin instead of restating the number, and
    docs/ci-cd.md names all four self-tests of the tracker-helpers job,
    rel.sh among them.

  • scripts/gh/fields.sh checks its arguments before any gh call (#198).
    No argument, an unknown command, a wrong operand count or --help prints
    the usage to stderr and exits 2, with no network call and no token needed.
    fields.sh --self-test proves it against a stub that records every call.

  • The per-edit comment-style hook also reads the conformance/*.tsv
    tables (#198), as CI does, and stays a quiet pass for any other file.
    Conformance track 5 (Dedup + DISTINCT) names #187 beside its issues, as its
    CP-10 and CP-32 rows do, and the book's conformance page is re-rendered.

  • Error bodies (#57): the gateway's own refusals (404, 501, a caught
    panic's 500) answer the ITS-REST Error shape with code and
    request_id, where they named the code in an error member, and the codes
    are kebab-case (not-found, not-implemented). No error body quotes the
    query, a parameter value or a header value (§5.4.3); a 424 or 504 under
    all-or-nothing stays the §11.4 result set and echoes the client's own q
    (N17). A node row shorter than the dispatched query selects is now that
    node's node-error (424, or reported under partial), where the whole
    query answered 502 (§11.1, §11.2).

  • The openehr-* family moves from 0.0.74 to 0.0.76 (#57). 0.0.76 keeps the
    members an open ITS-REST schema admits in an additional_properties map
    (FerroEHR #3526), so every error body is the generated ITS-REST Error
    with code and request_id in that map, and no FerroFED-side error type
    remains. The generated request and result-set types carry the same map.

  • The site, the README and the book describe the released gateway (#176).
    The landing page opens with the FerroFED mark and the current release, and
    shows the quickstart, the release binaries and the image
    ghcr.io/ferrohealth/ferrofed, with what each specification property has
    in v0.0.3 and what is planned. The README's install section names the
    release assets and their verification, and its quickstart runs the
    published image with docker compose up --wait. The book's introduction,
    claims, deployment, container and contribution pages, the governance and
    contribution guides, and the working rules no longer describe a design
    phase or a project with nothing to install.

  • The quickstart and the end-to-end harness run two FerroEHR nodes (#155,
    decision A44). EHRbase left both, because it refuses a . in
    PARTY_REF.namespace, which openEHR BASE admits (#118). In compose.yaml
    the services are ferroehr-a and ferroehr-b, each on its own database
    and with its own system_id (node-a.quickstart.local,
    node-b.quickstart.local, which the quickstart registry declares), and
    node B moved from port 8091 to 8082 (FERROEHR_B_PORT; node A's
    FERROEHR_PORT is now FERROEHR_A_PORT). Every end-to-end case seeds the
    patient's subject on both nodes, and the e2e (containers) CI job now runs
    the server's container tests as well as the testkit's.

  • The tracker records the kind, the urgency and the size of an issue in
    GitHub's native issue type (Bug, Feature, Task) and the organisation's
    Priority and Effort fields (#154). The bug, enhancement and P0 to
    P3 labels are retired, the bug and feature forms set the issue type, and
    scripts/gh/fields.sh and scripts/gh/migrate-fields.sh carry the model
    and the migration.

Fixed

  • The local crate-bump hook judges a commit or a push against origin/main,
    the base CI uses, and leaves finding the merge base to
    scripts/checks/crate-version-guard.sh (#243). A branch that bumps a crate
    to the version main already took is now blocked locally, as CI refuses it,
    where it used to pass. The book's checks page lists the tracker-helpers
    job, the self-tests of the scripts/gh helpers, among the tier-1 checks.
  • The outbound gate no longer reads the host and port of a registry
    endpoint URL (#232; §5.4.1, N33, CP-26). An endpoint whose host or port
    contained a withheld identifier, such as a short local identifier that
    matched a port number, refused every query for that patient. The
    authority comes from the operator's registry and never from a request,
    and §5.4.1 names the request path, the query string and the headers. The
    gate still reads the path, the query and the fragment, raw and
    percent-decoded, with the AQL text, the paging and the headers the
    gateway adds.
  • A SELECT DISTINCT query with ORDER BY and LIMIT (or a bounded
    OFFSET page) that selects a path whose value AQL defines no order for is
    refused 400 (incomparable-distinct-key), where a node could cut among
    distinct rows differently on each repeat (#234; §11.6.1, N13, CP-8,
    CP-32). AQL orders on data "available to primitives and Ordered types"
    (AQL master03-syntax §ORDER BY), so a whole RM object (SELECT DISTINCT c), a data value that is not a DV_ORDERED (c/name, a DV_TEXT), the
    DATA_VALUE of an ELEMENT, a collection and a path the RM does not
    resolve are not keys. The rewrite decides it by walking the path through
    openehr-rm's static model from the class FROM binds, asking the
    model's is_primitive and conforms_to_ordered lookups and following a
    reference to the target class the model names (FerroEHR #3537). Under
    DISTINCT only the selected paths to a primitive or a DV_ORDERED are
    pushed as ORDER BY keys, so the same query without a LIMIT is answered
    and the other paths are compared at the Tier alone. A path to a primitive
    value, such as c/name/value or …/value/magnitude, and an ordered data
    value, such as c/context/start_time, are pinned as before. The aql
    feature of openehr-federation now depends on openehr-rm; the crate is
    0.0.28 and adds Refusal::IncomparableDistinctKey.
  • A query with ORDER BY and LIMIT (or TOP, the fetch member, or a
    bounded OFFSET page) ordered on a path whose value AQL defines no order
    for is refused 400 with the new code incomparable-order-key, where it
    used to be sent to every node and re-ordered at the Tier unchecked (#242;
    §11.6.1, N39, CP-32). AQL ordering "assumes that data identified by the
    path … are comparable", the primitives and Ordered types (AQL
    master03-syntax §ORDER BY), and §11.6.1 holds the federated first n
    rows in each node's first n only "under a total order", so a node's cut
    on ORDER BY c/name LIMIT 10, a DV_TEXT, could drop a row of the
    answer. The rewrite uses the comparability rule of #234, and the
    refusal points at the key by position. The same order with no LIMIT is
    answered: every row reaches the Tier, which orders them all under its own
    total order, the same on every repeat. Under DISTINCT the refusal stays
    incomparable-distinct-key. openehr-federation is 0.0.29 and adds
    Refusal::IncomparableOrderKey.
  • Under version-identity dedup, SELECT DISTINCT compares the version uid
    without regard to case, as the dedup and the Tier order already do (#234;
    §10.2, CP-8, CP-9; BASE master05-identification_package.adoc
    §"Composite Identifiers and Case"; AQL master03-syntax §DISTINCT). Two
    rows of one node whose uids differ only in case are one distinct row, the
    first in the Tier order, with its text as the node sent it; a node cut at
    its LIMIT that returns both is refused as node-error. Outside dedup a
    uid compares as sent, in step with the Tier order.
  • A SELECT DISTINCT query with ORDER BY and LIMIT (or a bounded
    OFFSET page) whose selected function column the selected paths do not
    fix is refused 400 (unordered-distinct-cut), where a node could cut
    among distinct rows tied on every path differently on each repeat (#210;
    §11.6.1, CP-8, CP-32). AQL orders only on paths (AQL master03-syntax
    §ORDER BY), and under DISTINCT the gateway adds no column, so the
    selected paths are a node's only keys. A call to a single-row function AQL
    defines whose arguments are literals, parameters and selected paths, such
    as LENGTH(c/name/value) beside c/name/value, is answered as before; a
    call that reads a path the query does not select, a clock function such as
    NOW(), and TERMINOLOGY are refused. The same query without a LIMIT
    is answered. openehr-federation 0.0.22 adds
    Refusal::UnorderedDistinctCut.
  • Under version-identity dedup, the Tier orders the version uid without
    regard to case (#229; §10.2, §11.6.1, §11.6.2, CP-9, CP-32; BASE
    master05-identification_package.adoc §"Composite Identifiers and
    Case"). Two copies of one version whose ids differ only in case rank as
    one in the merged order and in the check of each node's order, as an
    ORDER BY key and as the tie-break, so an ORDER BY with LIMIT and a
    page under dedup stay exact for them. Every row keeps its uid as its node
    sent it. The comparison is openehr-base's composite_id_key. A node cut
    at its LIMIT that orders uids byte for byte and returns them in an order
    the Tier's disagrees with is refused as node-error, since the Tier
    cannot change how a node sorts. Outside dedup the uid orders as before, by
    code point. openehr-federation is 0.0.23.
  • An onward credential the Authorization header cannot carry is refused at
    configuration load, by config check and at boot with exit code 78, in
    place of failing every query to its endpoint with a 500 (#231). A bearer
    token, inline or read from bearer_token_file, is checked as the
    Bearer <token> value the node client sends, by the same http parse the
    client applies, and a basic user or password holding a control character,
    or a basic user holding a colon, is refused as RFC 7617 §2 requires. The
    refusal names the key the value came from
    (credentials.<endpoint>.bearer_token, its _file sibling, .user or
    .password) and never the value. A PIX Manager's
    [pixm.manager.credentials] is held to the same rule.
  • Version-identity dedup compares identifiers without regard to case (#225;
    §10.2, CP-9; BASE master05-identification_package.adoc §"Composite
    Identifiers and Case"). Two copies whose version ids differ only in case
    are one version, and the node whose system_id differs from the version's
    creating_system_id only in case keeps the originating copy. Before, both
    were compared byte for byte. A kept row still comes back as its node sent
    it. The comparison is openehr-base's composite_ids_equal and
    composite_id_key, which the registry identifiers already use, and the
    session-scoped resolution bindings now key on EhrId in place of their
    own case fold. openehr-federation is 0.0.21.
  • A patient query is no longer refused at random when its withheld
    identifier is a short hexadecimal value that occurs inside the minted
    X-Request-Id (#227; §5.4.1, N33, CP-26). The outbound gate reads the AQL
    text, the paging, the URL and every other header the gateway adds, and
    skips the minted id, which OutboundId::mint makes from no client input.
    A request a panic, the request timeout or the body ceiling answers now has
    its request-log line, with the status it answered (500, 408, 413)
    under the gateway's id. The "the federated query failed" line carries the
    same request_id as the request line. The hygiene assertions of the tests
    compare the raw bytes a mock node received, so a header value or a body
    that is not UTF-8 is searched too, and the CP-26 row of the conformance
    matrix names #217.
  • CP-29 is planned again in the conformance matrix and the gateway badge
    (#221): only its visibility half, the dedup record of §10.2 and §10.3, is
    built, and its write-routing and 409 half is #66. The dedup tests carry
    CP-9 alone. scripts/checks/crate-version-guard.sh run without its base
    ref prints its usage on stderr and exits 2.
  • The book's "What FerroFED claims" page lists decomposable aggregates (#54)
    among what has merged since v0.0.3, and no longer as planned; de-duplication
    is what remains planned for v0.0.4 (#211). The merge module doc of
    openehr-federation is wrapped like the rest of the crate's docs, and
    openehr-federation moves to 0.0.19 with no change to its code.
  • An ORDER BY with LIMIT query whose rows carry no uid, such as one that
    selects from EHR alone, returns the same rows on every repeat (#157;
    §11.6.1, §11.6.2, N39, CP-32). Each node is asked to order on a row key
    after the client's keys: the uid of the first COMPOSITION or VERSION
    as before, else <ehr>/ehr_id/value, else the uid of an EHR_STATUS or
    EHR_ACCESS; the key travels as a hidden column and never reaches
    columns[]. A node cut at LIMIT n, or at the LIMIT k + n of a bounded
    OFFSET page, then keeps the same tied rows each time, so two pages agree
    on the rows tied across their edge. A patient query, scoped to one
    ehr_id per node, gets no EHR key; FOLDER is never a key; a
    DISTINCT query keeps its selected columns as the tie-break and gains no
    column. A query with no row key is still answered: the Tier orders the
    rows it receives, and which tied rows a node returns is the node's.
    openehr-federation 0.0.17 carries the change.
  • A query that calls a function AQL 1.1.0 does not define, such as a
    product-specific MEDIAN(x), in SELECT or WHERE is refused 400
    (undefined-function) when it would reach more than one node, where it was
    sent to every node and answered one row per node (#187; §11.6.3, N14, N39,
    CP-10, CP-32). The gateway cannot tell whether such a function aggregates,
    and per-node aggregate rows are the answer §11.6.3 forbids. Directed to one
    endpoint, the query is sent unchanged. The single-row functions AQL defines
    (AQL §Functions: the string, numeric, and date and time functions, and
    TERMINOLOGY) are still sent to every node as written, and the five
    aggregates keep the decomposition rules. openehr-federation 0.0.14 adds
    aql::refusal::Refusal::UndefinedFunction.
  • A query that uses TOP together with a LIMIT clause is refused 400
    (top-with-limit), whether or not the two counts agree, because AQL
    forbids the pair (#162; AQL §TOP, §LIMIT). A TOP query sent with the
    ITS-REST fetch member is refused 400 (top-with-fetch), because
    ITS-REST says fetch "cannot be combined with AQL-top". TOP n alone is
    still read as LIMIT n.
  • A federated query that leaves no registry member in scope, because every
    endpoint is excluded (suspended by the operator, for example), answers
    404 and asks no node, where it answered 200 with empty rows (#166;
    §11.1, §11.2, §11.3). A candidate set that localization left empty is not
    that case: every member is not-localized, and the answer stays 200
    with complete: true (§14.1). A patient who is not-resolved at every
    member in scope still answers 200.
  • A gateway that federates refuses to boot, and config check refuses the
    file, unless server.request_timeout_ms exceeds
    federation.overall_timeout_ms by more than one second, the margin kept
    for combining the answers (#167; §11.5). The refusal names both keys.
    Before, a request timeout between the two let the server's 408, with no
    body, cut a slow fan-out instead of the 504 that carries
    meta.federation.
  • The documentation describes the gateway that exists (#163). The
    openehr-federation README names both subject carriers as resolution
    input. The book's client contract and configuration pages name
    POST /v1/query/aql as served, and the _file secrets paragraph sits
    under the configuration file it describes. Code and doc comments cite the
    specification sections a decision rests on, or say that no specification
    governs it.
  • The landing page no longer scrolls sideways on a phone narrower than about
    380 px (#182). The audience cards and the other card grids take a column
    minimum that shrinks to the page width, so a 320 px viewport shows one
    full-width column in the light and dark themes.
  • llms.txt describes follow-up routing to the owning CDR as planned for
    v0.0.5, where it described it as built (#180). The CI log, the guard
    scripts, the analyzer configuration and the research label no longer
    describe the repository as being in a design phase.
  • scripts/gh/labels.sh prints its usage and exits 2 on any argument it
    does not know, --help included, before it calls gh (#180). It ignored
    such an argument and wrote the label taxonomy to the repository. Its
    --self-test proves the refusal.
  • The ferrofed-engine crate description names what it holds (dispatch,
    fan-out, the budgets, the completeness decision and the outbound
    identifier-hygiene gate) and lists follow-up routing on the creating
    system id as planned, where it described routing as built (#191). The
    workflows, the release checklist, the version pages of the book and the
    repository, and the working rules no longer describe the Cargo workspace
    as not yet existing, and the book names the openehr-* pin as 0.0.76 and
    the PIXm and PDQm packages as vendored.
  • scripts/gh/rel.sh prints its usage and exits 2 before it calls gh on
    a usage error: no argument, an unknown command, a wrong operand count, a
    flag other than --replace, or --help (#191). It exited 1 with no
    argument, 0 on --help, and resolved the repository through gh
    first. Its new --self-test, run by the CI tracker-helpers job, proves
    each write's endpoint and each refusal.

Security

  • The ask-all probe no longer sends a path ehr_id that is not a bare UUID
    to every member (#259; §5.4.1, §12.5.1, N33, CP-26). The path ehr_id was
    checked only as a HIER_OBJECT_ID, a form that admits a bare national
    number as a one-arc ISO OID, so a patient identifier in the ehr_id slot
    of a read nothing routed was broadcast to the whole federation. A read
    whose ehr_id is an ISO OID, an internet id or a UUID with an extension,
    and that no targeting header, resolution binding or ehr_id index entry
    routes, is now refused with the new code probe-requires-uuid (400),
    and no member is asked. The same ehr_id with the endpoint header is
    forwarded to the named node alone, and the index then routes it, since a
    member may mint ehr_ids in a scheme other than UUID (§12b.2, N42a).
    The refusal is logged as the security event ehr-id-probe-refused, which
    never names the ehr_id. Track 10 gains the identifier in the ehr_id
    slot. ferrofed-registry adds EhrId::is_uuid, and ferrofed-engine's
    probe::Probe carries a ProbedEhrId, which only a bare UUID converts
    to, in place of the client's path segment.
  • A panic no longer prints its message to stderr (#260; §5.4.3, CP-26).
    ferrofed serve used Rust's default panic hook, which printed the
    message, and any value formatted into it, to stderr, part of a
    deployment's log stream. The binary now installs its own hook before it
    serves. The hook writes one fixed line through tracing, "a thread
    panicked", with the source location and, inside a request, the gateway's
    request_id, and never the message. It writes nothing to stderr. A
    panicking handler still answers 500, and its "the request handler
    panicked" line is unchanged. Track 10's panic case now runs in a child
    process and asserts that its stderr is empty. ferrofed-server adds
    panic::install_hook and request_id::serving.
  • A request routed to one node no longer logs the client's X-Request-Id
    (#62; §5.4.1, §5.4.3, N33, CP-26). The security events of a refused query
    parameter or a withheld request, and the failure events of the routed path
    and the ask-all probe, named the client's own id, free text that can carry
    a patient identifier. Every event of the routed path now names the
    gateway's own id, the one its request line records; the client's id stays
    in the response and its error body only.
  • A client's X-Request-Id no longer reaches any node (#217; §5.4.1, N33,
    CP-26). A legal client value was sent to every node of the fan-out as it
    came, so a patient identifier written into it passed the outbound gate,
    which checks only the identifiers the gateway resolved on. The gateway now
    mints its own id, a version 4 UUID, for every request and sends only that,
    the same id to every node of one request. The response header and the
    error bodies still name the client's own id. The request log, the security
    events and the panic line record the gateway's id and never the client's,
    with a client_named flag on the request line saying whether the client
    sent one; a client that sends none gets the gateway's id back. The book
    lists every header a node request carries and where its value comes from.
    ferrofed-engine adds outbound_id::OutboundId, which only
    OutboundId::mint makes, and DispatchOptions::with_request_id and
    fanout::fan_out take one in place of a string.