Correspondance FHIR R4
Generated from the source of truth:
apps/ehr-lab/src/infrastructure/fhir/mappers.ts(to<X>/from<X>functions + theSYSobject) andrepositories.ts(search parameters). This document faithfully describes the FHIR shape produced/read by the app — regenerate it if the mappers change.
Cross-cutting conventions: the FHIR store (Blaze) is the app's only datastore (directory, charts, messaging, documents, audit). References are written Type/${id} and read back by the last segment. Searches read a single page bounded by _count=200 and ask for _sort=-_lastUpdated, so the bound keeps the most recently written resources (unsorted, Blaze returns id order — chronological for server-assigned ids — and the bound would keep the oldest). Display order is computed app-side (Blaze only sorts on _lastUpdated). Both parameters come from bounded() in search.ts. All app-specific system/identifiers/extensions are grouped in the SYS object of the mappers. No authentication: the chosen identity signs the audit. Write origin: every write (FhirHttp.create, update, createBinary) carries meta.source = urn:ehr-lab (EHRLAB_SOURCE, http.ts), so a reader of the shared store (a hospital information system gateway, a workflow) can tell what the EHR wrote; an update by copy replaces meta.source and keeps the other meta fields read back (tags, profiles). The maintenance scripts that PUT directly (scripts/repair-*, classify-organ-systems) set it with withEhrLabSource(). DELETE carries no meta. Clinical coding systems: the domain carries short terminology keys in Coded.system (snomed, cim10, meddra, ucum); the mappers write their FHIR system (http://snomed.info/sct, http://hl7.org/fhir/sid/icd-10, urn:posos:meddra, http://unitsofmeasure.org) through toFhirSystem and read it back to the short key through fromFhirSystem (systems.ts). urn:posos:cim10, the URN written for CIM-10 before the HL7 URI, is also read back as cim10. A system outside that table passes through unchanged in both directions. This applies to Condition (history and problems), AllergyIntolerance, Encounter.reasonCode, MedicationRequest.reasonCode, dosage quantities and the note focus. scripts/repair-coding-systems.ts (pnpm --filter ehr-lab repair:coding-systems, -- --write to apply) rewrites stored Condition, AllergyIntolerance, Encounter and MedicationRequest codings whose system is a short key or urn:posos:cim10.
Patient & directory
patient — Patient (clinical identity)
FHIR resource: Patient (FHIR R4 standard, no profile; toPatient/fromPatient mappers)
Minimal clinical identity: last name/first name, sex, date of birth, INS identifier. The whole chart references the patient by its id. A patient may have no INS (created without one by the app, or written by another system), or a non-qualified INS: it is read as a provisional identity (provisional). toPatient emits name+gender+birthDate+active, plus identifier(INS) only when the draft has one; register() rejects an INS that is already present and runs no duplicate check without INS. GROUP-WIDE GLOBAL CONVENTIONS: references written Type/${id} and read back via refId()=last segment; every search bounded by bounded() (search.ts): _count=200 + _sort=-_lastUpdated, display order APP-SIDE (localeCompare 'fr'), Blaze only sorts on _lastUpdated; systems in the SYS object of mappers.ts.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Patient.id |
— | Assigned by Blaze on creation; never written by toPatient. fromPatient requires r.id (r.id!). | |
familyName |
Patient.name[0].family |
✔ | toPatient: name:[{family, given:[givenName]}]. Read name[0].family, fallback "?". validatePatientDraft requires a non-empty string (trimmed). | |
givenName |
Patient.name[0].given[0] |
✔ | A single first name: given is a 1-element array. Read name[0].given[0], fallback "". Required by validatePatientDraft. | |
sex |
Patient.gender |
FHIR administrative-gender codes: male | female | unknown (no explicit system written; raw value) | ✔ | Sex domain (male|female|unknown) mapped 1:1 to gender; validatePatientDraft rejects any other value. Read: male/female preserved, any other value (including other/null) → unknown. |
birthDate |
Patient.birthDate |
✔ | ISO YYYY-MM-DD. validatePatientDraft enforces the format ^\d{4}-\d{2}-\d{2}$ and rejects a future date. Read fallback "". | |
ins |
Patient.identifier[?(system==SYS.ins)].value |
SYS.ins = urn:oid:1.2.250.1.213.1.4.8 (INS-NIR) | — | 15-digit INS/NIR identifier (13 + check key), normalizeIns strips spaces/./-. Optional: validatePatientDraft reads an absent, null or blank value as no INS (provisional identity) and rejects a non-string value or one that is not 15 digits (DomainError, HTTP 400). toPatient: identifier:[{system:SYS.ins, value:ins}] (the only identifier) when present, no identifier otherwise. Read: identifier whose system==SYS.ins, "" if absent. |
provisional |
Patient.identifier + Patient.extension[fr-core-identity-reliability].extension[identityStatus].valueCoding.code |
https://hl7.fr/ig/fhir/core/StructureDefinition/fr-core-identity-reliability (IDENTITY_RELIABILITY_EXT, read only) | ✔ | Read only, never written. true when there is no INS identifier, or when the FR Core identity status is PROV (INS entered but not qualified, as written by a hospital information system gateway). Shown as « Identité provisoire » on the patients page. |
Markers (identifiers · categories · extensions · status/priority)
- INS identifier (uniqueness key) —
urn:oid:1.2.250.1.213.1.4.8 \| <NIR 15 digits>· SYS.ins constant. Unique identifier set; register() searches${SYS.ins}\|<ins>and throws DomainError if a chart already exists. - status / active —
active = true· Fixed boolean set at creation (no status field on Patient). A patient withactive = false(e.g. merged into another record by another system) is dropped from search. - identity reliability (read only) —
fr-core-identity-reliability· FR Core complex extension; only itsidentityStatussub-extension is read (PROV→ provisional). Kept out ofSYS: it is not an app system and the app never writes it. - resourceType —
Patient
Search: FhirPatients.search(query): input cleaned (removes \s.-); if /^\d{4,}$/ → params identifier=${SYS.ins}|<digits>, otherwise name=. Adds _count=200 and _sort=-_lastUpdated. Rows with
active = false dropped, the rest mapped (patients without INS are kept, provisional = true), sorted by familyName (fr). A patient without INS is found by name only. byId(id): http.read Patient/${SYS.ins}|<ins> (reject duplicate); then http.create(toPatient).
Notes: PatientDraft = same fields without id, ins optional; validatePatientDraft checks the untyped request body (a missing field or one of another type is a DomainError, HTTP 400) and normalizes before register. No business status (soft-delete not implemented for Patient).
practitioner — Healthcare professional
FHIR resource: Practitioner (FHIR R4 standard; toPractitioner/fromPractitioner mappers)
Directory caregiver (freely chosen identity, no auth). qualification carries the profession. toPractitioner takes Omit<Practitioner,'id'> & {key}; used at seeding — the repo (FhirDirectory) only reads practitioners.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Practitioner.id |
— | Assigned by Blaze; fromPractitioner requires r.id. | |
familyName |
Practitioner.name[0].family |
✔ | toPractitioner: name:[{family, given:[givenName]}]. Read name[0].family, fallback "?". | |
givenName |
Practitioner.name[0].given[0] |
✔ | Single first name (given[0]). Read fallback "". | |
title |
Practitioner.name[0].prefix[0] |
— | Carried title (« Dr », « Pr »). Written only when present; read back as prefix[0] || undefined. STORED rather than derived — the profession does not determine it. When absent, practitionerTitle() (domain, not FHIR) falls back to the profession's customary title. |
|
profession |
Practitioner.qualification[0].code.coding[?(system==SYS.profession)].code |
SYS.profession = urn:ehr-lab:profession ; MOCKED value-set: physician | nurse | resident | pharmacist | midwife | ✔ | toPractitioner: qualification:[{code:{coding:[{system:SYS.profession, code:profession}]}}]. Read: coding whose system==SYS.profession; fallback "physician" if missing/unknown. |
key (outside the domain type) |
Practitioner.identifier[?(system==SYS.practitioner)].value |
SYS.practitioner = urn:ehr-lab:practitioner | ✔ | Stable business key supplied on write (toPractitioner receives {key}), NOT exposed in the Practitioner interface and NOT read back by fromPractitioner. Used only as identifier value (and to target the directory search). |
Markers (identifiers · categories · extensions · status/priority)
- practitioner identifier —
urn:ehr-lab:practitioner \| <key>· SYS.practitioner constant; system used alone (identifier=${SYS.practitioner}\|) to list the directory. - qualification (profession) —
code.coding.system = urn:ehr-lab:profession· SYS.profession constant; code ∈ mocked ProfessionCode. - status / active —
active = true· Fixed at creation.
Search: FhirDirectory.practitioners(): http.search Practitioner { identifier:${SYS.practitioner}| (system alone, any value), _count:200, _sort:'-_lastUpdated' } → map fromPractitioner, sort by familyName (fr). No practitioner creation/modification via the repo (toPractitioner reserved for seeding).
Notes: practitionerDisplay(p, label?) formats "Firstname LASTNAME — profession label" on the domain side. ProfessionCode is a mocked value-set, replaceable by a real reference.
service — Care unit (CareService)
FHIR resource: Organization (type = department; toOrganization/fromOrganization mappers)
Facility service/unit (short code + name). Referenced as the serviceProvider of visits and as recipient (Organization/…) in messaging.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Organization.id |
— | Assigned by Blaze; fromOrganization requires r.id; also serves as fallback for code and name. | |
code |
Organization.identifier[?(system==SYS.service)].value |
SYS.service = urn:ehr-lab:service | ✔ | Short code (URG, MED, CAR…). toOrganization: identifier:[{system:SYS.service, value:code}]. Read: value of the SYS.service identifier, fallback to r.id if missing. |
name |
Organization.name |
✔ | toOrganization: name = s.name. Read: r.name, fallback "Service". |
Markers (identifiers · categories · extensions · status/priority)
- service identifier —
urn:ehr-lab:service \| <code>· SYS.service constant; system alone (identifier=${SYS.service}\|) to list services. - type (hospital department) —
http://terminology.hl7.org/CodeSystem/organization-type \| dept \| Hospital Department· SYS.orgType constant. type:[{coding:[{system:SYS.orgType, code:'dept', display:'Hospital Department'}]}] — fixed, not read back by fromOrganization. - status / active —
active = true· Fixed at creation.
Search: FhirDirectory.services(): http.search Organization { identifier:${SYS.service}|, _count:200, _sort:'-_lastUpdated' } → map fromOrganization, sort by name (fr).
Notes: No service creation/modification via the repo (toOrganization reserved for seeding).
caregiver↔service assignment (ServiceAssignment)
FHIR resource: PractitionerRole (link only, no coded role; toPractitionerRole/fromPractitionerRole mappers)
N–N link "this caregiver handles the patients of this service". Used for messaging routing (the threads/urgent items of the service's charts are visible to its team). The (professional, service) pair identifies the assignment — search before create (idempotent).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
practitionerId |
PractitionerRole.practitioner.reference |
✔ | Writes Practitioner/${practitionerId}. Read: refId(practitioner.reference) (last segment after '/'), fallback "". |
|
serviceId |
PractitionerRole.organization.reference |
✔ | Writes Organization/${serviceId}. Read: refId(organization.reference), fallback "". |
Markers (identifiers · categories · extensions · status/priority)
- status / active —
active = true· No code (no PractitionerRole.code): the only semantics is the professional↔service link. assignments() keeps only active=true, then filters roles missing practitionerId or serviceId.
Search: FhirDirectory.assignments(): search PractitionerRole { active:'true', _count:200, _sort:'-_lastUpdated' } → map fromPractitionerRole, filter (practitionerId && serviceId). Idempotence findRole(a): search { practitioner:Practitioner/${id}, organization:Organization/${id}, _count:'1' } → rows[0]?.id. assign(a): if findRole exists → no-op, otherwise http.create(toPractitionerRole). unassign(a): findRole then http.delete PractitionerRole/
Notes: No own identifier: uniqueness rests on the (practitioner, organization) pair via findRole. No business status; active always true at creation.
Visits (Encounter)
Venue (encounter.ts — Venue aggregate / episode of care)
FHIR resource: Encounter (FHIR R4 standard, no profile). Two modes carried by the same resource: ambulatory consultation (class AMB) and hospitalization (class IMP).
Care of a patient in a service by a professional, over a period. (Venue = the domain aggregate for a hospital visit / episode of care.) Written by toEncounter, read back by fromEncounter, updated by patchEncounter (mappers.ts:178-304). The transitions (openEncounter/transfer/discharge/closeConsultation) are PURE domain functions (encounter.ts); the infra only persists via create/update. An update is a copy of the stored version (patchEncounter): only the fields below are rewritten, each only when its business value changes; everything else is kept. Strictly clinical scope (neither billing nor logistics).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Encounter.id |
— | On creation absent (generated by Blaze); an update rewrites the stored resource under its id. On READ required (fromEncounter does r.id!). | |
class ('ambulatory' | 'inpatient') |
Encounter.class.code (+ .system, .display) |
http://terminology.hl7.org/CodeSystem/v3-ActCode (SYS.encounterClass) | ✔ | class is a SIMPLE Coding (not a CodeableConcept). Write: inpatient→code 'IMP' display 'inpatient encounter', otherwise 'AMB' display 'ambulatory'. Read (fromEncounter): code 'IMP'→'inpatient', ANY OTHER code→'ambulatory'. Only IMP/AMB are mapped. |
status ('in-progress' | 'finished') |
Encounter.status |
✔ | Native FHIR EncounterStatus code (no system). Write: the business value is written as-is (e.status). Read: 'finished' or 'entered-in-error' → 'finished', ANY OTHER value → 'in-progress'. The app writes only these two values; 'entered-in-error' comes from another system (retired visit, e.g. merged into a hospital system's visit). | |
patientId |
Encounter.subject.reference |
Patient/{id} | ✔ | Literal reference Patient/${e.patientId}, written at creation only: an update keeps the stored subject (a merge of identities repoints it). Read via refId(subject.reference). |
serviceId (responsible service) |
Encounter.serviceProvider.reference |
Organization/{id} | ✔ | Reference Organization/${e.serviceId}. Transfer (domain transfer()) modifies ONLY this field. Read via refId(serviceProvider.reference). |
practitionerId (professional who initiated the visit) |
Encounter.participant[0].individual.reference |
Practitioner/{id} | ✔ | Created with a single participant, no coded type/role: Practitioner/${e.practitionerId}. Read: participant[0].individual.reference. An update rewrites participant[0] only if the practitioner changes and keeps the following participants. |
reason (clinical reason, free text) |
Encounter.reasonCode[0].text |
— | reasonCode is an array; the whole block is emitted only if e.reason OR e.reasonCode is present. text = e.reason. Read: reasonCode[0].text. An update rewrites reasonCode[0] only if the reason changes and keeps the following entries. | |
reasonCode (structured reason, Coded: code/display/system) |
Encounter.reasonCode[0].coding[0] (.code, .display, .system) |
cim10→http://hl7.org/fhir/sid/icd-10, snomed→http://snomed.info/sct (toFhirSystem/fromFhirSystem (systems.ts)) |
— | Written in the same reasonCode[0] as the text, with coding=[{ system: toFhirSystem(e.reasonCode.system), code, display }]. Read: takes coding[0] WITHOUT a system filter, system mapped back by fromFhirSystem; display falls back to code if missing. |
start |
Encounter.period.start |
✔ | Always emitted (ISO). Domain: iso() guarantees a valid date at opening. Read: period.start ?? ''. | |
end |
Encounter.period.end |
— | Emitted only if e.end present. Set by discharge() (hospital discharge) and closeConsultation() on the transition to 'finished'. Read: period.end (optional). | |
dischargeDisposition (discharge outcome) |
Encounter.hospitalization.dischargeDisposition.coding[0].code |
urn:ehr-lab:discharge-disposition (SYS.dischargeDisposition) | — | hospitalization block emitted only if e.dischargeDisposition present: coding=[{ system: SYS.dischargeDisposition, code }] (no display on write). An update sets only hospitalization.dischargeDisposition and keeps the other hospitalization fields. Read: hospitalization.dischargeDisposition.coding[0].code. MOCKED value-set (MockTerminology.dischargeDispositions): home, other-hcf, rehab, long, aadvice, exp. |
Markers (identifiers · categories · extensions · status/priority)
- resourceType (fixed) —
Encounter· Standard FHIR R4 resource, no profile/meta.profile set by the mapper. - CLASS value-set (Encounter.class) —
http://terminology.hl7.org/CodeSystem/v3-ActCode \| IMP (inpatient encounter) · AMB (ambulatory)· SYS.encounterClass. Two codes only; class is a simple Coding. Used as the 'class' search param on the repo side (see query). - FHIR status —
in-progress \| finished· Subset of EncounterStatus, written/read without system. Encounter has NO priority in this model.entered-in-error(written by another system) is read as closed and kept out of every list. - OUTCOME value-set (dischargeDisposition) —
urn:ehr-lab:discharge-disposition \| home · other-hcf · rehab · long · aadvice · exp· SYS.dischargeDisposition. Value-set entirely MOCKED in MockTerminology (infrastructure/terminology/mock.ts): home=discharge home, other-hcf=transfer to another facility, rehab=rehabilitation care (SSR), long=long-term care (EHPAD nursing home), aadvice=against medical advice, exp=death (deceased). - structured-reason system (reasonCode.coding) —
http://hl7.org/fhir/sid/icd-10 (SYS.cim10)for a CIM-10 reason · The short key of the supplied Coded object is mapped by toFhirSystem; the read filters on no system (coding[0]). - identifier / category / extension / priority / location —
NONE· The mappers write NEITHER identifier, NOR category, NOR extension, NOR priority, NOR location — unlike other resources (Patient, MedicationRequest, Communication…). Those written by another system (e.g. visit and stay numbers, locations,meta.tag, extensions) are kept by an update.
Search: byId: GET Encounter/{id} (http.read). forPatient(patientId): search Encounter {subject:'Patient/{id}', _count:200, _sort:'-_lastUpdated'}, entered-in-error dropped, app-side sort start desc. Helper ofService(serviceId): {'service-provider':'Organization/{id}', _include:'Encounter:subject', _count:200, _sort:'-_lastUpdated', ...}, entered-in-error dropped. inpatientsOf(serviceId): ofService + {class:'IMP', status:'in-progress'}, sort start desc. consultationsOf(serviceId, sinceIso): two ofService searches — {class:'AMB', status:'in-progress'} (open consultations, no date bound) and {class:'AMB', date:'ge{YYYY-MM-DD}'} (recent, finished included) — merged and deduplicated by Encounter id, sorted in-progress first then start desc. The open search has its own bound so that finished visits of a high-volume service never push current patients off the board. create: http.create(toEncounter). update: http.read of the stored Encounter (absent → DomainError « Venue introuvable. »), then http.update(patchEncounter(current, e)). Search params used: subject, service-provider, class, status, date, _include=Encounter:subject.
Notes: Display order always APP-SIDE (Blaze only sorts on _lastUpdated); searches bounded at 200, most recently written first. _include:'Encounter:subject' brings Patients into the same bundle for service views (ofService rebuilds a Map id→Patient and drops Encounters with no resolved patient). The server search param is named 'service-provider' (with a hyphen) whereas the FHIR field is serviceProvider. The app never soft-deletes an Encounter; an entered-in-error Encounter written by another system is dropped from forPatient/ofService (isRetiredEncounter) and read as closed by byId, so no transition applies to it.
Problems/Past history & Allergies
history — Past-history item / Current problem (MedicalHistoryItem)
FHIR resource: Condition (R4). A SINGLE resource serves two business uses, distinguished by toHistoryCondition(h, confirmed): confirmed=false → PAST HISTORY (background terrain, category=problem-list-item, WITHOUT verificationStatus) ; confirmed=true → CURRENT PROBLEM / established diagnosis (category=encounter-diagnosis + verificationStatus=confirmed).
Background chart data (patient background), coded via SNOMED/ICD-10 terminology when available, free-text label otherwise. Mappers: toHistoryCondition/fromHistoryCondition (apps/ehr-lab/src/infrastructure/fhir/mappers.ts). Repo: FhirHistory (repositories.ts). The write never depends on a visit (no encounter field).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Condition.id |
✔ | Assigned by the store (r.id!). Omitted on creation. | |
patientId |
subject.reference |
✔ | Writes Patient/${patientId}; read back via refId(). CAUTION: Condition uses subject (unlike AllergyIntolerance which uses patient). |
|
label |
code.text |
✔ | Always written (= h.label). On read: code.text ?? code.coding[0].display ?? "?". | |
coding.code |
code.coding[0].code |
— | code.coding block written only if h.coding present. On read, coding kept only if code.coding[0].code is defined (otherwise coding=undefined). | |
coding.display |
code.coding[0].display |
— | On read: display ?? code (falls back to code if display missing). | |
coding.system |
code.coding[0].system |
snomed→http://snomed.info/sct, cim10→http://hl7.org/fhir/sid/icd-10 (toFhirSystem/fromFhirSystem (systems.ts)) |
— | Comes from the terminology (h.coding.system), written through toFhirSystem and read back through fromFhirSystem. No default value. |
recordedAt |
recordedDate |
✔ | ISO string. On read: recordedDate ?? "". | |
recordedBy |
recorder.reference |
✔ | Writes Practitioner/${recordedBy}; read back via refId(). |
|
coverageAck.at |
extension[?(url==SYS.coverageAck)].valueAnnotation.time |
SYS.coverageAck = urn:ehr-lab:coverage-ack | — | Coverage acknowledgement ("seen, no treatment expected") on a CURRENT PROBLEM. Written by FhirHistory.setCoverageAck, never by toHistoryCondition. Read back only if time is present. |
coverageAck.by |
extension[?(url==SYS.coverageAck)].valueAnnotation.authorReference |
— | — | Writes Practitioner/${by}; read via refId(). Comes from Annotation, the standard R4 type that already carries author + time — nothing invented for it. |
Markers (identifiers · categories · extensions · status/priority)
- clinicalStatus (clinical status, always written) —
http://terminology.hl7.org/CodeSystem/condition-clinical \| active· System written as a literal in the mapper (NOT via SYS). Alwaysactive, for both variants (past history and problem). - verificationStatus (CURRENT PROBLEM only) —
http://terminology.hl7.org/CodeSystem/condition-ver-status \| confirmed· Written ONLY if confirmed=true (createProblem). Absent for a past-history item. System as a literal. This status is what surfaces the Condition as an active problem and excludes it from past history. - verificationStatus — soft-delete —
http://terminology.hl7.org/CodeSystem/condition-ver-status \| entered-in-error· FhirHistory.remove() does not delete: it re-reads the Condition and rewrites verificationStatus=entered-in-error (Blaze forbids physical deletion because it is referenced by the audit). Filtered on read via isEnteredInError(). - category — PAST HISTORY (confirmed=false) —
SYS.conditionCategory (http://terminology.hl7.org/CodeSystem/condition-category) \| problem-list-item· display="Problem List Item". category=[{coding:[{...}]}]. The only case that actually uses the SYS.conditionCategory constant. - category — CURRENT PROBLEM (confirmed=true) —
http://terminology.hl7.org/CodeSystem/condition-category \| encounter-diagnosis· display="Encounter Diagnosis". System written as a LITERAL (same URL as SYS.conditionCategory but not referenced via the constant). - extension — coverage acknowledgement —
SYS.coverageAck (urn:ehr-lab:coverage-ack) \| valueAnnotation· Set/cleared by FhirHistory.setCoverageAck (read Condition, rebuild extensions minus ours, PUT). The Annotation carries authorReference + time + a fixed French text. Clearing writesextension: []rather than dropping the field, so the removal is explicit. Read by fromHistoryCondition into MedicalHistoryItem.coverageAck.
Search: FhirHistory.forPatient (PAST HISTORY): GET Condition?subject=Patient/{id}&category=problem-list-item&_count=200&_sort=-_lastUpdated, then app-side filter !isEnteredInError(verificationStatus), sort recordedAt desc. FhirHistory.problemsOf (CURRENT PROBLEMS): GET Condition?subject=Patient/{id}&clinical-status=active&verification-status=confirmed&_count=200&_sort=-_lastUpdated, same filter + sort. create → toHistoryCondition(item) [confirmed=false] ; createProblem → toHistoryCondition(item, true) ; remove → read Condition/{id} then update verificationStatus=entered-in-error. No byId and no search param on the code.
Notes: setCoverageAck is the only writer of the coverage extension and it preserves every other extension on the resource. The mapper does NOT set an onset field (no onsetDateTime/onsetString written or read), contrary to what the lead suggested — do not invent it. No encounter, severity, note, or bodySite field. The read takes code.coding[0] directly (first coding, no system filter, unlike Observation which goes through codingOf). The past-history vs problem distinction rests entirely on category + presence of verificationStatus=confirmed, never on a domain flag (MedicalHistoryItem carries no confirmed field).
problem — Problem on one of the four lists (Problem)
FHIR resource: Condition (R4). Mappers: toProblemCondition/fromProblemCondition (apps/ehr-lab/src/infrastructure/fhir/problem-mappers.ts) — a module of their own, distinct from the MedicalHistoryItem mappers above, which they do NOT replace: the two read the same resources through different models (see Backward compatibility).
The four-list model (domain/problem.ts): active, stay, multidisciplinary, history. Repo: FhirHistory.allProblems / addProblem / updateProblem / attachmentsOf / setAttachedObservations (repositories.ts). SYS for these three URNs lives in infrastructure/fhir/systems.ts, a leaf module with no imports, so the domain tests can read it without the @/ alias.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Condition.id |
✔ | Assigned by the store. Omitted on creation. | |
patientId |
subject.reference |
✔ | Patient/${patientId}; read via refId(). |
|
label |
code.text |
✔ | Always written. On read: code.text ?? coding[0].display ?? "?". |
|
coding |
code.coding[0] |
snomed→http://snomed.info/sct, cim10→http://hl7.org/fhir/sid/icd-10 (toFhirSystem/fromFhirSystem (systems.ts)) |
— | Written only if present. An uncoded problem is accepted and flagged in the UI, never rejected. The system is written through toFhirSystem and read back through fromFhirSystem. |
list |
category[0].coding[0].code |
SYS.problemList = urn:ehr-lab:problem-list | ✔ | OUR category, written FIRST — it is what governs the read. |
list (standard mirror) |
category[1].coding[0].code |
SYS.conditionCategory | problem-list-item · encounter-diagnosis | ✔ | Nearest standard category, for third-party readers: stay → encounter-diagnosis, the other three → problem-list-item. Never written instead of ours — the standard codes cannot carry multidisciplinary nor separate active from history. |
status |
clinicalStatus + verificationStatus |
condition-clinical · condition-ver-status | ✔ | provisional → clinical active + verification provisional ; active → active + confirmed ; resolved → clinical resolved, verification unchanged (confirmed) ; refuted → verification refuted + clinical inactive. |
parentId |
extension[?(url==SYS.parentProblem)].valueReference |
SYS.parentProblem = urn:ehr-lab:parent-problem | — | The umbrella. Extension for want of a standard element: Condition has no parent link. |
supersedes.id |
extension[?(url==SYS.supersedes)].valueReference.reference |
SYS.supersedes = urn:ehr-lab:supersedes | — | Written by precise(): the hypothesis this diagnosis replaced. |
supersedes.label |
extension[?(url==SYS.supersedes)].valueReference.display |
— | — | The display carries the label so the replaced hypothesis stays readable without a second round-trip. |
encounterId |
encounter.reference |
— | Set on a stay problem: the visit it is the focus of, which is what allows reviewing it at discharge. |
|
recordedAt / recordedBy |
recordedDate / recorder.reference |
— | Same convention as the history item. Written only when non-empty: a Condition written by another system (a workflow through the FHIR plugin) may carry neither, is read back with "", and an edit leaves them absent rather than writing an empty dateTime or a reference without an id, which the store rejects. |
|
coverageAck |
extension[?(url==SYS.coverageAck)].valueAnnotation |
SYS.coverageAck | — | Same extension as the history item — Annotation (authorReference + time). Written by setCoverageAck. |
organSystems |
extension[?(url==SYS.organSystem)].valueCode (REPEATED) |
SYS.organSystem = urn:ehr-lab:organ-system | — | Organ-system categories from the SNOMED subsumption classifier (PMC7062335), computed AT ENTRY and persisted. Repeated because multiple membership is the normal case (19.4 % in the paper). DERIVED data: a third-party reader can ignore it, and pnpm --filter ehr-lab classify:problems recomputes it. Read back only for codes this version of the domain knows — an unknown code is dropped rather than invented. |
| attached lab results | evidence[0].detail[*].reference |
— | — | Observation/${id}, MANY-TO-MANY: an observation may be carried by several problems. evidenceDetail([]) writes nothing rather than an empty block. |
Markers
evidence.detailfor lab results, notObservation.reason—Observationhas NOreasonelement in R4, so the link is carried problem-side, where the specification puts it.evidencemeans "what supports": exact for a creatinine under renal failure, slightly stretched for a monitoring INR. The stretch is preferred over inventing an extension — a third-party FHIR reader understandsevidence.Treatments are NOT written here — the treatment ↔ problem link already exists on the prescription (
MedicationRequest.reasonReference/reasonCode, from the indication of the selected regimen) and is derived at read time. Duplicating it problem-side would create two truths.updateProblemre-reads then merges —{...r, ...rebuilt, id}: evidence, notes and foreign extensions survive an edit of the list, status, label, code or umbrella.refuted= the assertion is withdrawn. A ruled-out or replaced hypothesis. The READ tests verification before clinical status: the resource carriesclinicalStatus: inactive, and reading that first would map it back toresolved, i.e. to a disease the patient had. Such a Condition is filtered out of every list bybuildProblemBoard, and its trace is thesupersedesreference on the replacement.scripts/repair-refuted-hypotheses.tsrepairs the ones written before this status existed (signature: referenced by another Condition'ssupersedesand filed as resolved past history); it writes nothing without--write.
Backward compatibility (read): a Condition without our category is read under the OLD convention — encounter-diagnosis or verificationStatus=confirmed → active, everything else → history. unconfirmed and differential are read as provisional: they say the same thing, and ignoring them would let another system's hypothesis pass for an established diagnosis. An existing chart must not empty itself because the model changed; the seed exercises this path for real.
Search: allProblems → GET Condition?subject=Patient/{id}&_count=200&_sort=-_lastUpdated, app-side isEnteredInError filter, then fromProblemCondition. Note it does NOT filter on category: the four lists live in one query, and legacy Conditions have to come back too.
allergy — Allergy / Intolerance (Allergy)
FHIR resource: AllergyIntolerance (R4). toAllergyIntolerance/fromAllergyIntolerance mappers (mappers.ts). FhirAllergies repo (repositories.ts).
Background chart data (anamnesis), coded via the Posos terminology (active ingredient / excipient / allergen class) or a free-text label. Independent of visits.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
AllergyIntolerance.id |
✔ | Assigned by the store (r.id!). | |
patientId |
patient.reference |
✔ | Writes Patient/${patientId}; read back via refId(). CAUTION: patient field (NOT subject) — hence the patient search param in the repo. |
|
label |
code.text |
✔ | Always written (= a.label). On read: code.text ?? code.coding[0].display ?? "?". | |
coding.code |
code.coding[0].code |
— | code.coding block written only if a.coding present. On read, coding kept only if code.coding[0].code defined. | |
coding.display |
code.coding[0].display |
— | On read: display ?? code. | |
coding.system |
code.coding[0].system |
snomed→http://snomed.info/sct (toFhirSystem/fromFhirSystem (systems.ts)) |
— | Comes from the Posos terminology (a.coding.system), written through toFhirSystem and read back through fromFhirSystem. No default value. |
recordedAt |
recordedDate |
✔ | ISO string. On read: recordedDate ?? "". | |
recordedBy |
recorder.reference |
✔ | Writes Practitioner/${recordedBy}; read back via refId(). |
Markers (identifiers · categories · extensions · status/priority)
- clinicalStatus (always written) —
http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical \| active· Local constant ALLERGY_CLINICAL in mappers.ts (not in SYS). Alwaysactiveat creation. Not read back by fromAllergyIntolerance. - verificationStatus (always written) —
http://terminology.hl7.org/CodeSystem/allergyintolerance-verification \| confirmed· Local constant ALLERGY_VERIF (not in SYS). Alwaysconfirmedat creation. - verificationStatus — soft-delete —
http://terminology.hl7.org/CodeSystem/allergyintolerance-verification \| entered-in-error· FhirAllergies.remove() re-reads then rewrites verificationStatus=entered-in-error (referenced by the audit → no physical deletion). Filtered on read via isEnteredInError().
Search: FhirAllergies.forPatient: GET AllergyIntolerance?patient=Patient/{id}&_count=200&_sort=-_lastUpdated, then app-side filter !isEnteredInError(verificationStatus), map fromAllergyIntolerance, sort recordedAt desc. create → toAllergyIntolerance(item). remove → read AllergyIntolerance/{id} then update verificationStatus=entered-in-error. No byId, no search param on criticality/category.
Notes: IMPORTANT — fields from the lead ABSENT from the mappers, NOT to be invented: criticality, category (food/medication/environment/biologic), type (allergy/intolerance), reaction/manifestation, onset. None are written or read. The only clinical granularity is code + label; clinicalStatus/verificationStatus are fixed (active/confirmed). The repo's search param is patient (aligned with the patient.reference field, different from Condition which uses subject).
Medications
Prescription (domain/medication.ts — hospital prescription line, with optional BIM dosage)
FHIR resource: MedicationRequest (R4) — toMedicationRequest / fromMedicationRequest mappers
The INTENT to prescribe a medication (identified by a code from the Posos terminology), attached to a visit and a prescriber. intent always 'order'. When the prescriber entered a structured dosage (guided prescription flow) or picked a recommended dosage from the Posos MedicalDB (BIM), it is carried as a structured dosageInstruction[0] (text, route, doseAndRate, timing.repeat — including intake moments when and infusion duration) plus reasonCode for the chosen indication. When the guided flow computed an administration plan, its summary rides on the dispense-plan extension and the individual planned intakes are materialized as Tasks (see PlannedAdministration below). May be a discharge prescription (category discharge). Discontinuation materialized by status 'stopped' + stop-date extension (and cancels the remaining planned-intake Tasks).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Written ONLY if provided (update branch: ...(p.id ? { id } : {})). On read: r.id! (non-null ensured). | |
patientId |
subject.reference |
Patient/{id} | ✔ | Writes Patient/${p.patientId}. Read via refId(subject.reference). |
encounterId |
encounter.reference |
Encounter/{id} | ✔ | Writes Encounter/${p.encounterId}. Read via refId(encounter.reference). |
drug.code |
medicationCodeableConcept.coding[0].code |
✔ | System carried by coding[0].system (see drug.system). Read via codingOf(r,'medicationCodeableConcept') WITHOUT system filter → takes the 1st coding found. Default '?' if missing. | |
drug.display |
medicationCodeableConcept.coding[0].display AND medicationCodeableConcept.text |
✔ | Written both in coding[0].display and in .text. Read: coding.display, otherwise fallback to medicationCodeableConcept.text, otherwise '?'. | |
drug.system |
medicationCodeableConcept.coding[0].system |
variable (pass-through): e.g. urn:posos:cis, urn:posos:medicabase | ✔ | Medication terminology system passed through as-is from DrugCoding; NOT a SYS constant (examples urn:posos:cis / urn:posos:medicabase cited in the domain docstrings). Read: med.system, otherwise ''. |
status |
status |
✔ | Write: 'active' if status==='active', otherwise 'stopped' (no other value emitted). Read: 'active' if r.status==='active', otherwise 'stopped'. | |
prescriberId |
requester.reference |
Practitioner/{id} | ✔ | Writes Practitioner/${p.prescriberId}. Read via refId(requester.reference). |
authoredAt |
authoredOn |
✔ | ISO creation timestamp (domain: new Date().toISOString()). Read: r.authoredOn, otherwise ''. | |
stoppedAt |
extension[url=urn:ehr-lab:stopped-at].valueDateTime |
urn:ehr-lab:stopped-at (SYS.stoppedAt) | — | Extension emitted ONLY if p.stoppedAt defined. Read: extension whose url===SYS.stoppedAt → valueDateTime. |
isDischarge |
category[].coding[](system=…medicationrequest-category, code=discharge) |
http://terminology.hl7.org/CodeSystem/medicationrequest-category (SYS.mrCategory) | discharge | — | category emitted ONLY if p.isDischarge. Fixed coding {system:SYS.mrCategory, code:'discharge', display:'Discharge'}. Read: isDischarge=true if a category coding has system===SYS.mrCategory AND code==='discharge' (otherwise field omitted). |
dosage.text |
dosageInstruction[0].text |
— | Whole dosageInstruction emitted ONLY if p.dosage present. Semi-structured sentence built from BIM parts (dose · frequency · duration · route). Read: a dosageInstruction[0] without text is ignored. |
|
dosage.dose |
dosageInstruction[0].doseAndRate[0].doseQuantity | .doseRange |
UCUM (ucum → http://unitsofmeasure.org) |
— | Quantity when the BIM gives a single value, Range (low/high) otherwise; unit + UCUM code passed through (incl. mg/kg). |
dosage.timing |
dosageInstruction[0].timing.repeat |
— | frequency/frequencyMax/period/periodMax/periodUnit (UCUM time code straight from the BIM: h, d…), count for a single intake; boundsPeriod when a start is supplied (end calculated from the duration in days), otherwise boundsDuration (Quantity) or boundsRange. | |
dosage.asNeeded |
dosageInstruction[0].asNeededCodeableConcept.text |
— | PRN indication; read back as text. No automatic planned intake Tasks. | |
dosage.maxDailyDose |
dosageInstruction[0].maxDosePerPeriod |
UCUM | — | Numerator is the dose; denominator is 1 day (d). Read when the denominator is exactly 1 d. |
dosage.infusion |
dosageInstruction[0].extension[url=https://ehr-lab.posos.fr/fhir/StructureDefinition/continuous-infusion].valueString |
App extension | — | JSON preparation, prescribed rate and optional titration; sanitized on read, malformed data ignored. Writes the calculated mL/h as doseAndRate[0].rateQuantity instead of a dose quantity. No repeated intake Tasks or automatic titration. |
dosage.timing.whens |
dosageInstruction[0].timing.repeat.when[] |
HL7 event-timing codes (MORN, NOON, EVE, HS) | — | Intake moments from the structured entry. Read back as-is. |
dosage.timing.adminDuration |
dosageInstruction[0].timing.repeat.duration + .durationUnit |
UCUM (min) | — | Duration of ONE administration (infusion). Emitted when the structured entry sets it. |
dispensePlan |
extension[url=urn:ehr-lab:dispense-plan].valueString |
urn:ehr-lab:dispense-plan (SYS.dispensePlan) | — | Compact JSON summary {intakeText, times?, occurrenceCount?} of the retained administration plan (intake composition in specialties + daily times). The occurrences themselves are Tasks. Read tolerantly: unparseable JSON → field absent. |
dosage.route |
dosageInstruction[0].route |
urn:posos:roa (SYS.roa) | — | route.text = designation; coding emitted only when the BIM provides an EDQM ROA code. |
dosage.instructions |
dosageInstruction[0].additionalInstruction[].text |
— | BIM guidelines (conduites à tenir), text-only. | |
dosage.nature |
dosageInstruction[0].extension[url=urn:ehr-lab:dosage-nature].valueCoding |
urn:ehr-lab:dosage-nature (SYS.dosageNature) | — | BIM nature (STANDARD, ATTACK, MAXIMAL…) as code + French label as display — context FHIR has no slot for. |
dosage.sourceLabel |
dosageInstruction[0].extension[url=urn:ehr-lab:dosage-source].valueString |
urn:ehr-lab:dosage-source (SYS.dosageSource) | — | Source of the recommendation (e.g. « Résumé des Caractéristiques du Produit »). |
dosage.indication |
reasonCode[0] |
cim10→http://hl7.org/fhir/sid/icd-10, snomed→http://snomed.info/sct, meddra→urn:posos:meddra | — | reasonCode.text = indication label (condition + modifiers); coding emitted when the BIM condition carries one (short BIM terminology keys mapped by toFhirSystem, read back by fromFhirSystem). |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
MedicationRequest· Fixed. - intent (FHIR) —
order· Fixed, always 'order' (no other intent emitted). - status —
active \| stopped· Fixed to these two values (direct mapping of the domain status). - category (discharge prescription) —
SYS.mrCategory = http://terminology.hl7.org/CodeSystem/medicationrequest-category ; code=discharge, display=Discharge· Conditional (isDischarge). Distinguishes the discharge-prescription line from hospital treatment. - stop-date extension —
SYS.stoppedAt = urn:ehr-lab:stopped-at (valueDateTime)· Conditional (stoppedAt present). - dosage-nature extension —
SYS.dosageNature = urn:ehr-lab:dosage-nature (valueCoding)· Conditional (dosage picked). BIM nature of the regimen, needed for a lossless round-trip. - dosage-source extension —
SYS.dosageSource = urn:ehr-lab:dosage-source (valueString)· Conditional (source known). - dispense-plan extension —
SYS.dispensePlan = urn:ehr-lab:dispense-plan (valueString, compact JSON)· Conditional (guided flow computed a plan).
Search: byId: http.read('MedicationRequest', id). forPatient: GET MedicationRequest?subject=Patient/{patientId}&_count=200&_sort=-_lastUpdated, then sort authoredAt descending APP-SIDE (byDateDesc). create → http.create(toMedicationRequest) ; update → http.update(toMedicationRequest, with id).
Notes: No business identifier on the resource. Discontinuing a line = update status→stopped + stoppedAt extension (no deletion). The dosage is OPTIONAL: a line prescribed without picking a BIM recommendation carries no dosageInstruction/reasonCode, exactly as before. The client-sent dosage payload is defensively re-sanitized server-side (sanitizeDosage) before reaching the mapper.
HomeMedication (domain/medication.ts — usual / home treatment, collected at admission)
FHIR resource: MedicationStatement (R4) — toMedicationStatement / fromMedicationStatement mappers
A STATEMENT (the patient takes…), distinct from the hospital prescription: collected at admission (manual entry or Scan&Go transcription). Always status 'active' on write; deletion = soft-delete status 'entered-in-error' (drops out of lists because the search is status=active).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Not emitted on creation (no id branch in toMedicationStatement). Read: r.id!. | |
patientId |
subject.reference |
Patient/{id} | ✔ | Writes Patient/${m.patientId}. Read via refId(subject.reference). |
drug.code |
medicationCodeableConcept.coding[0].code |
✔ | Read via codingOf(r,'medicationCodeableConcept') without system filter; default '?'. | |
drug.display |
medicationCodeableConcept.coding[0].display AND medicationCodeableConcept.text |
✔ | Written in coding[0].display and .text. Read: coding.display, otherwise fallback to .text, otherwise '?'. | |
drug.system |
medicationCodeableConcept.coding[0].system |
variable (pass-through): e.g. urn:posos:cis, urn:posos:medicabase | ✔ | Passed through as-is from DrugCoding; not a SYS constant. Read: med.system, otherwise ''. |
recordedAt |
dateAsserted |
✔ | ISO timestamp of collection (draftHomeMedication: new Date().toISOString()). Read: r.dateAsserted, otherwise ''. | |
recordedBy |
informationSource.reference |
Practitioner/{id} | ✔ | Writes Practitioner/${m.recordedBy}. Read via refId(informationSource.reference). |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
MedicationStatement· Fixed. - status —
active (write) ; entered-in-error (soft-delete)· toMedicationStatement forces 'active'. remove() does update status→'entered-in-error' ; since forPatient filters status=active, the entry disappears from lists.
Search: forPatient: GET MedicationStatement?subject=Patient/{patientId}&status=active&_count=200&_sort=-_lastUpdated, sort recordedAt descending APP-SIDE. create → http.create(toMedicationStatement). remove(id): read then update status→entered-in-error.
Notes: No encounter (unlike the prescription): it's a patient statement, not attached to a visit. No business identifier.
Administration (domain/medication.ts — administration of a dose at a point in time)
FHIR resource: MedicationAdministration (R4) — toMedicationAdministration / fromMedicationAdministration mappers
The FACT that a dose was given at a point in time, ALWAYS attached to a prescription line (request → MedicationRequest). status always 'completed'.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Not emitted on creation. Read: r.id!. | |
patientId |
subject.reference |
Patient/{id} | ✔ | Writes Patient/${a.patientId}. Read via refId(subject.reference). |
encounterId |
context.reference |
Encounter/{id} | ✔ | CAUTION: 'context' field (not 'encounter' like MedicationRequest). Writes Encounter/${a.encounterId}. Read via refId(context.reference). |
prescriptionId |
request.reference |
MedicationRequest/{id} | ✔ | Link to the prescription line. Writes MedicationRequest/${a.prescriptionId}. Read via refId(request.reference). |
drug.code |
medicationCodeableConcept.coding[0].code |
✔ | Copied from the prescription (administer() takes prescription.drug). Read via codingOf without filter; default '?'. | |
drug.display |
medicationCodeableConcept.coding[0].display (+ .text on write) |
✔ | Written in coding[0].display AND .text. BUT on read, fromMedicationAdministration does NOT fall back to .text: med.display otherwise '?' directly (different from Prescription/HomeMedication). | |
drug.system |
medicationCodeableConcept.coding[0].system |
variable (pass-through): e.g. urn:posos:cis, urn:posos:medicabase | ✔ | Passed through as-is. Read: med.system, otherwise ''. |
at |
effectiveDateTime |
✔ | Instant of administration (ISO, truncated to the minute on the domain side). Read: r.effectiveDateTime, otherwise ''. | |
performerId |
performer[0].actor.reference |
Practitioner/{id} | ✔ | Writes performer:[{actor:{reference:Practitioner/${a.performerId}}}]. Read via refId(performer[0].actor.reference). |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
MedicationAdministration· Fixed. - status —
completed· Fixed, always 'completed' (no partial/cancelled administration modeled).
Search: forPatient: GET MedicationAdministration?subject=Patient/{patientId}&_count=200&_sort=-_lastUpdated, sort 'at' descending APP-SIDE. create → http.create(toMedicationAdministration). No byId/update/remove exposed in the repository.
Notes: No business identifier. The 'request' link to MedicationRequest is structural (an administration always exists for a line). FHIR field 'context' for the visit (a specificity of MedicationAdministration in R4).
PlannedAdministration (domain/medication.ts — planned intake of the administration plan)
FHIR resource: Task (R4) — toPlannedAdministrationTask / fromPlannedAdministrationTask mappers
One PLANNED intake of the administration plan, materialized at prescription time (guided flow) — same Task pattern as the workflow validations, so the platform can observe the plan by polling. Lifecycle: requested (planned) → completed (administered — writes the MedicationAdministration at the same time) or cancelled (line stopped: manual stop, switch or discharge). Materialization is bounded (≤ 60 Tasks per line; no treatment duration → 48 h horizon).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Not emitted on creation. Read: r.id!. | |
patientId |
for.reference |
Patient/{id} | ✔ | Read via refId(for.reference). |
prescriptionId |
focus.reference |
MedicationRequest/{id} | ✔ | The line the intake belongs to — the cancellation key when the line is stopped. |
at |
executionPeriod.start |
✔ | Scheduled instant of the intake (ISO). | |
doseText |
description |
✔ | Intake composition in specialties (« 5,3 mL — AMOXICILLINE ARROW 500 mg / 5 mL… »). | |
status |
status |
requested→planned, completed→done, cancelled→cancelled | ✔ | Any other Task status reads as 'planned'. |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
Task· Fixed. - intent (FHIR) —
order· Fixed. - code —
SYS.plannedAdministration = urn:ehr-lab:planned-administration ; code=administer· Distinguishes these Tasks from the validation Tasks (urn:ehr-lab:validation).
Search: forPatient: GET Task?patient=Patient/{id}&code=urn:ehr-lab:planned-administration|administer&_count=200&_sort=-_lastUpdated, sort 'at' ASCENDING app-side (a MAR reads forward). byId: http.read + code check. complete(id): update status→completed. cancelForPrescription: GET Task?focus=MedicationRequest/{id}&code=…&status=requested, then update each →cancelled.
Notes: Administering a planned intake goes through the same domain guard as a manual administration (administer()), then completes the Task — the pancarte and the platform see the same transition. Tasks are never deleted.
Biology & derived values
lab — Lab result (LabResult / Analyte)
FHIR resource: Observation (R4 standard). toObservation/fromObservation mappers in mappers.ts (l.385-416). No FHIR profile declared; use: measurement of a LOINC analyte with a numeric value + UCUM unit at a given instant.
A biological measurement of an analyte (LOINC code from the mocked value-set MockTerminology.labAnalytes): numeric value, UCUM unit, sampling/result timestamp, performer. Manual entry (testbed for workflows triggered on the arrival of results). DERIVED VALUES (e.g. eGFR) have NO separate mapper: if recorded they would be an identical Observation (same toObservation, category laboratory, one LOINC code). No eGFR analyte exists in the mocked value-set (see markers). Created by ehr.recordLab (application/ehr.ts l.665).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
Observation.id |
— | Read-only (fromObservation: r.id!). Assigned by the Blaze store at creation; never set on write. | |
patientId |
subject.reference |
✔ | Writes Patient/${l.patientId}. Read back via refId(subject.reference). REQUIRED (always written). |
|
encounterId |
encounter.reference |
— | Writes Encounter/${l.encounterId} ONLY if encounterId present (conditional spread). Optional on the domain side (encounterId?: string). recordLab fills it with the active visit if one exists. |
|
analyte.code |
code.coding[0].code |
http://loinc.org (SYS.loinc) | ✔ | LOINC code of the analyte. On write code.coding[0] = { system: SYS.loinc, code: l.analyte.code }. On read: codingOf(r, "code", SYS.loinc) ?? codingOf(r, "code") — the LOINC-system coding is preferred, otherwise fallback to the 1st coding. Default "?" if missing. |
analyte.label |
code.coding[0].display + code.text |
✔ | Written both in the LOINC coding display AND in code.text (redundancy). On read: code?.display ?? code.text ?? "?". | |
analyte.unit |
valueQuantity.unit |
http://unitsofmeasure.org (SYS.ucum) | ✔ | No FHIR field dedicated to the analyte unit: fromObservation reconstructs analyte.unit from valueQuantity.unit. On the domain side LabResult.unit = analyte.unit (recordLabResult enforces unit = analyte.unit). |
value |
valueQuantity.value |
✔ | Numeric value. On read value?.value ?? Number.NaN (NaN if missing). The domain requires Number.isFinite(value) on entry. | |
unit |
valueQuantity.unit (and valueQuantity.code) |
http://unitsofmeasure.org (SYS.ucum) | ✔ | On write valueQuantity = { value, unit: l.unit, system: SYS.ucum, code: l.unit } — the unit serves BOTH as label (unit) AND as UCUM code (code), the same string. On read only valueQuantity.unit is read (value.code ignored). |
at |
effectiveDateTime |
✔ | Instant of sampling/result (ISO). Read as-is, default "". Domain: rejects a date in the future (5-min tolerance) and ISO normalized via toISOString(). | |
performerId |
performer[0].reference |
✔ | Writes performer: [{ reference: Practitioner/${l.performerId} }] (DIRECT reference, no actor wrapper like MedicationAdministration). Read via refId(performer[0].reference). Filled by ctx.practitionerId. |
Markers (identifiers · categories · extensions · status/priority)
- status (FHIR-mandatory) —
status = "final"· Hard-coded constant on write (toObservation), never read back by fromObservation. All created Observations are final. - category (laboratory category — key marker) —
category[0].coding[0] = { system: SYS.obsCategory = "http://terminology.hl7.org/CodeSystem/observation-category", code: "laboratory", display: "Laboratory" }· Hard-coded. Used as the repo's search filter (category=laboratory). Not read back in fromObservation. This is the marker that attaches a derived value (eGFR) to biology if it were recorded. - analyte-code system —
SYS.loinc = "http://loinc.org"· System of the code coding. codingOf prefers this system on read. - quantity system —
SYS.ucum = "http://unitsofmeasure.org"· valueQuantity.system. UCUM code = the unit string itself. - identifier / extension —
NONE· The Observation carries neither a business identifier nor an extension. No biology-specific SYS.* identifier/extension constant. - priority —
N/A· Observation has no priority; not applicable to biology (the priority/urgency field exists only for Communication). - derived values (eGFR) —
NO dedicated code/category in the code· The mocked value-set (terminology/mock.ts ANALYTES) contains: 2160-0 serum creatinine µmol/L, 2823-3 potassium mmol/L, 2951-2 sodium mmol/L, 718-7 hemoglobin g/dL, 6690-2 leukocytes 10*3/µL, 1988-5 CRP mg/L, 4548-4 HbA1c %, 2345-7 blood glucose mmol/L, 1751-7 albumin g/L, 6301-6 INR 1. NO eGFR analyte. The mention 'detect-from-egfr' (ValidationCard.tsx l.20) is a WORKFLOW NAME that proposes a Condition — not a derived Observation. An eGFR, if entered, would reuse toObservation identically.
Search: FhirLabs.forPatient (repositories.ts l.353): GET Observation?subject=Patient/{id}&category=laboratory&_count=200&_sort=-_lastUpdated, then APP-SIDE sort by at descending (byDateDesc — Blaze only sorts on _lastUpdated). category=laboratory passed as CODE ALONE (no system). FhirLabs.create: http.create(toObservation(result)). No byId, no additional filter, no soft-delete (unlike Condition/Allergy/Document).
Notes: Source of truth: mappers.ts (toObservation/fromObservation l.385-416) + repositories.ts (FhirLabs l.350-365) + domain/lab.ts (LabResult, Analyte, recordLabResult) + terminology/mock.ts (ANALYTES value-set). Point of attention: valueQuantity.code == valueQuantity.unit (the UCUM unit string doubles as the code, a testbed simplification). No reference range (referenceRange), no interpretation/abnormal flag are mapped — fields absent from the mappers, not invented here.
vitals — Body measurement (VitalMeasure: height / weight)
FHIR resource: Observation (R4 standard) — toVitalObservation / fromVitalObservation mappers
A dated body measurement — height or weight — in the canonical unit (cm / kg). Category vital-signs (vs laboratory for LabResult), fixed LOINC codes: 8302-2 Body height, 29463-7 Body weight. Every new entry is APPENDED (history preserved); the chart banner and the BIM dosage matching use the LATEST measurement of each kind + a derived BMI (summarizeVitals, kg/m², computed — never persisted). Created by ehr.recordVitals (audited), displayed/entered via the chart banner (VitalsActions).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
kind |
code.coding[0] |
http://loinc.org — 8302-2 (height) | 29463-7 (weight) | ✔ | Read: any vital-signs Observation whose LOINC code is not one of these two is IGNORED (fromVitalObservation → undefined, filtered by the repo) — other vital signs may coexist. |
value |
valueQuantity.value |
UCUM cm | kg (unit doubles as code) |
✔ | Canonical unit enforced by the domain (VITAL_UNITS); plausibility bounds 20–260 cm / 0.3–400 kg. |
patientId |
subject.reference |
Patient/{id} | ✔ | |
at |
effectiveDateTime |
✔ | Never in the future (5-min tolerance). | |
recordedBy |
performer[0].reference |
Practitioner/{id} | ✔ |
Markers: status final (hard-coded) · category vital-signs (search filter of FhirVitals) · no identifier/extension.
Search: FhirVitals.forPatient: GET Observation?subject=Patient/{id}&category=vital-signs&_count=200&_sort=-_lastUpdated, unknown codes filtered, app-side sort by at desc.
Documents & Scan&Go
document (PatientDocument) — Chart document (PDF: report, letter, scanned prescription…)
FHIR resource: DocumentReference (R4) + Binary (raw bytes). Mappers: toDocumentReference / fromDocumentReference (mappers.ts:560-612). Binary via FhirHttp.createBinary/readBinary (http.ts).
The document metadata lives in a DocumentReference; the bytes in a separate FHIR Binary, referenced by content.attachment.url = "Binary/
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
DocumentReference.id |
— | Read-only (r.id!); assigned by Blaze at creation. | |
patientId |
subject.reference |
✔ | Writes "Patient/ |
|
title |
description (+ content[0].attachment.title) |
✔ | Written in description AND attachment.title. Read: description ?? attachment.contentType ?? "Document". Required (validateUpload: non-empty title). | |
category |
category[0].coding[0].code |
urn:ehr-lab:document-category (SYS.docCategory) | — | Written ONLY if present. Read via codingOf(r,'category',SYS.docCategory).code (exact system match, no fallback). Mocked value-set: report / letter / prescription / imaging / other (terminology/mock.ts DOCUMENT_CATEGORIES). |
contentType |
content[0].attachment.contentType |
✔ | Also written as Binary.contentType on POST /Binary and used as the Accept of GET /Binary. Read: attachment.contentType ?? "application/pdf". Domain enforces "application/pdf" (validateUpload). | |
binaryUrl |
content[0].attachment.url |
✔ | Value "Binary/ |
|
size |
content[0].attachment.size |
— | Bytes (bytes.byteLength on upload). Optional; read as-is. | |
createdAt |
date (+ content[0].attachment.creation) |
✔ | Written in date AND attachment.creation (ISO, new Date().toISOString() on upload). Read from r.date ?? "". | |
authorId |
author[0].reference |
✔ | "Practitioner/ |
|
transcribed |
extension[url=urn:ehr-lab:transcribed].valueBoolean |
urn:ehr-lab:transcribed (SYS.transcribed) | — | "transcribed" extension. NOT written by toDocumentReference: added by FhirDocuments.markTranscribed (update, idempotent: ignores if the url already exists). Read: true if an extension has url===SYS.transcribed AND valueBoolean===true, otherwise false. |
Markers (identifiers · categories · extensions · status/priority)
- status (write) —
current (fixed)· Always "current" at creation. Also used as a search filter (status=current) and as a soft-delete mechanism (set to "entered-in-error" by remove). - category system —
urn:ehr-lab:document-category \| {report\|letter\|prescription\|imaging\|other}· SYS.docCategory. Codes = mocked value-set DOCUMENT_CATEGORIES. category block entirely omitted if no category. - transcription extension —
urn:ehr-lab:transcribed \| valueBoolean=true· SYS.transcribed. Marks "prescription already transcribed (Scan & Go)" → neutralizes the "Transcribe" CTA. - Binary (byte storage) —
resourceType Binary, Content-Type = document's contentType· createBinary: POST /Binary as a FHIR JSON resource (contentType,data= base64 bytes,meta.source), since a raw-bytes POST carries nometa. readBinary: GET /Binary/{id} with Accept = contentType (404/410 → undefined). The DocumentReference points to it via attachment.url = "Binary/". - soft-delete —
status = entered-in-error· remove() does not physically delete (the DocumentReference is referenced by the creation AuditEvent → Blaze forbids the delete). Updating status → drops out of lists (search status=current). The Binary stays inert.
Search: FhirDocuments.forPatient: DocumentReference?subject=Patient/
Notes: Domain guardrails (validateUpload, document.ts): title required, contentType === application/pdf, size > 0, size ≤ 15 MB. binaryUrl expected in the "Binary/
Scan&Go source (ScanSourceDoc) — source PDF of the scanned prescription, archived to the chart
FHIR resource: Input: DocumentReference read from the PARTNER FHIR STORE (Posos), pointed to by an extension of the scan's RequestGroup (scango/posos.ts pullSource:168-191). Output: archived as a local DocumentReference + Binary via the same toDocumentReference (application/ehr.ts archiveScanDocument:613-635).
ScanSourceDoc {contentType, title, bytes} has no dedicated to/from mapper: it is PULLED from the partner FHIR store (not from the postMessage) then re-injected into the local document store via documents.upload with category "prescription" and marked transcribed. The session join is done on the partner patient identifier.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
title |
(constant) → DocumentReference.description + attachment.title |
✔ | FIXED value on the pull side: "Ordonnance scannée (Scan & Go)". Serves as the idempotency key on archival (existing.title === source.title). | |
contentType |
warehouse DocumentReference.content[0].attachment.contentType → local attachment.contentType |
✔ | Read from the partner attachment; att.contentType ?? "application/pdf". | |
bytes |
warehouse content[0].attachment.data (base64) OR attachment.url → local Binary |
✔ | Either decoded from attachment.data (base64), or fetched from attachment.url (temporary public URL ~24h, no auth). undefined if bytes empty. Become the local Binary. | |
category (on archival) |
DocumentReference.category[0].coding[0].code |
urn:ehr-lab:document-category (SYS.docCategory) | — | Set to "prescription" (label "Ordonnance scannée") by archiveScanDocument. |
transcribed (on archival) |
extension[url=urn:ehr-lab:transcribed].valueBoolean=true |
urn:ehr-lab:transcribed (SYS.transcribed) | — | markTranscribed(doc.id) called right after the upload: the archived PDF IS the source of the transcription in progress → already transcribed. |
patientId / sessionId |
subject.reference (local) ; warehouse side: Patient.identifier |
id://partner (PARTNER_SYS) | ✔ | sessionId = patientId (correlation key returned to Posos). Warehouse join via patient identifier "id://partner| |
authorId (on archival) |
DocumentReference.author[0].reference |
✔ | "Practitioner/<ctx.practitionerId>". |
Markers (identifiers · categories · extensions · status/priority)
- session ↔ warehouse join —
id://partner \| <sessionId>· PARTNER_SYS. Searches the latest RequestGroup by partner patient identifier. - RequestGroup → source DocumentReference link —
extension url contains "prescriptionDocumentReference" \| valueReference.reference· The scan's RequestGroup references the source DocumentReference; docId cleaned of the "urn:uuid:" and "DocumentReference/" prefixes. - archival idempotency —
category === 'prescription' && title === source.title· archiveScanDocument does not re-create if a prescription document with the same title already exists for the patient (returns {archived:false, reason:'déjà archivé'}). - local result —
DocumentReference status=current, category=prescription, extension transcribed=true + Binary· Once archived, it is a standard PatientDocument (same fields as the document entity above).
Search: scango/posos.ts latestRequestGroup: RequestGroup?subject:Patient.identifier=id://partner|
Notes: pull(sessionId) (proposed lines) and pullSource(sessionId) (source PDF) query the SAME latest RequestGroup. The Bundle→lines mapping (mapScannedPrescription) is on the domain side (scango.ts) and does not belong to mappers.ts. Warehouse access uses an OAuth2/IAP token (warehouseGet).
Messaging (threads) & audit
Thread — discussion root (Thread / root ThreadEntry)
FHIR resource: Communication (root = WITHOUT partOf). Written by toThreadRoot; header re-read by threadRootFields; read as the 1st entry by fromCommunicationEntry. Recognized as root by isThreadRoot (no partOf).
The originating message of a thread (notification / chart note / workflow alert). recipient present = user notification; recipient absent = chart note. The root carries the header (title, patient, urgency, sender, audience) AND its content is the thread's 1st entry.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id (Thread.id / ThreadEntry.id) |
id |
— | FHIR server id; the Thread's id = the id of the root Communication. | |
title (Thread.title) |
topic.text |
— | Written only if input.title provided. Read by threadRootFields.title. Also serves as content fallback for the 1st entry (fromCommunicationEntry: body || topic || ""). | |
patientId (Thread.patientId) |
subject.reference |
— | Writes Patient/{id} only if patientId provided. Read via refId(subject.reference). |
|
urgency (Thread.urgency / ThreadEntry.urgency) |
priority |
✔ | Writes the raw Urgency value ("routine" | "urgent"). Read by urgencyOf: priority ∈ {urgent,stat,asap} → "urgent", otherwise "routine". The urgency of the assembled thread = MAX(root, entries) (buildThread, outside the mappers). | |
createdBy / senderId (Thread.createdBy, ThreadEntry.authorId) |
sender.reference |
✔ | Writes Practitioner/{senderId}. Read by threadRootFields.createdBy and fromCommunicationEntry.authorId via refId(); || undefined → absent = clinical automaton. |
|
content (root's ThreadEntry.content) |
payload[0].contentString |
✔ | Content of the originating message. Read by payloadOf() which joins all payload[].contentString with "\n". @mentions are rendered INLINE in this text (no dedicated FHIR field); the recipient audience is carried by recipient. | |
audience.practitioners (Thread.audience) |
recipient[].reference |
— | Writes Practitioner/{id} for each professional; recipient block omitted if audience empty. Read by audienceFromRecipients: Practitioner/ refs → practitioners. addAudience merges (dedup) and rewrites recipient. |
|
audience.services (Thread.audience) |
recipient[].reference |
— | Writes Organization/{id} for each service. Read by audienceFromRecipients: Organization/ refs → services. |
|
createdAt / sentAt (Thread.createdAt, ThreadEntry.sentAt) |
sent |
✔ | Writes input.at. Read with fallback: sent ?? meta.lastUpdated ?? "". | |
summary (Thread.summary) |
extension(url=SYS.noteSummary).valueString |
SYS.noteSummary (urn:ehr-lab:note-summary) | — | One-sentence automated summary, written by the synthesis workflow — never typed. Read by noteSummaryOf; patched by withNoteAnnotations (which preserves the other extensions). |
focus (Thread.focus) |
extension(url=SYS.noteFocus).valueCodeableConcept |
SNOMED CT (http://snomed.info/sct) | — | Focus of a focused nursing note (« transmission ciblée »). Derived, never typed: written only by withNoteAnnotations (the synthesis workflow's patch), never by toThreadRoot — there is no focus field in the compose form. focusConcept maps the short snomed system key to the canonical URI (toFhirSystem); noteFocusOf reads it back to the short key (fromFhirSystem) (display falls back to CodeableConcept.text, then to the code). |
archivedAt (Thread.archivedAt) |
extension(url=SYS.threadArchivedAt).valueDateTime |
SYS.threadArchivedAt (urn:ehr-lab:thread-archived-at) | — | Archiving is TEAM-WIDE, like acknowledgement — it lives on the root, not in the per-professional Basic. Written by withThreadArchive (which drops both extensions when unarchiving) and read by threadArchiveOf. buildThread derives archived = archivedAt && lastAt <= archivedAt, so later activity makes the thread resurface without an explicit unarchive. |
archivedBy (Thread.archivedBy) |
extension(url=SYS.threadArchivedBy).valueString |
SYS.threadArchivedBy (urn:ehr-lab:thread-archived-by) | — | Practitioner id behind the last archive gesture. Written alongside archivedAt, removed with it. |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
Communication· Fixed. - status —
completed· Constant on write (toThreadRoot). Not read back. - category[0] (note kind) —
SYS.noteKind (urn:ehr-lab:note-kind) \| observation \| transmission· Nature of the chart entry: medical observation (physician) vs focused nursing note. Read by noteKindOf, which defaults toobservationwhen absent — pre-existing threads and platform-written alerts need no migration. It is deliberately FIRST in the array: the platform'sfhir-communication-receivedbinding mapscategory.0.coding.0.codeonto thecategoryfield of the declared eventCommunicationReceived, so a trigger filter can select the note kind with no binding change. - category (root nature) —
SYS.communicationCategory (urn:ehr-lab:communication-category) \| thread-root· coding without display. Marks a thread root. isThreadRoot does NOT rely on it (it tests the absence of partOf), but the category distinguishes root vs ack (threadEvent). Read by system, so its position in the array does not matter. - priority (values) —
routine \| urgent· urgencyOf also accepts urgent/stat/asap → urgent on read (tolerance to standard FHIR data).
Search: threadsFor({practitionerId,serviceIds,patientIds}): 2 to 3 merged Communication searches, all with part-of:missing=true & _revinclude=Communication:part-of & _count=200 & _sort=-_lastUpdated — (a) recipient=Practitioner/{id},Organization/{serviceIds...} (list = OR) ; (b) sender=Practitioner/{id} ; (c) if patientIds: subject=Patient/{ids...}. threadsForPatient(patientId): Communication?subject=Patient/{id}&part-of:missing=true&_revinclude=Communication:part-of&_count=200&_sort=-_lastUpdated. byId(threadId): read Communication/{threadId} (checks isThreadRoot) then Communication?part-of=Communication/{threadId}&_count=200&_sort=-_lastUpdated. Assembly: assembleThreads dedup by id, attaches children via parentThreadId, sort by lastAt desc.
Notes: Root = Communication without partOf (isThreadRoot). buildThread computes app-side: acknowledgedBy/At (1st entry kind=ack), lastAt (max of sentAt), urgency (max), unread/archived (via ThreadState), validationRunId (validationRunIdOf). start() = create(toThreadRoot); annotate() = read + update(withNoteAnnotations) — a patch of the summary/focus extensions only. Blaze only sorts on _lastUpdated → app-side sort.
ThreadEntry — thread entry: reply (message) or acknowledgement (ack)
FHIR resource: Communication (child = WITH partOf → root). Written by toThreadEntry; read by fromCommunicationEntry; attached to the root by parentThreadId.
A follow-up entry in a thread. kind="message" → text reply; kind="ack" → acknowledgement (empty content, marked by category threadEvent). The acknowledgement is GLOBAL (the 1st ack counts for everyone, cf. acknowledgement()).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id (ThreadEntry.id) |
id |
— | FHIR server id. | |
threadId (parent) |
partOf[0].reference |
✔ | Writes Communication/{threadId}. Read by parentThreadId via refId(partOf[0].reference). The presence of partOf = it is NOT a root. |
|
kind (ThreadEntry.kind) |
category[0].coding[0] (ack) | presence of payload (message) |
SYS.threadEvent (urn:ehr-lab:thread-event) | ack | ✔ | If kind="ack": writes category=[{coding:[{system:SYS.threadEvent,code:"ack"}]}] and NO payload. If kind="message": writes payload, NO category. Read: isAck = codingOf(category, SYS.threadEvent)?.code==="ack". |
authorId / senderId (ThreadEntry.authorId) |
sender.reference |
✔ | Writes Practitioner/{senderId}. Read via refId() || undefined. |
|
content (ThreadEntry.content) |
payload[0].contentString |
— | Written only for kind="message". Read by payloadOf; for an ack, content forced to "" (isAck). | |
sentAt (ThreadEntry.sentAt) |
sent |
✔ | Writes input.at; read sent ?? meta.lastUpdated ?? "". | |
urgency (ThreadEntry.urgency) |
priority |
— | toThreadEntry does NOT write priority → read by urgencyOf(undefined) = "routine" by default. (The domain intends an urgent reply to bump the thread up, but the entry write does not set priority.) |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
Communication· Fixed. - status —
completed· Constant on write. - category ack —
SYS.threadEvent (urn:ehr-lab:thread-event) \| ack· Present only on an acknowledgement; absent on a message. - mentions —
(no FHIR field)· resolveMentions/buildMentionables are on the domain side; @mentions stay in contentString, no separate FHIR mapping for entries.
Search: Retrieved with the root via _revinclude=Communication:part-of (threadsFor / threadsForPatient) or explicitly via Communication?part-of=Communication/{threadId}&_count=200&_sort=-_lastUpdated (byId). addEntry() = create(toThreadEntry). No standalone entry search outside a thread's context.
Notes: fromCommunicationEntry is common to root + entries. buildThread sorts entries by sentAt asc and puts the root first. acknowledgement() returns {by,at} of the 1st entry kind=ack.
Validation root — request for a human decision (suspended workflow)
FHIR resource: Communication (special root, WITHOUT sender). Written by toValidationRoot; runId read by validationRunIdOf; materialized (idempotent) by ensureValidationRoot.
Root of a VALIDATION thread: materializes into the chart a suspended platform run so it can be discussed/decided. No sender (= clinical automaton). The identifier carries the runId (reconciliation key + idempotency). Created only on the 1st interaction (before that, synthetic thread id validation:{runId}).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id (Thread.id) |
id |
— | Server id; returned by ensureValidationRoot. | |
runId (Thread.validationRunId) |
identifier[0].value |
SYS.validationRun (urn:ehr-lab:validation-run) | ✔ | Writes identifier=[{system:SYS.validationRun,value:runId}]. Read by validationRunIdOf: identifier.find(system===SYS.validationRun).value. Reconciliation key with the platform and idempotency key. |
patientId (Thread.patientId) |
subject.reference |
— | Patient/{id} if provided. Read by threadRootFields. |
|
title (Thread.title) |
topic.text AND payload[0].contentString |
✔ | The title is written BOTH in topic.text and in payload[0].contentString (content = title). | |
createdAt (Thread.createdAt) |
sent |
✔ | Writes input.at; read sent ?? meta.lastUpdated. |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
Communication· Fixed. - status —
completed· Constant. - priority —
routine· Fixed (a validation is always written as routine; urgency can rise via the thread entries). - category (root) —
SYS.communicationCategory (urn:ehr-lab:communication-category) \| thread-root· Same category as any thread root. - sender —
(absent)· No sender → createdBy undefined = clinical automaton.
Search: ensureValidationRoot({runId,...}): Communication?identifier={SYS.validationRun}|{runId}&_count=1 ; if found returns its id, otherwise create(toValidationRoot). Then searched/assembled like an ordinary root (threadsFor/byId).
Notes: Synthetic thread while not materialized: domain id validation:{runId} (VALIDATION_THREAD_PREFIX, isSyntheticValidationId, runIdFromSyntheticId). validation? (actionable) is computed app-side by reconciliation with the platform, outside the mappers.
Decision: the decision is written on the platform's validation Task, not on this root. FhirWorkflowValidations.resolve(runId) searches Task?identifier={SYS.validationRun}|{runId}&status=requested&_sort=-_lastUpdated&_count=1 and updates that Task (copy of the read resource, status = completed/rejected, owner = Practitioner/{id}); no requested Task → DomainError « Validation introuvable… ». A run can chain several validations, each with its own Task under the same run identifier, and the platform applies a decision only to the validation named by the Task's urn:ehr-lab:validation-pending identifier: the search therefore targets the Task still pending, the most recent one when an expired validation left its Task open.
ThreadState — read state of a thread per professional
FHIR resource: Basic (one resource per (professional, thread) pair; upsert). Written by toThreadState; read by fromThreadState.
Unread = last thread event later than readAt. A single Basic per (professional, thread), author = the professional. Archiving is NOT here: it is a team-wide fact carried by the thread root (see Thread), so a discussion closed by one clinician stops notifying the others.
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Server id; used for the in-place update (upsert preserves extension). | |
practitionerId (author of the state) |
author.reference |
✔ | Writes Practitioner/{practitionerId}. Used as search filter (author=). |
|
threadId |
extension(url=SYS.threadReadThread).valueString |
SYS.threadReadThread (urn:ehr-lab:thread-read-thread) | ✔ | valueString extension carrying the id of the root Communication. Read by fromThreadState. |
readAt (ThreadState.readAt) |
extension(url=SYS.threadReadAt).valueDateTime |
SYS.threadReadAt (urn:ehr-lab:thread-read-at) | — | Extension written only if readAt provided. markRead sets readAt=at. Drives unread. |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
Basic· Fixed. - code (state type) —
SYS.threadRead (urn:ehr-lab:thread-read) \| state· code.coding=[{system:SYS.threadRead,code:"state"}]. Used as search filter (code=SYS.threadRead|state).
Search: FhirThreadState.rows(pro): Basic?code={SYS.threadRead}|state&author=Practitioner/{practitionerId}&_count=200&_sort=-_lastUpdated. stateOf → map threadId→{readAt}. markRead: searches the existing one by threadId (fromThreadState) then update (rewrites extension) or create.
Notes: A single Basic per (professional, thread). Unread is computed app-side by comparing readAt to the last thread event, and is forced false on an archived thread. Basics written before archiving moved to the root may still carry a stale threadArchivedAt extension; it is ignored on read.
AuditEntry — audit trail (WHO / WHERE / WHAT / ON WHOM / WHEN)
FHIR resource: AuditEvent. Written by toAuditEvent; read by fromAuditEvent. Systematic logging by the application layer.
Every traced action: professional (agent), service in context (source.site), action + target resource (entity), patient concerned (entity Patient), timestamp (recorded).
Fields
| Business field | FHIR path | System / value-set / code | Required | Notes |
|---|---|---|---|---|
id |
id |
— | Server id. | |
action (AuditEntry.action) |
action (+ subtype[0].code) |
AUDIT_ACTION create→"C", update→"U", read→"R" ; subtype system SYS.restInteraction (http://hl7.org/fhir/restful-interaction), code = raw action (create/update/read) | ✔ | FHIR action code, single letter (C/U/R). Read by AUDIT_ACTION_BACK[action] (C→create,U→update,R→read) ?? "create". subtype[0]=[{system:SYS.restInteraction,code:a.action}] carries the raw action. |
activity (AuditEntry.activity) |
entity[].name |
✔ | Short human-readable description, carried by the name of the DESCRIBED entity (2nd entity pushed). Read: entities.find(e=>e.name).name ?? "Action". | |
practitionerId (AuditEntry.practitionerId) |
agent[0].who.reference |
✔ | Writes Practitioner/{id} with requestor:true. Read via refId(agent[0].who.reference). |
|
serviceId (AuditEntry.serviceId) |
source.site |
— | Writes the raw serviceId string into source.site (NOT a reference). Read source.site. | |
patientId (AuditEntry.patientId) |
entity[].what.reference (Patient/…) |
— | If present, pushed as the 1st entity: {what:{reference:Patient/{id}}}. Read: entities.find(what.reference startsWith Patient/) then refId(). |
|
target (AuditEntry.target) |
entity[].what.reference (described entity) |
— | Raw reference of the resource concerned (e.g. MedicationRequest/42), written in the what of the 2nd entity (the one carrying name=activity). Read: described.what.reference ?? 1st non-patient entity. |
|
at (AuditEntry.at) |
recorded |
✔ | Timestamp; read recorded ?? "". |
Markers (identifiers · categories · extensions · status/priority)
- resourceType —
AuditEvent· Fixed. - type —
SYS.auditType (http://terminology.hl7.org/CodeSystem/audit-event-type) \| rest — display "RESTful Operation"· Constant on write. - subtype —
SYS.restInteraction (http://hl7.org/fhir/restful-interaction) \| {create\|update\|read}· Code = raw domain action. - outcome —
"0"· Fixed (success). Not read back. - source.observer —
display "ehr-lab"· Constant; identifies the observer (the app). site = serviceId. - agent.requestor —
true· The professional is the requestor of the action. - entity (structure) —
[ {what:Patient/{id}} (if patient), {what:{target?}, name:activity} ]· 1st entity = patient (optional); last entity = described resource (name=activity, what=target if provided).
Search: FhirAuditTrail.query: AuditEvent?_count={limit||100}&_sort=-_lastUpdated (+ entity=Patient/{patientId} if patientId) (+ agent=Practitioner/{practitionerId} if practitionerId). An AuditEvent is never rewritten, so _lastUpdated is its recording time and the bound keeps the latest entries (recently viewed charts read the last 200). Sort byDateDesc("at") app-side then slice(limit||100). record() = create(toAuditEvent).
Notes: The entity mapping carries 2 roles: patient (search by entity=Patient/…) and described target resource. serviceId is NOT a FHIR reference but a free value in source.site.