Hazard log: the logging component
This page is the clinical safety hazard log of FerroEHR’s European logging software component, the part of the EHR system that “provides logging information related to access by health professionals or other individuals to priority categories of personal electronic health data” (EHDS Art. 2(2)(o), Regulation (EU) 2025/327). EHDS Annex II 1.1 asks that the harmonised software components “achieve the performance intended by its manufacturer” and be designed so that, “during normal conditions of use, they are suitable for their intended purpose and their use does not put at risk patient safety”.
The Regulation names no method or standard for showing that, so the form of this log is FerroEHR’s own design. For each hazard it states the cause, what could happen to a patient, the control, the test that shows the control holds, and what remains. “Normal conditions of use” are those of the intended-purpose statement. The log is the EHDS-side companion of the CRA risk assessment, and it is versioned with the release like every page of this book.
- The component and the surface it records
- H1: an access is not recorded
- H2: a record names the wrong person, organisation or patient
- H3: the category of the data is not recorded
- H4: a record misstates where the data came from
- H5: the log is lost, or reaped too early
- H6: a patient cannot read their log
- H7: the access log is altered
- H8: the log discloses more than it should
- H9: an emergency access to data restricted under EHDS Art. 8 is not marked
- When the log is reviewed
Warning
The quotations are from the Official Journal text vendored in the repository at
docs/law/eu/ehds/text.html(EUR-Lex). A control and a passing test show what the software does; they do not show that the component meets Annex II, which no conformity assessment has examined.
The component and the surface it records
The component is, in the code: ferroehr::system_log (the access-event model,
the audit store, the DICOM and FHIR renderings and the sinks),
ferroehr-rest::system_log (the middleware that records every API access and
classifies it), the ITI-81 GET /fhir/r4/AuditEvent retrieval, and the
retention register in ferroehr::storage::marks. The middleware is installed
outermost on the API router, around the authentication layer, so it sees every
response of the openEHR REST surface, refusals included. What each record
holds, field by field against Annex II 3.2, is on the
audit trail page.
Test paths below are relative to the repository root. Each was read for this log; “no test” means none exists.
H1: an access is not recorded
Cause: a route the middleware does not classify; a record dropped when the emission queue is full or the store is unreachable; a domain-level access (resolving a subject to an EHR, an identifier resolution, an EHR Extract export) that skips the middleware; reads made straight against the database.
Effect: a patient, or the organisation investigating a breach, cannot learn that their record was read. Under EHDS Art. 9 a patient has a right to that information.
Control: every generated ITS-REST operation has an explicit audit
classification, and an unknown operation falls back to the audited default.
A rejected access (401, 403) is always recorded. Under
[audit] fail_mode = "closed" a request whose record cannot be taken is
answered 503 and the domain-level operations withhold their result. An AQL
execution emits one record per EHR it served. deployment_profile = "production" refuses audit switched off, audit with no durable sink and
audit failing open, unless each is accepted by name.
Shown by:
app/ferroehr-rest/src/system_log/classify.rs:every_generated_operation_is_explicit,every_read_operation_produces_an_access_record,unrecognised_operation_fails_closed_to_default;app/ferroehr-rest/tests/it/audit_e2e.rs: one test per resource family (for examplecomposition_get_emits_read_record,aql_execute_emits_execute_record),unauthenticated_request_emits_401_record,fail_closed_returns_503_when_channel_full,fail_closed_503_carries_openehr_error_body_and_retry_after,aql_execute_emits_one_access_record_per_served_ehr;app/ferroehr/tests/it/access_event_fail_closed.rs:a_linkage_resolution_is_withheld_when_its_record_is_rejected,a_subject_lookup_is_withheld_when_its_record_is_rejected,an_identifier_resolution_is_withheld_when_its_record_is_rejected,an_extract_export_is_withheld_when_its_record_is_rejected;app/ferroehr/src/config/deployment.rs:production_refuses_each_open_gap_and_names_the_remedy,production_refuses_the_open_access_default_and_the_open_audit_fail_mode;app/ferroehr/tests/it/legal_marks.rs:setting_and_lifting_a_mark_is_recorded_in_the_access_trail;app/ferroehr-rest/tests/it/audit_route_coverage.rs:every_route_writes_exactly_one_access_recordenumerates every route the assembled router mounts, the extension routes included, drives each one once, and asserts exactly one access record carrying its operation id. The routes it exempts (the health probes, the public status and discovery documents, the OpenAPI document and Swagger UI, theOPTIONSmanifest and the management surface) are listed in the test with the reason.
Remains: the shipped fail_mode is open, so under the sandbox
profile a full queue drops a record and the request succeeds
(fail_open_serves_request_when_channel_full pins that behaviour). Database
access outside the server is recorded nowhere.
H2: a record names the wrong person, organisation or patient
Cause: the actor taken from something other than the verified credential; an organisation guessed when the token carries none; the EHR taken from the wrong part of the request; one record written for an AQL page that served several patients.
Effect: an access is attributed to a professional who did not make it, or recorded against the wrong patient, so a patient is told of an access to someone else’s record and the real one goes unseen.
Control: the principal comes from the authenticated credential; a 401
records the caller as unknown and never fabricates one. The organisation is
read from the same token claim the ABAC layer reads, and stays empty when
there is no claim or the caller used Basic. The EHR comes from the resource
the dispatch resolved, or from the request path where none exists. An AQL
execution records each served EHR separately. With
[auth.oidc.professional] configured, a token that names no natural person is
refused on patient data unless it is a client token acting for a named
professional, and the record carries the acting mode (person or client)
and that professional; with [auth.oidc.assurance] configured, it carries the
assurance level the token maps to, and a token below the configured minimum is
refused with a step-up challenge.
Shown by:
app/ferroehr/tests/it/audit_ehds_mapping.rs:element_a_the_accessing_organisation_is_recorded_and_rendered_in_fhir,element_b_the_person_who_accessed_is_recorded_and_rendered,element_b_the_professional_behind_a_client_is_recorded_and_rendered,the_assurance_level_is_recorded_and_reaches_neither_rendering,nen_7513_the_actor_roles_are_recorded_and_rendered_in_both_formats;app/ferroehr/tests/it/audit_store.rs:access_fields_round_trip_on_both_write_paths,the_accessing_organisation_round_trips_on_both_write_paths,the_actor_roles_round_trip_on_both_write_paths,both_inserts_persist_the_authentication_columns;app/ferroehr-rest/tests/it/patient_data_assurance.rs:a_token_below_the_minimum_gets_the_step_up_challenge,a_client_token_without_acting_for_is_refused,a_client_acting_for_a_professional_passes_and_is_recorded;app/ferroehr-rest/src/system_log/middleware.rs:ehr_id_from_various_paths,object_id_from_template_paths,object_id_from_query_paths,object_id_from_demographic_paths;app/ferroehr-rest/tests/it/audit_e2e.rs:unauthenticated_request_emits_401_record,aql_execute_emits_one_access_record_per_served_ehr.
Remains: both blocks are off by default, and until the deployment
configures [auth.oidc.professional] a client application’s credential is
recorded as that application, not as the professional behind it. A shared
Basic account names the account; with either block configured, Basic is
refused on patient data. The assurance level reaches the local store’s columns only, because neither
the DICOM nor the FHIR R4 audit format has an element for it, and ITI-81 serves
the FHIR document.
H3: the category of the data is not recorded
Cause: a template or archetype missing from the deployment’s
[audit.categories] map; a record type the classifier does not reach.
Effect: a record cannot say whether the access reached a patient summary, a prescription or a test result, which Annex II 3.2(c) asks for and the patient’s information under EHDS Art. 9(2)(c) depends on.
Control: each access is classified when it happens, by template id, then
root archetype id, then the query’s positive constraints. A logical delete is
classified by the version it deleted, a VERSIONED_COMPOSITION read by the
composition it holds, a CONTRIBUTION read (with or without
Prefer: resolve_refs) by the versions it committed, and an EHR Extract
export or import by the versions it carried or landed. EHR, EHR_STATUS,
EHR_ACCESS and the directory are recorded none by resource kind. An
access the map cannot classify is recorded unclassified, with the ids as
evidence, and is never refused. Each record carries the digest of the map
that classified it.
Shown by:
app/ferroehr-rest/tests/it/audit_categories.rs:an_unmapped_template_is_unclassified_with_its_ids_as_evidence,a_multi_category_query_records_every_category_it_served,a_leaf_only_query_is_classified_from_its_constraints,a_zero_row_query_is_classified_from_its_constraints,a_not_contains_operand_never_counts,a_flat_write_is_classified_by_its_template,a_logical_delete_is_classified_by_the_version_it_deletes,a_versioned_composition_read_is_classified_by_its_composition,a_contribution_read_is_classified_by_its_versions;app/ferroehr/tests/it/service_message_audit.rs:the_extract_records_carry_the_categories_of_the_data;app/ferroehr/tests/it/audit_ehds_mapping.rs:element_c_the_priority_category_is_recorded_and_rendered_in_fhir_only.
Remains: the map is the deploying organisation’s, and FerroEHR ships
none, so an incomplete map yields unclassified records. Records in the
demographic domain carry no classification, because no priority category
lives there. The Helm chart carries the map as config.audit.categories, empty
by default as in the binary.
H4: a record misstates where the data came from
Cause: a body’s FEEDER_AUDIT provenance ignored; an AQL page’s
origins taken from one row only.
Effect: a patient or a reviewer is told the wrong system created the data that was read (Annex II 3.2(e)).
Control: each committed version records the distinct originating systems
of its body, or this server when it has none; a read records the origins of
what it served, an AQL page the union, with the true count beside a capped
set. The FHIR AuditEvent of a read or a query carries the origins of the
data it served.
Shown by: app/ferroehr/tests/it/access_origins.rs:
a_body_without_feeder_audit_has_this_server_as_its_origin,
feeder_audits_anywhere_in_the_body_count_once_each,
an_aql_page_records_the_union_of_the_origins_it_served,
an_unstamped_row_is_assessed_from_the_body_at_read;
app/ferroehr-rest/tests/it/audit_e2e.rs:
composition_get_records_the_origins_of_the_served_data;
app/ferroehr/src/system_log/fhir.rs:
query_record_carries_the_origins_of_the_served_data.
Remains: the DICOM rendering carries no origins, because PS3.15 defines no element for them; the local store and the FHIR export carry them.
H5: the log is lost, or reaped too early
Cause: a retention period shorter than the law requires; a reaper that deletes more than it should; the database that holds the local store lost; a forwarding sink down.
Effect: the information a patient has a right to for “at least three years from each date of access” (EHDS Art. 9(2)) no longer exists.
Control: each jurisdiction has a registered retention floor (three years
in every EU Member State, longer where national law sets more), and a
configured period below it stops the boot. The reaper deletes only records
past the horizon, in calendar years where the period is given in years, and
the chain stays verifiable after it runs. The forwarding outbox keeps
records until the sink takes them. production refuses audit with no
durable sink unless accepted by name.
Shown by:
app/ferroehr/src/config/mod.rs:a_retention_below_the_jurisdiction_floor_is_refused,the_ehds_floor_holds_in_every_member_state_and_a_longer_national_floor_wins,retention_years_meets_a_floor_in_years_exactly,a_ceiling_below_a_floor_is_refused;app/ferroehr/src/system_log/config.rs:the_ehds_floor_reaches_every_member_state_and_a_longer_national_floor_wins;app/ferroehr/tests/it/audit_store.rs:reap_deletes_only_rows_past_the_horizon,reap_years_deletes_only_rows_past_the_calendar_horizon;app/ferroehr/tests/it/audit_chain.rs:retention_reaping_leaves_a_verifiable_trail;app/ferroehr/tests/it/audit_feed.rs:arr_outage_leaves_rows_pending_then_delivers_on_recovery.
Remains: a local store shares the fate of its database, so backups and an off-box sink are the deploying organisation’s (B7).
H6: a patient cannot read their log
Cause: the log reachable only with an administrator credential; no retrieval scoped to one patient.
Effect: the information EHDS Art. 9 gives a patient cannot be produced.
Control: ITI-81 GET /fhir/r4/AuditEvent searches the log by patient,
agent, action and date, and a configured subject audit role reads one
subject’s records and nothing else. EHDS Art. 9(2) has the information
provided “through electronic health data access services”, which the Member
States establish (Art. 4(1)); FerroEHR serves the log to such a service and
has no patient-facing interface of its own.
Shown by: app/ferroehr-rest/tests/it/audit_iti81.rs:
searchset_bundle_with_filters_and_paging,
the_subject_audit_role_reads_one_subjects_log_and_nothing_else,
rbac_gates_the_audit_surface_to_admins.
Remains: the subject audit role is unset by default, so the log is admin-only until the deployment configures it.
H7: the access log is altered
Cause: a record changed or deleted in the database, by a compromised application role or by someone with direct access.
Effect: an access disappears, or appears to have been made by someone else, and the record a patient or a court relies on is false.
Control: records are hash-chained in the database; UPDATE, DELETE
and TRUNCATE on the log are refused; the application role cannot run DDL
or rewrite the log; audit.verify_audit_chain() names any record that
changed or went missing; the server runs that verification one minute after
boot and then every [audit.store] verify_interval_seconds (default one day),
logs a finding at ERROR, counts it in atna_audit_chain_findings and shows
it on the audit_chain readiness indicator; forwarding puts copies off the
box.
Shown by:
app/ferroehr/tests/it/audit_chain.rs:a_modified_audit_record_is_detected_and_named,a_deleted_audit_record_is_detected_in_the_middle_and_at_the_end,the_audit_table_refuses_mutation_deletion_and_truncation,the_application_role_cannot_run_ddl_or_rewrite_the_audit_trail,the_batched_write_path_is_chained_too,the_chain_check_reports_a_tampered_record_on_the_health_surface,the_audit_subsystem_schedules_the_chain_check;app/ferroehr/tests/it/audit_store.rs:the_access_trail_refuses_rewriting_deletion_and_truncation.
Remains: the chain gives evidence of tampering and does not prevent it.
A deployment that sets verify_interval_seconds = 0 finds a broken chain only
when an operator runs the check. The indicator never flips readiness, so a
finding reaches someone only through an alert on the counter or the
indicator, which the deploying organisation sets up.
H8: the log discloses more than it should
Cause: the category classification or identifying content copied into request logs, spans or metrics; the log served to a caller with no right to it.
Effect: the log of who read a patient’s record becomes a disclosure of that record.
Control: the classification stays in the access log; telemetry carries no identified data; the ITI-81 surface is authorised like any other.
Shown by: app/ferroehr-rest/tests/it/audit_categories.rs:
the_classification_never_reaches_spans_logs_or_metrics;
app/ferroehr/tests/it/privacy_leak.rs:
the_atna_records_carry_the_opaque_pseudonym_and_nothing_else;
app/ferroehr-rest/tests/it/audit_iti81.rs:
rbac_gates_the_audit_surface_to_admins.
Remains: the log names patients and actions by design, so whoever holds ITI-81 access reads that.
H9: an emergency access to data restricted under EHDS Art. 8 is not marked
Cause: a natural person restricts the access of health professionals to some of their data (EHDS Art. 8), and a professional reaches that data anyway because the person’s vital interests require it (Art. 11(5)). Without a mark, the access record of that emergency looks like any other.
Effect: EHDS Art. 9(1) includes “access provided in accordance with Article 11(5)” in what a patient is told about, and Art. 11(5) asks that such cases be “logged in a clear and understandable format” and be “easily accessible for the data subject”. An unmarked emergency access hides that it happened.
Control: a deployment names the purpose-of-use codes its callers declare
for an emergency ([audit] emergency_purpose_codes, each also on
purpose_codes). An access declaring one carries the emergency mark in its
access record: the emergency_access column, and in the FHIR AuditEvent a
purposeOfEvent with the HL7 v3 code ETREAT and the mark in plain words.
The person’s own subject-scoped ITI-81 retrieval serves that document. The
mark lifts no restriction: an object restricted under GDPR Art. 18 is refused
with an emergency purpose exactly as without one, and the refused attempt is
logged with the mark.
Shown by: app/ferroehr-rest/tests/it/audit_emergency_access.rs:
an_emergency_access_is_marked_and_shown_in_the_subjects_own_access_log,
an_emergency_purpose_does_not_lift_a_gdpr_restriction;
app/ferroehr/tests/it/audit_ehds_mapping.rs:
art_11_5_the_emergency_mark_reaches_the_fhir_rendering_only;
app/ferroehr/tests/it/audit_store.rs:
both_inserts_persist_the_emergency_mark_and_it_cannot_be_rewritten.
Remains: FerroEHR does not hold the Art. 8 restriction itself, so it neither hides restricted data nor grants the override; the mark rests on the purpose the caller declares, and an emergency the caller does not declare is not marked. The Art. 8 restriction with its override is planned (#3682). The DICOM syslog rendering has no purpose element and does not carry the mark.
When the log is reviewed
The log is reviewed in the release that changes any module of the component listed above, with the hazards, controls and test names checked against that release’s tree. It is also reviewed when an incident, a complaint or a vulnerability shows a hazard the log does not name. The technical documentation cites it as the EHDS-side risk assessment of the component.
| Version | Date | Change |
|---|---|---|
| 1 | 2026-10-06 | First log, nine hazards |
| 2 | 2026-10-07 | H9 names the EHDS Art. 8 restriction; the emergency mark and the subject-scoped retrieval are its control |
| 3 | 2026-10-08 | Reviewed for release 4.3.6 (changes since 4.3.5: the assurance level and the natural person, the emergency mark, the FHIR connector removed beside the ITI-81 route); every named test exists and asserts its control; no hazard added |