Repository navigation
0.0.7
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.tomlandregistry.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 markedEDIT), put each credential in a file under
secrets/, and rundocker compose up --wait. The compose file runs the
publishedghcr.io/ferrohealth/ferrofedimage at the release's version and
mounts the two files andsecrets/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,
theferrofed healthcheckprobe and theunless-stoppedrestart 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
jobrelease composerenders the compose file and runsferrofed config checkover the two examples as attached. The repository'scompose.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}/distributeon 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 newmeta.registrymember saysheld, where a
first distribution's saysstored. It is404for a version the
registry does not hold,400for a request naming no member, a body, a
deployment without distribution or an endpoint-targeted definition, and
405with an emptyAllowat a read-only registry. Without the admin
listener the action does not exist. Every secondPUTof a held version
stays409(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 bykind
(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 byresult,appliedorrefused
(ferrofed_registry_reloads_total).[metrics] listenserves the
Prometheus text exposition atGET /metricson an admin listener of its
own, never the gateway's listener, and[metrics] otlp_endpointpushes the
same metrics to an OTLP gRPC collector; both are off by default.serve
andconfig checkrefuse a listener on a non-loopback address unless
[metrics] allow_remote = trueis set, a listener onserver.listen, and
a collector that is nohttp://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-servercargo 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 withINSERT … ON CONFLICT DO NOTHING
stores exactly one of two racingPUTs 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,urlorurl_file, TLS is rustls with the
platform's roots, and the schemaferrofedand its table are created when
absent.filesis read-only: one file per
{qualified_query_name}/{version}.aql, loaded and admitted at start; a
PUTis a405with the new codestored-query-read-onlyandAllow: GET, OPTIONS,OPTIONSlists noPUT, and a malformed file refuses the
start andconfig 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 theehr_idbefore anything is sent (#289; §12.4, §12.5.2, N23,
N42, CP-15; ITS-REST 1.1.0ehr_create_with_id). When a resolution
binding of the session or theehr_idindex places theehr_idat a
member other than the one the targeting headers name, the create is a
409with the new codeehr-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 theehr_id. A create at
the member that holds theehr_idis forwarded, so that node answers its
own409, and anehr_idnothing 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_queriesbeside the registry (#78; §12.7, N44, N43, CP-40).
Off by default, refused byconfig checkand at start without
[stored_queries], and declared inOPTIONS {base}/as
definition.stored_query_fan_out,trueonly beside the registry. Where
it is on, a stored-queryPUTwhoseopenEHR-federation-endpointis*
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
StoredQueryandmeta.federationon the template fan-out's statuses:
200,207withcompletefalse, or424and504, the registry
holding the definition whatever the members answered and nothing rolled
back. A definition carrying aFROM ENDPOINTorORGANISATIONdirective
is refused for distribution with400 definition-endpoint-targeted
before anything is stored, and stays storable and executable federated.
AGETof a version naming members reports drift per member:active
where its copy is the same query, andnode-errorwith
error.codedefinition-differsordefinition-missingotherwise,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
PUTor versionGETcarryingopenEHR-federation-endpointor
openEHR-federation-organisationis 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-endpointlists the endpoints that
contributed rows andopenEHR-federation-system-idtheir 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 answeredactivewith arow_countabove 0; one that answered no
rows, failed or was never asked is not listed, an answer that fails lists
none, andmeta.federation.endpoints[]still names every endpoint (§11.1).
The headers carry registry ids andsystem_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, andOPTIONS {base}/declares
the configured value asdefinition.fan_out_template_upload. Where it is
on, an ADL 1.4 or ADL 2 template upload whoseopenEHR-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.federationwith oneendpoints[]entry per registry member and no
node's body:200when every named member accepted,207with
complete: falsewhen some failed, and424or504when none accepted.
The provenance headers list the members that accepted. A plain upload still
names its one node, and*staysendpoint-unknownon every other
definition request and whenever the setting is off. -
ferrofed serveopens 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 andopenehr-*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.
Underprofile = "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 theprettyrendering, so a
log collector reading JSON never receives it, andconfig check,
healthcheckandadmission checkprint none. It never shows a
credential, a URL, a header value or request data. Each pin comes from the
crate constantscripts/checks/versions.shholds todocs/VERSIONS.md,
and theopenehr-*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 theprettylog and the startup banner,
whatever the format and the terminal. -
The ITS-REST
GETforms of query execution are served (#287; N1, CP-1).
GET {base}/v1/query/aqland, where the stored-query registry is offered,
GET {base}/v1/query/{name}[/{version}]carry the request in the query
string:q(ad hoc only),offsetandfetchby name, and every other pair
aquery_parametersmember, so a storedGETbindsq=…to$q. The
generatedopenehr-itsparameters decode it (FerroEHR #3540), a+is a
literal plus, and the request runs the pipeline of thePOSTform
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_idis dropped, as from aPOSTbody. A query string the decoder
refuses is400 body-invalidand asks nobody; anullparameter is
400 parameter-invalid, as in a body.OPTIONSon both paths listsGET,
and the request log records neitherqnor 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/answers501and no node is asked. The new
[federation] demographic_endpointsetting declares the one registry
endpoint that serves the area. A DEMOGRAPHIC request names that endpoint
inopenEHR-federation-endpointand 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 withopenEHR-federation-endpointand
openEHR-federation-system-id. The setting is never applied as a default:
a request naming no endpoint is400 target-requiredand no node is
asked, one naming another endpoint is400 targeting-conflict, and
several endpoints,*or an unknown id are refused as on any routed
request.config checkrefuses an id the registry does not hold, and the
setting withoutregistry.document.OPTIONS {base}/declares
its_rest.demographicasunsupported: 501, or asrouted-single-node
naming the endpoint a request names, andOPTIONSon 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, its404or validation400included,
comes back as the node sent it withopenEHR-federation-endpointand
openEHR-federation-system-id. Without a header the request is
400 target-required;*or an unknown id is400 endpoint-unknownand
two endpoints are400 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-queryPUTs are routed, the
versionedPUT {base}/v1/definition/query/{name}/{version}included
(#298), andOPTIONSon either path listsPUT.
OPTIONS {base}/declaresits_rest.definitionasrouted-single-node,
andOPTIONSon 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,--countfrom 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,failorcannot-checkwith its evidence:ehr_idgeneration
(a version-4 UUID, and noehr_idissued twice),system_iduniqueness
(thesystem_idthe node reports is the one the registry records, which
the registry holds unique), and theehr_idexchange (the configured
cross-reference maps each subject to theehr_idthe node created, or
cannot-checkwhere the gateway cannot write to it). Reuse across
restores and adoption of foreignehr_ids are alwayscannot-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 exits0when no condition failed,1when 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 servereads 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 learnedcreating_system_idroute the new document
maps elsewhere is withdrawn with aRegisteredCreatingSystemConflict
incident, andehr_idindex 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 theERRORline names
the failure class and the files, never their content.config checkis
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 ownehr_id, with no query string, and returns the node's answer
unmodified withopenEHR-federation-endpointand
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
code409 subject-severallisting the endpoints, unless
openEHR-federation-endpointnames one of them; a subject no member holds
is the operation's own404 no-destination; a cross-reference that cannot
answer is the new424 resolution-unavailable.OPTIONS {base}/v1/ehr
now listsGET. -
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 answers404. No prefix is reserved, and
/rest/openehris 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
Locationstill 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 oneehr_idgoes only to the
member that owns it, found as a pathehr_idis (§12.5.1, N41): the
session's binding, theehr_idindex, then the ask-all probe. Every other
member is reportedexcluded, the answer names the acting endpoint in
openEHR-federation-endpoint(N31), two claimants are409 ehr-id-collisionwith neither queried (N42), anehr_idno member holds is
404 no-destination, and one that is not a bare UUID and no earlier step
routes is400 probe-requires-uuidwith no member asked (§5.4.1, N33).
A query directed byFROM ENDPOINTor 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, theEHR_STATUSor the directory, a directory delete (each
naming its preceding version inIf-Match) and a composition delete
(naming it in the path) route by the pathehr_idas before, and are sent
only when the registry maps the preceding version'screating_system_id
to that same node, as the member's ownsystem_idor 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 (noIf-Match, several,*, a weak or unquoted
tag, or noOBJECT_VERSION_ID) is refused with the new code
400 preceding-version-invalid.POST {base}/v1/ehris now served, and it
andPUT {base}/v1/ehr/{ehr_id}go only to the one endpoint the targeting
headers name: without them the request is400 target-required, and with
several endpoints400 endpoint-several; neither a binding nor the index
routes a new EHR.OPTIONS {base}/v1/ehranswersPOST, OPTIONS. -
A
CONTRIBUTIONthat 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}/contributionroutes by its pathehr_idas
before, and the gateway now reads the body with the ITS-REST
NewContributiontype: each version'spreceding_version_uidmust be one
the registry maps to that node, by the rule above, or the request is
refused409 controlling-system-unreachableand no node is sent it. A
CONTRIBUTIONof creations alone routes as before. A body that is no
CONTRIBUTIONin the representation itsContent-Typeselects, a
preceding_version_uidthat is noOBJECT_VERSION_ID, or an XML body, is
refused400 preceding-version-invalid. The node receives the body byte
for byte. -
A
CONTRIBUTIONwhose versions'datais 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+jsonor
application/openehr.wt.structured+jsonthe envelope stays canonical
(ITS-REST 1.1.0contribution_create), and the gateway reads it with the
genericNewContributionofopenehr-its0.0.80 for each version's
preceding_version_uid, leavingdatato the node. ACONTRIBUTIONof
creations alone goes by its pathehr_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-unreachableand no node is sent it. The node
receives the body byte for byte, under the declared media type. A
CONTRIBUTIONin canonical XML is still refused
400 preceding-version-invalid(#308), now with a message that names the
XML form. -
Integrity incidents for an
ehr_idtwo members claim (#63; §12.5.2,
§12b.2, N42, CP-33). A request whoseehr_idthe session's resolution
bindings, theehr_idindex or the ask-all probe finds at two members or
more is refused409 ehr-id-collisionlisting the claiming endpoints, and
the gateway logs oneEhrIdCollisionincident: anERRORline under the
new log targetferrofed::integritywith theehr_id, the routing step
that found the claimants (detection) and the claimants' endpoint ids.
When the index learns anehr_idit already holds at another member, it
raises the index-insert alarm of §12b.2 once, anIndexInsertCollision
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 anehr_idonly 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 bykind. -
The obligations checklist (#278):
conformance/obligations.tsvholds 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
evidencecells of the conformance tables as it reads theirreason
cells. -
Container readiness (#303).
ferrofed healthcheckasks the gateway on this
host forGET /health/readinessover loopback, prints one line and exits
0only on200,1otherwise; the image carries
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3
over it, and thecompose.yamlgateway service the same healthcheck.
Readiness answers503until boot completes and from the momentSIGTERM
orSIGINTarrives, 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 newGET /health/dependenciesalways answers
200with 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-pinnedkubeconform
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 onSIGTERMand reloads onSIGHUPthrough Unix
signals, and every release binary and the container image are Linux. The
book says so under Operate, and buildingferrofed-serverfor a non-Unix
target, such as Windows, stops at acompile_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 requiredconclusion, assembles the site as the Docs
workflow publishes it and runs lychee 0.24.2, pinned by version and
SHA-256, with--offline --include-fragmentsover every page, and over
README.mdagainst 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.shruns the same check locally, with a
self-test, andscripts/site/assemble.shtakesSITE_ROADMAP=offto leave
the roadmap block unrendered.docs/VERSIONS.mdpins 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/, andOPTIONS {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
OPTIONSon an unserved path. The errors test now sends each of them. - The quickstart's
compose.yamlruns only the published
ghcr.io/ferrohealth/ferrofedimage: itsbuild:block is gone, so
docker compose up --buildno longer builds the gateway, and
scripts/checks/release-compose.shfails when any compose file in the
repository carriesbuild:(#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, sodocker compose downno 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 onmain(#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 reportednode-erroruntil
#83. Two pages are new: Identity resolution, covering[pixm],[dev],
profileandfederation.default_namespace, and Health probes, moved out
of the container page.Prefer: waitis 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_secondstimes it beside its count in
ferrofed_node_requests_total, and a probe still waiting when the overall
budget runs out is atime-outtimed to that moment. The book's Metrics
page lists which calls eachoutcomeand 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). Anode-errorcarries 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 anofflineortime-outnode
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
AVGover integers answers an integer (#309; AQL 1.1.0
§3.9.1.5, "it will also determine the return type"). When every node's
SUMis 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,
so5 / 2is2and7 / 2is4; 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'sSUMis a real,AVGis the
decimal mean written as the nearest JSON number, as before.
openehr-federationis 0.0.33. - The
openehr-*family moves to 0.0.81 (FerroEHR #3548, #3551, #3552), and
openehr-federationto 0.0.35 with it (#329). - A body routed to one node without a
Content-Typetravels with the first
media type the operation lists,application/jsonwherever ITS-REST lists
several, where it was a415for an operation whose body is declared in
more than one (#329). ITS-REST 1.1.0 makesContent-Typeoptional with no
default, so the first listed is the gateway's own choice, as for a missing
Accept. TheContent-Typeof a body is held to the media types the
operation's body is declared in, and aCONTRIBUTIONis read for its
preceding versions by the request body reader ofopenehr-its. - The compose quickstart runs four FerroEHR nodes,
ferroehr-ato
ferroehr-don ports 8081 to 8084, each with its ownsystem_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 throughdocker/postgres/20-ferrofed-node-databases.sh, so the
per-nodeferroehr-a-postgresandferroehr-b-postgresservices and their
volumes are gone (docker compose down -vremoves the old ones). Each node
database admits only its own node's role:CONNECTis 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 inurn:oid:2.999.1.1: one at
all four nodes, one at two, one at one and one at none, and
scripts/quickstart/seed.shcreates exactly those EHRs and a composition in
each over ITS-REST. A patient query now resolves in the quickstart instead
of failing closed with424. 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.shandscripts/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 generatedopenehr-itsparameters (#292, FerroEHR
#3540), so a+is a literal plus. Aquery_typegiven twice, or a pair
that does not percent-decode to UTF-8 text, is now400 body-invalidand
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-federationto 0.0.31 with it. The fuzz crate is now held to the
family pin byscripts/checks/versions.sh. - An
ehr_idthat the session's resolution bindings or theehr_idindex
hold at two members is now refused409 ehr-id-collisionlisting 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 longer400 target-required. Naming the node inopenEHR-federation-endpointstill
routes the request to it (§12.5.1 step 1, N41). The integrity incidents of
thecreating_system_idmap are logged underferrofed::integritytoo. - The
409 controlling-system-unreachablemessage now names the controlling
system in every case (#66; §10.3copy-write-reject, N36, CP-29). Where
the registry routes the version'screating_system_idto another member,
it names thatcreating_system_idas 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 aCONTRIBUTIONversion by position), and still
quotes nothing of the request. CP-29 is scored end to end: a write against
a row thatversion-identitydedup kept, sent through the copy'sehr_id,
is refused409whether 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 ownehr_idreaches it alone and its answer names no
copy. - A body routed to one node always travels with a
Content-Typeits
ITS-REST operation declares (#298; §12.6, N43, CP-34). The gateway reads
each operation's request-body media types fromopenehr-its
(request_media): a clientContent-Typefor an operation that declares
noContent-Typeparameter, such as the versioned stored-queryPUT, is
composed as the listed media type it names, or refused415 media-type-unsupported; a body sent without one travels with the one
media type the operation's body is declared in,text/plainfor a stored
query andapplication/jsonfor a commit, and an operation whose body is
declared in several is415. A forwarded body no longer reaches a node
without aContent-Type. - Each path identifier of a routed request is parsed as the identifier class
openehr-itsstates 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). Theuid_based_idof a
DELETEis anOBJECT_VERSION_ID, as ITS-REST declares: a DEMOGRAPHIC
delete addressing aHIER_OBJECT_IDis400 parameter-value-invalid,
and a composition delete stays400 preceding-version-invalid, with
nothing sent.
Fixed
-
Every link in
llms.txtis 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 a500when 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 atime-outin
ferrofed_node_requests_total, markeddownon
GET /health/dependencies, and listed astime-outinmeta.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 nodetime-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_totalnor timed in
ferrofed_node_request_duration_seconds, and leave the member's state on
GET /health/dependenciesas it was, where they counted astime-outand
set the memberdown. 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 answer504(node-timeout), and the member record in
meta.federationsaystime-out. -
GET {base}/health/dependenciesrecords 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 below500is
up, a5xxisfailing, and no answer isdown, read from the node's
own HTTP status whatever the §11.1 record says. A query member that
answered400, a refused store, a node that refused the gateway's onward
credentials, and a drift check whose copy differs or is missing areup
where some of them werefailing; a request that never left the gateway
changes nothing. -
ferrofed_node_requests_totalandferrofed_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
offlineinmeta.federation, which §11.1 leaves no other status for,
and is no longer counted and timed as anofflinenode 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 fromopenehr-its's route table,
so the line never carries theehr_idor version uid of the path, and the
configured base path appears in front of it once. AnOPTIONSrequest 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/aqland the stored-queryPOST {base}/v1/query/{name}
hold theirContent-Typetoapplication/json, the media type ITS-REST
1.1.0 lists for both (#269). Another media type, or a parameter other than
charset=utf-8, is415 media-type-unsupported, and no node is asked
(RFC 9110 §15.5.16); a body sent astext/plainused to be read as JSON.
A body sent without aContent-Typeis read as JSON, the first listed
media type, as for a missingAccept, 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 itsContent-Typetotext/plainunder the same rule: another media
type is415 media-type-unsupportedand nothing is stored. -
The
query-parameter-refusedsecurity 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
definitionPUTthat raises it too (#269). -
OPTIONS {base}/declares the EHR area as the gateway routes it (#290;
§7a.2, N30, CP-23).its_rest.ehrnow says that a new EHR, by
POST {base}/v1/ehror byPUT {base}/v1/ehr/{ehr_id}, goes only to the
endpoint the targeting headers name, and that thePUTis refused when
another member holds itsehr_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 fourits_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-endpointheader orFROM ENDPOINT, or undirected with
the patient resolving at one member alone, answers with
openEHR-federation-endpointandopenEHR-federation-system-idnaming that
endpoint and its node'ssystem_id, as N31 requires of a request
dispatched to a single node (#319; §7a.3, CP-24). Zero rows, a424or a
504from that node carries them too; a query that asked no node carries
neither. -
A request routed by its target alone (
POST {base}/v1/ehrand a definition
request) checks its declared header and query values before its target, as
the EHR route does: a malformed value is400 parameter-value-invalid, and
anAcceptorContent-Typethe operation does not list is406or415,
before400 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 asnode:a,
-nodeor 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 theENDPOINT
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'sversion_at_time,Acceptor
pathversion_uidagainst, and a patient identifier written there
reached the node. The gateway now works from what the ITS-REST operation
declares inopenehr-its's parameter table. It composesAccept,
Content-TypeandPreferitself, so the node receives a value the
operation lists, in its own spelling:Acceptis negotiated as an
RFC 9110 §12.5.1 media-range list,*/*or noAcceptsending the first
listed type, and one that admits no listed type is refused with the new
codemedia-type-not-acceptable(406);Content-Typemust name a
listed type, withcharset=utf-8accepted and dropped, or it is refused
with the new codemedia-type-unsupported(415);Preferkeeps only
the listed preferences and ignores the rest (RFC 7240 §2). Each path
identifier parses as the openEHR identifier it names withopenehr-base
(version_uidanOBJECT_VERSION_ID, a textuid_based_ida
UID_BASED_ID, auuid-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'sIso8601_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-tagkey,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, anAcceptparameter and a path
uid.ferrofed-engineadds thedeclaredmodule, which takesmimefor
the media types, andForwardError::Value. - The
Debugoutput of the configuration no longer prints an inline
credential (#364). An inlinebearer_tokenorpasswordof 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 newferrofed_registry::secret
module: aSecret, whichDebug,DisplayandSerializerender as
***, or aSecretUrl, 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 PostgreSQLurlandurl_fileand
metrics.otlp_endpointareSecretUrls too. A_filesibling 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
addsPixmConfigError::BaseUrlfor a Manager base URL that does not
parse. - A PIX Manager
urlthat carries a user name or a password is refused by
config checkand at start, namingpixm.manager[N].urland 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
Debugoutput ofihe-iti's types no longer prints a credential
(#370).PixmClientandPdqmClientderivedDebug, 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, anAuthorizationheader
among them. Each client now shows its URLs with the userinfo and the
query replaced by***, and leaves the HTTP client out. mCSD's
DirectoryOrganizationandDirectoryEndpointshow theirfullUrland
the endpointaddressthe same way, and the endpoint leaves out the rest
of its resource, whoseheaderlist may hold a credential. The crate
carries this redaction itself and depends on nothing in FerroFED. Every
redacted value inihe-itinow shows the family's***, where the
identifiers, the matched Patients and the page links showed[REDACTED],
and so doferrofed-identity'sPatientRefand the admission probe's
synthetic subject.
ihe-itiis 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 aferrofed_registry::secret::SecretUrlor of anihe-ititype
(#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
@.