Binding map expressions (MapExpr)
MapExpr is the projection grammar an event binding
uses to turn one field of a raw record (a FHIR resource, an HL7 message, any
JSON-shaped payload from an event source)
into one field of a domain event, or into one of the run / decision / by
values used to resolve a suspended human validation. It is pure, declarative
data — no code, no URL — so a binding travels between plugin and instance, and
between instances, unchanged.
The grammar is defined once, browser-safe, in
libs/shared/src/plugin/mapping.ts
as the zod schema MapExprSchema (exported type MapExpr). It is consumed by
EventBindingContribSchema in
libs/shared/src/plugin/manifest.ts.
Evaluation is a separate concern that lives in
libs/engine-core/src/engine/event-mapping.ts
(applyMapping, evalExpr), called by the API host — the schema never imports an
evaluator, and the evaluator never imports zod.
This page documents the grammar and its evaluation semantics exhaustively. For
the surrounding binding schema (event, resolve, query…) see the
plugin manifest reference; for
the authoring workflow see
Contribute an event source and binding.
Where a MapExpr is used
| Location | Shape | Required |
|---|---|---|
eventBindings[].map |
Record<string, MapExpr> |
no (defaults to {}) |
eventBindings[].resolve.run |
MapExpr |
yes, when resolve is present |
eventBindings[].resolve.pending |
MapExpr |
no |
eventBindings[].resolve.decision |
MapExpr |
yes, when resolve is present |
eventBindings[].resolve.by |
MapExpr |
no |
A binding declares exactly one of event (with map) or resolve — see
Event bindings.
Grammar
MapExprSchema is a zod union of four forms, checked in this order:
| Form | Zod shape | Meaning |
|---|---|---|
| Dotted path (shorthand) | z.string() |
Property access by .-separated segments; a numeric segment indexes an array. |
| Path object | z.object({ path: z.string(), ref: z.boolean().optional() }) |
Same access as the string form, plus optional FHIR-reference stripping. |
| JSONPath object | z.object({ jsonpath: z.string(), all: z.boolean().optional(), ref: z.boolean().optional() }) |
A jsonpath-plus expression (filters, wildcards, recursive descent). |
| Literal | z.object({ const: z.unknown() }) |
A fixed value, independent of the record. |
Source: MapExprSchema in
mapping.ts.
Dotted path — "a.b.0.c"
The shorthand form. Evaluated by getPath:
function getPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const seg of path.split(".")) {
if (cur == null) return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
- The path is split on
.; each segment is a plain property lookup. - A numeric segment (e.g.
"0") indexes an array the same way — array elements have no separate syntax. - If any intermediate value is
nullorundefined, evaluation short-circuits and returnsundefined;getPathnever throws.
Example (from external-plugins/fhir/plugin.yaml's fhir-creatinine binding):
value: "valueQuantity.value", occurredAt: "effectiveDateTime".
Path object — { path, ref?: true }
Same traversal as the string form (getPath(record, expr.path)), packaged so
ref can be attached.
| Key | Type | Required | Meaning |
|---|---|---|---|
path |
string | yes | Dotted path, identical semantics to the shorthand. |
ref |
boolean | no | When true, strip a FHIR-style reference prefix from a string result. |
ref calls stripRef:
function stripRef(v: unknown): unknown {
return typeof v === "string" && v.includes("/") ? v.slice(v.indexOf("/") + 1) : v;
}
It takes the substring after the first / — not specifically the FHIR
ResourceType/id pattern, any string containing a / is cut there
("Patient/patient-42" → "patient-42"; "a/b/c" → "b/c"). A non-string
value, or a string without /, passes through unchanged. ref is silently a
no-op when the underlying value isn't a string.
Example: patientId: { path: subject.reference, ref: true } on
{ subject: { reference: "Patient/patient-42" } } → "patient-42".
JSONPath object — { jsonpath, all?: true, ref?: true }
For cases the dotted path can't express: filters, wildcards, recursive
descent. Evaluated with the jsonpath-plus
library:
function evalJsonPath(record: unknown, path: string, all?: boolean): unknown {
const res = JSONPath({ path, json: record as object, wrap: !!all });
if (all) return res; // always an array
return Array.isArray(res) ? res[0] : res; // filter → array: take the first
}
| Key | Type | Required | Meaning |
|---|---|---|---|
jsonpath |
string | yes | A JSONPath expression, evaluated against the raw record as $. |
all |
boolean | no | false/absent: return only the first match. true: return every match, always as an array (wrap: true). |
ref |
boolean | no | Same stripRef post-processing as the path object — applied to whatever evalJsonPath returns. |
With all absent (wrap: false, jsonpath-plus's own semantics): zero
matches → undefined; exactly one match → that value, unwrapped; more than
one match → an array (of which only the code path documented above still
takes index 0 — effectively "first match" in every case).
A malformed JSONPath expression, or an expression evaluated against a record shape it doesn't expect, can throw — see Errors and omission.
Examples (from apps/api/tests/test-events.ts), evaluated against
{ code: { coding: [{ system: "...snomed...", code: "X" }, { system: "http://loinc.org", code: "2160-0" }] }, identifier: [{ value: "lab-1" }, { value: "lab-2" }], subject: { reference: "Patient/p9" } }:
| Expression | Result |
|---|---|
{ jsonpath: "$.code.coding[?(@.system=='http://loinc.org')].code" } |
"2160-0" |
{ jsonpath: "$.subject.reference", ref: true } |
"p9" |
{ jsonpath: "$.identifier[*].value", all: true } |
["lab-1", "lab-2"] |
Edge case — ref with all: true: stripRef only acts on a string
value; with all: true the result is an array, so ref has no effect on it
(each element is not stripped individually). Combining ref: true with
all: true is therefore meaningless as currently implemented.
Literal — { const: <any> }
if ("const" in expr) return expr.const;
Returns the configured value verbatim, ignoring the record entirely. const
is z.unknown(): any JSON-compatible value (string, number, boolean, object,
array, null) is accepted, not only strings.
Example: analyte: { const: creatinine }.
Evaluation — evalExpr and applyMapping
Dispatch, in evalExpr (event-mapping.ts):
| Check | Branch |
|---|---|
typeof expr === "string" |
Dotted path via getPath. |
"const" in expr |
Literal. |
"jsonpath" in expr |
JSONPath via evalJsonPath, then ref if set. |
| (else) | Path object: getPath(record, expr.path), then ref if set. |
A whole mapping table is applied with applyMapping:
export function applyMapping(record: unknown, map: Record<string, MapExpr>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [field, expr] of Object.entries(map)) {
const v = evalExpr(record, expr);
if (v !== undefined) out[field] = v;
}
return out;
}
- Every
field: MapExprentry inmap(or inresolve) is evaluated independently against the same rawrecord. - A result of
undefinedomits the key from the output object — it is never present with the valueundefined. A partial raw record therefore yields a partial event, not one full ofundefinedfields.
Errors and omission
getPath and the literal/path branches never throw for an expression of one of
the forms above. evalJsonPath (via jsonpath-plus) can throw — a malformed
JSONPath string, or an expression applied to a record shape it cannot traverse.
applyMapping itself does not catch that: the throw propagates to the caller.
An object that matches none of the forms still passes the schema: the literal form
{ const } accepts any object and keeps none of its other keys, so the expression is
read as {}. That happens with a misspelled key ({ jsonpat: "$.x" }) and with a
value of the wrong type ({ path: subject.reference, ref: "yes" }, { jsonpath: 1 }).
The plugin load reports each key of such an expression as unknown (kind key), the
valid ones included, and evalExpr then reaches the path branch without a path and
throws, so every record of the binding is rejected. Check the key names and the types
of the values.
The host (apps/api/src/server/source-records.ts, called when the reception delivers a
source record) wraps every applyMapping call in a try/catch per record:
- On the event/map path, a throw is logged and the generation is recorded
with outcome
rejected; the record that failed to map does not stop the source or affect other records. - On the resolve path, a throw is likewise logged and recorded as
rejected; an empty/missingrunvalue (m.runfalsy after mapping) is also treated asrejectedwithout calling the resolution path, as is an empty/missingpendingvalue when the binding declarespending.
See
apps/api/src/server/source-records.ts
(createRecordProcessor). A throw from the rest of the processing (the ingress
unreachable while starting or resuming a run) is not a mapping failure: the
reception delivers the record again — see
Records that fail to process.
Host application
Starting a run (event + map)
The host builds the domain event object as:
event = {
...applyMapping(data, binding.map),
type: binding.event,
eventId: `${connectionId}:${feed}:${recordId}`,
};
eventId is derived from the identifier of the inbound connection that received
the record, the feed (= binding) name, and the record id the source mechanism
supplied — stable across re-emissions of the same logical record, which is what
makes ingestion idempotent downstream. The mapped fields never include type or eventId
themselves; those two are added after applyMapping runs, so a map entry
named type or eventId is overwritten. For a declared event, such a key is also
reported at load (« absent du contrat »), as are connectionId, recordId and names
starting with __: none of them is a contract key. occurredAt is a contract key,
typed DateTime; mapped, it replaces the reception time the ingestion sets otherwise.
Values are not converted: the value the expression reads is the value the event
carries. A Decimal field of a declared event needs a JSON number in the record, not
numeric text.
Next, the binding's require is applied to the projected event: when one of the
listed fields is absent — missing, null, a string empty once trimmed, or [] for a
field the contract declares as a list — no event is emitted, the generation is counted
rejected, and the host logs at debug level « enregistrement filtré (champs exigés
vides) ». Under EVENT_CONTRACTS=enforce, the contract's required fields and
required-kind ids are added to the list.
The resulting outcome, recorded per source/feed/event, is one of accepted
(at least one run started), duplicate (every workflow that receives the
event already had it), filtered (workflows receive the type, none of their
filters keeps the event), rejected (mapping threw, a require field is empty,
or the ingestion rejected the event — an unclaimed type, or a type no published
workflow receives), or unavailable (a run could not be submitted to Restate; the
reception delivers the record again and the retry starts only the missing runs).
Resolving a validation (resolve)
const m = applyMapping(data, binding.resolve);
// resolveFromBinding (apps/api/src/server/validation-resolve.ts)
const runId = m.run != null ? String(m.run) : "";
// … opResolve(runId, { decision: …, by: …, pendingId: m.pending != null ? String(m.pending) : undefined })
m.run, m.pending, m.decision, m.by are read back as plain values
(coerced with String(...) when present) and passed to the host's
run-resolution path. The decision is normalized there: any value other than the
literal string "rejected" is treated as "accepted" — so, for example, a FHIR
Task status of completed maps to an accepted decision. With pending, the
decision is applied only if the run waits on that validation. Resolution is
idempotent: re-observing an already-applied decision is a benign no-op
(duplicate outcome), not an error.
See Add human validation and
Run and resolve for the run-side
lifecycle that resolve bindings feed into.
Worked example
The fhir-creatinine binding's map (see
Contribute an event source and binding),
applied to a raw Observation:
map:
patientId: { path: subject.reference, ref: true }
encounterId: { path: encounter.reference, ref: true }
analyte: { const: creatinine }
value: valueQuantity.value
unit: valueQuantity.unit
occurredAt: effectiveDateTime
{
"subject": { "reference": "Patient/patient-42" },
"encounter": { "reference": "Encounter/enc-7" },
"valueQuantity": { "value": 160, "unit": "µmol/L" },
"effectiveDateTime": "2026-06-10T09:30:00Z"
}
produces:
{
"patientId": "patient-42",
"encounterId": "enc-7",
"analyte": "creatinine",
"value": 160,
"unit": "µmol/L",
"occurredAt": "2026-06-10T09:30:00Z"
}
If the raw record only has subject.reference (no valueQuantity, no
effectiveDateTime), the produced object is { "patientId": "patient-42", "analyte": "creatinine" } — the other keys are omitted, not set to null or
undefined.
The corresponding resolve variant (fhir-validation-decided, on the FHIR Task
the platform publishes for a pending validation):
resolve:
run: { jsonpath: "$.identifier[?(@.system=='urn:ehr-lab:validation-run')].value" }
pending: { jsonpath: "$.identifier[?(@.system=='urn:ehr-lab:validation-pending')].value" }
decision: status
by: { path: owner.reference, ref: true }
run reads the identifier that carries the run id. A domain run id
(<eventId>:<slug>) contains :, so it is stored in an identifier value, not in a
resource id. pending reads the identifier that carries the decided validation
(pending.id of the run).
The JSONPath examples of the JSONPath object section are exercised as executable
assertions in apps/api/tests/test-events.ts.
Related references
- Plugin manifest — contributes.eventBindings —
the full
eventBindingsschema (name,source,query,event/resolve). - Plugin manifest — contributes.eventSources —
the mechanism a binding's
sourcenames. - Contribute an event source and binding — the authoring recipe for both sides.
- Connect an event source and activate bindings — the operator side (the inbound connection of the source and the bindings it enables).
- The open event model — why source, binding, and connection are three separate concerns.
- FHIR mapping reference (
apps/ehr-lab/docs/resources/reference/fhir-mapping.md) — FHIR-specific conventions for writing amap. - Add human validation and
Run and resolve — the workflow
side that
resolvebindings resume.