Comparison with EHRbase
FerroEHR and EHRbase are two independent open-source openEHR CDRs. This page measures them side by side with the instruments FerroEHR applies to itself: the CNF 2.0 conformance runner executes the same committed catalogue against both servers, and the step-load stress instrument drives both with the same seeded clinical workload. Both directions are always published; a result that favours either server is reported exactly like one that favours the other.
EHRbase is prior art here, never an oracle. Every expected outcome comes from the openEHR specification text, so a row is red because the specification says otherwise, never because the two servers disagree.
Note
The two systems are not built or hosted alike, and the generated tables below say so. FerroEHR is built from this repository’s current sources; EHRbase runs from its official published container images. The two runs were also measured on different machines; each committed report records its own environment. Read the conformance columns as comparable (identical catalogue, identical runner, each side’s own declarations) and the performance columns as two separate measurements rather than a like-for-like hardware race.
Each side runs with its own committed party set: an ixit describing the reachable instances (EHRbase’s Basic auth carries one clinical and one admin principal and no read-only one, so its ixit declares none) and a statement (the ICS) declaring the capabilities, specification versions, and ambiguity-register options that party actually claims. A capability a party does not claim is dropped from its verdict scope and can never count against it; a case whose ground cannot exist on a party’s topology or technology profile is recorded not applicable with a machine citation, never fail. That is how the comparison stays fair without weakening a single case.
Every number and every curve on this page is generated at build time from the
committed run records (results.json, verdicts.json, stress.json under
docs/conformance/); nothing here is hand-typed, and a CI stale-numbers gate
rejects any attempt to hand-type it. To reproduce either side yourself, see
Conformance and
Benchmarks.
One difference between the two projects is not something a runner can score, so this page states it rather than tabling it. FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap: the compliance overview names the legal sources, the control matrix is generated from the tracker, and the shared-responsibility page says which obligations the software cannot carry. None of that is a certification claim, and none of it is a statement about EHRbase, which publishes its own documentation on its own terms.
- Conformance
- Systems under test
- Methodology
- Profile verdicts
- In-scope outcomes
- Capability-by-capability
- Failures — both directions
- Performance
- Method, in one paragraph
Conformance
Systems under test
| ferroehr | EHRbase | |
|---|---|---|
| Product | ferroehr 4.3.0 | ehrbase 2.34.0 |
| Run date | 2026-09-15 | 2026-09-15 |
| Party statement | committed with the runner (declares ITS-REST pin, signing, terminology posture) | committed with the runner |
| Stack | the project’s own compose stack, built from the current sources | a dedicated compose stack of the official EHRbase images |
Methodology
Both systems execute the same committed CNF 2.0 catalogue (1145 case-by-format
executions) through the same instrument (veredictum), each on
fresh volumes with its own committed party set: the ixit names the reachable
instances (EHRbase declares no readonly principal), and the statement (the
ICS) declares the claimed capabilities, spec versions, and ambiguity-register
options — ISO/IEC 9646-style test selection excuses undeclared option
branches, unclaimed capabilities, and release-dated behaviour outside the
declared versions as N/A with a citation, never as silent skips. Verdicts are
pure functions of (statement, results, catalogue, capability matrix).
The declared-version delta matters and is stated, not hidden: ferroehr
declares ITS-REST 1.1.0
while EHRbase declares ITS-REST
1.0.3 —
the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour (the
Demographic API, ITEM_TAGs, Simplified Formats on the wire, the admin EHR
delete, the weak-ETag/Location header forms, …) is cited N/A for the
1.0.3 declaration rather than driven against a release EHRbase never
claimed. The verdict-bearing comparison below is therefore each party’s
in-scope subset, never the raw record.
Profile verdicts
| Profile | ferroehr | EHRbase |
|---|---|---|
| CORE | pass | fail |
| STANDARD | pass | fail |
| OPTIONS | pass | not claimed |
| SEC-BASIC | pass | not claimed |
In-scope outcomes
Runs compared: ferroehr (run of 2026-09-15) vs EHRbase 2.34.0 (run of 2026-09-15) — the SAME catalogue through the same runner, each with its own committed party statement. Per the presentation rule, the headline is each party’s VERDICT SCOPE (the cases its own declarations select), never the raw record: a raw count would book release-dated and unclaimed surfaces against a party that never claimed them.
| verdict scope (selected) | driven | in-scope passed | in-scope failed | in-scope inconclusive | |
|---|---|---|---|---|---|
| ferroehr | 1145 | 1104 | 1104 | 0 | 0 |
| EHRbase | 692 | 571 | 148 | 148 | 275 |
An inconclusive row’s wire answered outside the operation’s bound outcome
map, or its required ground could not be established (e.g. a refused
provisioning exchange) — never counted as a failure of the behaviour under
test. Every not-run row in the full committed record
(docs/conformance/<sut>/results.json) carries a machine-readable
citation: an undeclared option branch, an unclaimed capability, a
release-dated behaviour outside the declared spec versions, or a ground the
party’s topology cannot establish.
Capability-by-capability
Evidence tokens from each party’s computed verdicts: passed (every gating case green), failed (at least one gating case red), inconclusive (a gating case neither passed nor failed cleanly), not_evidenced (claimed, but no gating case produced a verdict — there is no excused state: a required capability without passing evidence fails its tier, whichever party claims it), or not_claimed (absent from that party’s ICS, so no case naming it was ever selected — it can never count for or against that party). The whole capability matrix is listed for both columns, because the matrix is the profiles book as data, not a claim list.
| Capability | ferroehr | EHRbase |
|---|---|---|
| ActivityReport | passed | not_claimed |
| Adl14ArchetypeProvisioning | passed | failed |
| Adl14OptProvisioning | passed | failed |
| Adl2ArchetypeProvisioning | passed | not_claimed |
| Adl2OptProvisioning | passed | not_claimed |
| AdminApi | passed | not_evidenced |
| AnonymousEhrs | passed | not_claimed |
| AqlAdvanced | passed | inconclusive |
| AqlBasic | passed | failed |
| AqlTerminology | passed | not_claimed |
| ArchetypeValidation | passed | failed |
| AuditAccountability | passed | not_claimed |
| AuthenticatedAccess | passed | passed |
| AuthorizationSeparation | passed | not_evidenced |
| BulkEhrLoad | passed | not_claimed |
| ChangeSets | passed | failed |
| CompositionOps | passed | inconclusive |
| DefinitionApi | passed | failed |
| DemographicApi | passed | not_claimed |
| DemographicArchetypeValidation | passed | not_claimed |
| DemographicArchive | passed | not_claimed |
| DirectoryOps | passed | failed |
| EhrApi | passed | failed |
| EhrArchive | passed | not_claimed |
| EhrDemographicSeparation | passed | passed |
| EhrDumpLoad | passed | not_claimed |
| EhrExtract | passed | not_claimed |
| EhrOperations | passed | failed |
| EhrStatus | passed | failed |
| ItemTags | passed | not_claimed |
| MessageApi | passed | not_claimed |
| PartyOperations | passed | not_claimed |
| PartyRelationshipOperations | passed | not_claimed |
| PhysicalDeletion | passed | not_evidenced |
| QueryApi | passed | failed |
| QueryProvisioning | passed | failed |
| Signing | passed | not_claimed |
| SimplifiedFormats | passed | not_claimed |
| SmartAppLaunch | passed | not_claimed |
| SystemApi | passed | not_claimed |
| Tds | passed | not_claimed |
| TemplateExamples | passed | not_evidenced |
| Versioning | passed | failed |
Failures — both directions
ferroehr failures (with the EHRbase outcome on the identical case)
| Case | Format | Failure | EHRbase outcome |
|---|---|---|---|
| — | — | none — zero failing cases | — |
EHRbase failures by schedule chapter
| Chapter | failed cases |
|---|---|
| CONT | 67 |
| I_EHR_STATUS | 27 |
| I_EHR_CONTRIBUTION | 13 |
| I_EHR_DIRECTORY | 13 |
| I_DEFINITION_QUERY | 10 |
| I_DEFINITION_ADL14 | 8 |
| I_EHR_SERVICE | 4 |
| I_QUERY_SERVICE | 4 |
| I_EHR_COMPOSITION | 1 |
| I_ITS_REST_REVISION_HISTORY | 1 |
Every EHRbase-failed case, with the ferroehr outcome on the identical case
| Case | Format | EHRbase failure | ferroehr outcome |
|---|---|---|---|
| CONT-COMP-content_card_1plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_1plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-content_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-context_existence | — | expected created, observed validation_failed | passed |
| CONT-DV_CODED_TEXT-validate_open | — | expected bad_request, observed validation_failed | passed |
| CONT-DV_DATE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_list | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_pattern | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_ORDINAL-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_PROPORTION-validate_ratio_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_SCALE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_MULTIMEDIA-validate_media_type | — | expected created, observed validation_failed | passed |
| CONT-DV_PARSABLE-validate_value_formalism | — | expected created, observed validation_failed | passed |
| CONT-DV_TEXT-validate_open | — | expected bad_request, observed validation_failed | passed |
| CONT-DV_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-EVENT-state_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-EVENT-state_ex_opt | — | expected bad_request, observed validation_failed | passed |
| CONT-EVENT-type_any | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_interval_event | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_point_event | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HISTORY-events_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_any | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_list | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_single | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_table | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_tree | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_opt | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_opt | — | expected bad_request, observed validation_failed | passed |
| I_DEFINITION_ADL14.delete_archetype-clinical_forbidden | — | expected forbidden, observed not_found | passed |
| I_DEFINITION_ADL14.upload_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-largest_published | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_no_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_QUERY.list_queries-prefix_all_versions | — | [0]/name: path resolves to nothing | passed |
| I_DEFINITION_QUERY.list_queries-version_get_xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.list_queries-xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.store_query-default_slot_with_higher_version | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-dotted_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-unqualified_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-update_in_place | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-version_duplicate_case_variant_name | — | expected conflict, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prefix_rejected | — | expected bad_request, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prerelease_rejected | — | expected bad_request, observed stored | passed |
| I_EHR_COMPOSITION.get_versioned_composition-malformed_uid | — | expected bad_request, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-delete_directory | — | expected created, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-deleted_member_with_data | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_incomplete_lifecycle | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type | — | expected conflict, observed validation_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type_deleted | — | expected conflict, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_valid_combinations | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-fail_modify_non_existing_directory | — | expected validation_failed, observed precondition_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-full_ehr_status | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-minimal_ehr_status | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-non_exiting_opt | — | expected template_not_found, observed validation_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-update_existing_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-valid_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.get_contribution-version_ref_shape | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.create_directory-ehr_not_modifiable | — | expected updated, observed precondition_missing | passed |
| I_EHR_DIRECTORY.create_directory-root_archetype_id_mismatch | — | expected validation_failed, observed created | passed |
| I_EHR_DIRECTORY.create_directory-versioned_id_items | — | expected created, observed bad_request | passed |
| I_EHR_DIRECTORY.create_directory-wide_id_items | — | expected created, observed bad_request | passed |
| I_EHR_DIRECTORY.delete_directory-ehr_with_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.delete_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.delete_directory-etag_names_new_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory-deleted_head | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory_at_time-deleted_at_time | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory_at_version-deleted_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.update_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.update_directory-root_archetype_id_mismatch | — | expected validation_failed, observed updated | passed |
| I_EHR_DIRECTORY.update_directory-stale_if_match | — | header ETag: expected the latest version uid, got none | passed |
| I_EHR_SERVICE.create_ehr-committal_headers | — | commit_audit/description/value: path resolves to nothing | passed |
| I_EHR_SERVICE.create_ehr-invalid_status | — | expected validation_failed, observed created | passed |
| I_EHR_SERVICE.create_ehr-wrong_method | — | header Allow: expected a value matching “.*(GET.*POST|POST.GET).”, got none | passed |
| I_EHR_SERVICE.get_ehr-malformed_ehr_id | — | expected bad_request, observed not_found | passed |
| I_EHR_STATUS.clear_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.clear_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.clear_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status_at_version-addressed_version | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-contained_uid_form | — | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.get_versioned_ehr_status-container_shape | — | owner_id/type: “ehr” != expected “EHR” | passed |
| I_EHR_STATUS.get_versioned_ehr_status-xml | canonical-xml | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.set_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_modifiable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_queryable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-rm_version_empty | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history-two_versions | canonical-json | expected updated, observed precondition_missing | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_header | — | row count 100 != expected 1 | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_param | — | row count 100 != expected 1 | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-unknown_ehr_scope | — | expected not_found, observed ok | passed |
| I_QUERY_SERVICE.execute_stored_query-fetch_with_top | — | expected stored, observed bad_request | passed |
Both servers’ capability conformance, from each party’s committed verdicts (generated, diff-guarded; see Conformance for how to read the grid):
And the per-chapter outcomes side by side. Both charts render the same
chapter-and-band taxonomy, so they read band-for-band: a band EHRbase did not
exercise shows as an explicit no cases row in the same position. Compare the
printed counts, not the bar lengths: each chart scales its bars to its own
widest band, and the legend states that scale.
How to read the EHRbase column
Three mechanisms decide what the EHRbase column can say, and all three are visible in the committed record:
- Selection. A case is in EHRbase’s verdict scope only if its capabilities intersect what EHRbase claims and its behaviour is dated at or below the specification versions EHRbase declares. EHRbase declares ITS-REST 1.0.3 while the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour is cited out of scope rather than driven. Unclaimed surfaces drop out the same way, and they drop out before the request: a case whose capabilities a party’s statement does not claim is recorded not applicable with its citation instead of being driven, so an unclaimed surface cannot produce a red row at all. Anything a record still carries from before that rule reached the driver is excluded from the in-scope tally above and is not listed as a divergence below. The headline is the verdict scope for exactly that reason.
- Inconclusive, not failed. A row is inconclusive when EHRbase answered with a status the operation’s specification-cited outcome map does not contain, or when the exchange that would have established the case’s ground was refused. The runner never guesses a verdict, and an inconclusive row is never counted as a failure of the behaviour under test.
- Report-only grounds. Where a case rests on an openEHR silence our ambiguity register marks report-only (an upstream problem report still open) the row is recorded but does not gate either party’s verdicts.
Where the red rows do concentrate is the content chapter: no content case passed (each one either failed or never established its ground) and the direction is the opposite of a permissive server. The catalogue’s content cases are accept/reject decision tables committed against a template the case itself provisions, and in the ones that ran, EHRbase’s rejected rows pass while its accepted rows fail: it refuses documents its own template admits. Some rows require a plain bad request rather than a semantic refusal (the document is malformed, not merely invalid), and there EHRbase refuses too, in the wrong class.
The principal EHRbase divergences, stated plainly
Each item below is a red row in the committed EHRbase record, restated from what that record holds: the outcome the specification-cited expectation required versus the outcome observed. Except where marked, every one was selected for EHRbase’s own declared ITS-REST 1.0.3; the Release-1.1.0-dated behaviours never reach this list. The record carries the case id, the failing step, the rows driven and the reason string. It is not a wire transcript, so nothing is quoted here beyond what it actually captured.
- Every
EHR_STATUSwrite is refused. Setting or clearingis_queryable/is_modifiableis realized as a read-modify-write: read the status, flip the flag,PUTit back with the version identifier from the server’s ownETagin a quotedIf-Match, the exact form the released overview’s own example shows. Every one of those rows, including the plain happy path, answers400instead of updating: the status the same released section reserves for a client that did not provideIf-Matchat all. The rest of that path is therefore unreadable: a row meant to distinguish a semantic refusal from a malformed request gets that same400, so the record cannot say what EHRbase’s validation would have done. - No
Last-Modifiedanywhere. The released overview says bothETagandLast-ModifiedSHOULD be included for versioned resources; EHRbase sends noLast-Modifiedon any of them: contribution commits, contribution reads, directory reads, updates and deletes, and the versionedEHR_STATUSreads in JSON and XML alike. It is the single widest header difference in the record. Relatedly, a staleIf-Matchon a directory update is refused without theETagnaming the latest version that the refusal is supposed to carry. - Some model-invalid content is accepted.
POST /ehrwith anEHR_STATUSthat is an archetype root carrying noarchetype_detailsis created rather than refused, while the four sibling rows in the same case (missing mandatory members, an undecodable polymorphic slot) are refused correctly, so this is a validation gap, not a missing validator. A directory whose root archetype identifier does not match the one required is likewise accepted, on both the create and the update path, and a CONTRIBUTION member marked deleted that still carries data commits instead of being refused. - Contribution refusals land in the wrong family. A CONTRIBUTION whose
EHR_STATUSmember carries an invalid change type is refused as a semantic validation error where the catalogue requires a conflict; the variant that deletes the mandatoryEHR_STATUSanswers not-found instead. A CONTRIBUTION naming a template that does not exist answers a generic validation error rather than the template-not-found branch, and a modification against a directory that does not exist answers a failed precondition. - A missing directory is dressed as a failed precondition. Both the update
and the delete of a directory on an EHR that has none answer
412 Precondition Failedwhere the released text requires not-found. HTTP requires the opposite order, since a failure detectable before the precondition is evaluated takes precedence over evaluating it. On the same operation a genuinely staleIf-Matchanswers409, a status the operation’s outcome map does not contain at all, so that row is recorded inconclusive rather than red. - A malformed identifier in the path answers not-found, not bad request. Given a path segment that is not a UUID at all, both the EHR read and the versioned-COMPOSITION read report a miss instead of a client error. The treatment is not uniform: the malformed version-identifier rows pass.
- Stored-query names the released grammar admits are refused. The released
qualified-name grammar makes the namespace optional (it lists a plain
my_compositionsamong its valid examples) and puts the dot inside the query-name character set, with::as the only separator. Both a plain unqualified name and a namespace-less dotted name are refused as bad requests. A third store (a fully qualified name whose AQL carries aTOP) is refused as well, so the paging conflict that case exists to test never got driven. - The stored-query listing does not honour the prefix. The released list
operation’s own worked example lists “all versions of all queries with names
starting with
org.openehr”. After storing two names under one prefix, the case’s prefix listing comes back with no first row to read anamefrom at all, so a client cannot discover what a namespace holds. - Version-grammar refusals are missing at the store. A version that is only a prefix and a pre-release version are both stored where the catalogue requires a bad request, and a second store under a case-variant of an existing name is accepted where a conflict is required.
- An XML
Accepton a JSON-only operation is answered, not refused. The released stored-query listing declaresAccept: application/jsonand nothing else, and the overview requires406 Not Acceptablewhen an XML request cannot be fulfilled. Both the listing and its version-get sibling answer success instead. - A query scoped to one EHR is executed over all of them. A client
restricts an ad-hoc query to a single record with the
ehr_idquery parameter or theopenehr-ehr-idrequest header. The service model defines that input as the specific set of EHRs on which to execute the query, and the released REST query chapter says the same thing from the other side: a population query is one that does not use the parameter to constrain the scope. EHRbase discards both carriers. In the record, a bare projection over EHRs scoped to one freshly created EHR comes back at the request’s own row limit instead of the single scoped row, and a scope naming an EHR that does not exist answers an ordinary result set rather than reporting the unknown EHR. Reproduced live against a composed EHRbase, its own response metadata discloses the query it actually executed, with a row limit appended and no EHR predicate added; the header behaves exactly like the parameter. Naming the EHR inside the AQL works, so what is dropped is specifically the request-level scope. Stated at its true strength: the released REST text puts support for these parameters at SHOULD and the semantics are the service model’s, so this is a divergence from service-model semantics on a released carrier, not the breach of a REST-level MUST. - A
405carries noAllowheader, which RFC 9110 makes mandatory on that status; and the versioned-EHR_STATUScontainer names its owner with a lower-caseehrtype where the Reference Model’s object reference spells itEHR. - (1.1.0-grounded) The template upload refuses a JSON
Accept. Asked forapplication/jsonit answers406(the refusal body the record captured reads"No acceptable representation") and serves XML only, while the released parameter enumeration for that header lists JSON first. This single refusal is what makes most content-chapter rows inconclusive: the runner’s provisioning uploads ask for JSON, EHRbase refuses, and the case’s ground never exists.
Two things the record deliberately does not support, and therefore are not claimed here: it captures no response bodies beyond the few a case records, so no error text is quoted above that the record does not contain; and it says nothing about EHRbase’s canonical-XML root namespace: the case that would have established it never got past provisioning, and the negotiated-namespace siblings are out of scope for EHRbase’s declared options.
Where the difference is a reading of a silence
Some red rows are not EHRbase’s fault, and are called out rather than counted.
- Which version a version-less stored-query store lands on. A store with no version in the URL has to land at some version, and no released sentence says which. Our suite pins the lowest slot because a suite must pin something; EHRbase continues the existing series instead, so a store beside an already-stored higher version takes the next one up. Both are defensible readings, so those two rows record a difference of house convention. The open question is with openEHR, not with either implementation.
One half of the EHR-scope row above is a silence too, and a much narrower one than this page previously claimed: the service model declares an error for a query scoped to an EHR that does not exist, and the released REST text binds that error to no status, so which refusal a conformant server owes is genuinely open, and our suite resolves it to not-found. What the scope does is not open, which is why that row is published as a divergence rather than as a reading.
Every silence is recorded in the runner’s typed ambiguity register and reported upstream: a specification silence is never resolved privately, and never resolved by looking at what a server happens to do.
Performance
Both systems run the same committed step-load stress instrument
(veredictum stress) on their own freshly seeded cnf.scale.10k
corpus: the geometric ladder climbs until the system leaves the envelope
(p99 over the budget or errors past tolerance), then bisects to the
maximum sustainable throughput. Every number derives from the two
committed stress.json reports; each load step embeds re-checkable
histograms and, where sampled, per-container resource telemetry.
| max sustainable throughput | worst p99 at the knee | DB peak CPU at the knee | SUT peak RSS at the knee | |
|---|---|---|---|---|
| ferroehr | 512 req/s | 134 ms | 101 % | 247 MB |
| EHRbase | 0 req/s | — ms | — % | — MB |
A stress report is exploration evidence: it earns no conformance class (classes are earned exclusively by the hour-long measured class runs) and carries no class vocabulary — the chart shows where each system breaks.
Reading the stress table honestly
The step-load ladder declares a rate sustained only if the whole envelope holds at that rate: the latency budget and the error budget. EHRbase’s ladder found no sustained rung, and its own report records two simultaneous breaches on every rung it tried, down to the lowest one:
- The error tolerance. Composition commits and updates, contribution
commits,
EHR_STATUSupdates, directory creates and the item-tag operations error deterministically; these are the same wire refusals the conformance section above lists, met again under load. - The latency budget, on the ward-round query, by more than an order of magnitude.
Because both breaches are already present at the ladder’s lowest rung, neither is a throughput ceiling: EHRbase’s entry in the generated table says the envelope never held, not how fast the server can go, and it must not be read as a speed comparison. The per-operation latencies its report carries are honest observations from those same rungs.
Method, in one paragraph
The conformance instrument derives every expected outcome from the openEHR
specifications (never from either server’s observed behaviour) and runs
against real composed deployments of both systems (scripts/conformance.sh,
CONF_SUT=ehrbase for the EHRbase side). The stress instrument drives the same
hospital-simulation workload (admissions, observations, medication rounds, lab
contributions, chart reviews, corrections, discharges) built from official CKM
templates with seeded determinism, so both servers receive byte-identical
requests from the same corpus; latencies are coordinated-omission-corrected
from each request’s planned arrival instant, and the ladder bisects to the last
rate held inside the envelope. Each run records its own environment, and the
two runs’ environments differ. The full method chapters:
Conformance · Benchmarks.