The open event model
Every run starts the same way: something outside the platform happens, and a POST /api/events call turns it into the first node of a workflow. What that "something"
is — a lab result landing in a FHIR warehouse, a nurse's note, a discharge
prescription being edited — is never known to the engine. This page explains how
that can be true: how an event type comes to exist at all, who is allowed to claim
one, and what the ingestion boundary does and deliberately does not check.
For the mechanics of authoring a source and a binding, see Contribute an event source and binding; for the operator side, Connect an event source and activate bindings. This page is the why underneath both.
There is no DomainEvent union
A more conventional engine would define a closed union of event types up front —
BioResultReceived | PatientAdmitted | MedicationPrescribed | … — and grow it every
time a new integration needs a new event. Meridian's engine defines no such thing.
The core's node catalogue (libs/shared/src/engine/catalog-metadata.ts) declares
what a trigger is — a NodeKind that establishes context and emits initial
outputs — but the core itself contributes zero trigger nodes. Every trigger in a
running instance, without exception, comes from a plugin.
This is the same bet the rest of the platform makes: the engine ships a shape
(kind: trigger, an optional eventType, an optional example), and plugins fill
it with content. A plugin registers a trigger node exactly the way it registers a
compute or a sink — through contributes.nodes in its manifest — and the
catalogue treats it identically to a core primitive once loaded. Nothing about
ingestion required a special extension mechanism; it reuses the one the whole
platform already has. See What a plugin is for that
mechanism in general, and the type system
for why keeping types first-class was worth the registry machinery.
A hardcoded event union was the alternative, and it was rejected for the same reason a hardcoded clinical vocabulary was: it would tie every new integration to a core release, and it would make it impossible for a third-party plugin to introduce an event the platform's authors never anticipated.
Claiming an event type
A plugin claims an event type simply by declaring it on a trigger node. Here is the
actual contribution from @posos/clinical (external-plugins/clinical/plugin.yaml
— its labels are French because that manifest declares locale: fr, its source
language; see translate a plugin for the overlay
story):
- id: trigger.bio-result-received
kind: trigger
category: Déclencheurs
label: Résultat de biologie reçu
description: Un résultat de biologie est reçu pour un patient durant un séjour.
eventType: BioResultReceived
establishes: [patient, encounter]
example: { encounterId: sejour-7, analyte: creatinine, value: 160, unit: "µmol/L" }
outputs:
- { name: result, type: { kind: object, name: Observation } }
behavior: { module: ./behaviors/triggers.ts, export: bioResultReceived }
eventType is the claim. There is no separate registry of "known events" to update
and no central list to append to — the string simply has to be unique enough, in
practice, that two triggers do not mean two different things by the same name.
(establishes is open in the same way: patient and encounter resolve
because they are context kinds contributed by @posos/common, in this plugin's
dependsOn closure — see
context kinds.) The
plugin manifest reference
documents every field on a node contribution; what matters here is that this one
field is what makes POST /api/events accept a payload at all.
example is not a schema. It is a sample payload — exactly the object a caller
would send — surfaced by GET /api/workflows so the console can pre-fill a "send
test event" form (exampleEventFor in apps/api/src/server/api.ts merges
{ type } with whatever the trigger's example declares). It documents
intent for a human; it is never parsed or validated against an incoming event.
The POST /api/events contract
The handler (apps/api/src/server/api.ts) does four things, in order, to a JSON
body:
- Fill in the envelope. If
eventIdis absent, it generates one (evt-xxxxxxxx); ifoccurredAtis absent, it stamps the current time. Nothing else about the body is normalised. - Check the claim.
body.typemust be a non-empty string, and some loaded trigger — core or plugin, it makes no difference — must declare that string as itseventType. If none does, the request is rejected with 400 andAucun déclencheur ne revendique l'événement « <type> »("no trigger claims this event"). - Find a workflow to run. A claimed event type is necessary but not
sufficient: the platform also needs a workflow whose
trigger.typeresolves (via the catalogue) to thateventType. If no loaded workflow matches, ingestion still rejects with 400. This is the same two-tier shape as ports and adapters — a plugin advertises a capability, something else has to actually delegate to it — applied to ingestion instead of hexagonal ports: a trigger node is the capability, a workflow file that starts from it is the activation. - Deduplicate, then run. If a run already exists for that
eventId, the existing run is returned with200— posting the same event twice is a no-op, not a second run. Otherwise a new run starts and the response is201.
POST /api/events
{ "type": "BioResultReceived", "patientId": "patient-42",
"encounterId": "sejour-7", "analyte": "creatinine", "value": 160, "unit": "µmol/L" }
→ 201 { run details… } # first time this eventId is seen
→ 200 { same run details… } # eventId seen before (idempotent replay)
→ 400 { error: "…ne revendique…" } # type is not claimed by any loaded trigger
→ 400 { error: "Aucun workflow…" } # type is claimed, but no workflow starts from it
Event-source ingestion (a fhir-poll feed, for instance) calls the identical
ingestEvent function after its binding's map has projected a raw record into
these same fields — POST /api/events and an active binding are two doors into
the same check, not two different ones.
No payload schema — by design
Once the envelope and the type claim are checked, nothing validates the rest of
the payload. The event's TypeScript shape is, literally,
{ eventId: string } & Record<string, unknown> (libs/engine-core/src/engine/interpreter.ts).
Whatever fields a trigger's payload needs are read, coerced and defended against
entirely inside that trigger's own fromEvent function — not at the door.
Compare two triggers contributed by the same plugin
(external-plugins/clinical/behaviors/triggers.ts):
// Encounter is included only if the raw event actually carries one — an
// encounter-less event stays valid rather than failing at the boundary.
function contextOf(e: Record<string, unknown>) {
return {
patient: { id: String(e.patientId) },
...(e.encounterId ? { encounter: { id: String(e.encounterId) } } : {}),
};
}
// An externally-sourced criticality is only forwarded if it matches the
// value-set's known concepts — bad upstream data is dropped, not propagated.
const CRITICALITIES = new Set(["low", "high", "unable-to-assess"]);
export const allergyRecorded: BehaviorFor<"trigger.allergy-recorded"> = () => ({
fromEvent: (e) => ({
context: { patient: { id: String(e.patientId) } },
outputs: {
allergy: codeableOf(e),
...(CRITICALITIES.has(String(e.criticality)) ? { criticality: String(e.criticality) as never } : {}),
},
}),
});
Neither of those checks is imposed by the engine. They are choices each trigger
author made, because the engine will not make them on their behalf. This is a
deliberate trade: an ingestion boundary this open never blocks a producer for
sending a shape nobody anticipated — a manual curl, a new upstream system, a
plugin nobody has heard of, can all post the same eventType — but it also means a
malformed field surfaces inside a run (a coerced "undefined" string, a silently
dropped output) rather than as a rejected request. The type safety Meridian is
built around — structural checks on every port, described in
the type system — starts one
step later, at the trigger's output ports, not at the raw event. A trigger's
fromEvent is the seam where untyped, ownerless JSON becomes the typed dataflow
graph the rest of the engine can reason about.
Three separately-open layers, not one connector
An event rarely starts life as a clean { type, … } object — for the bundled FHIR
integration it starts as a MedicationRequest or Observation resource. Getting
from there to a trigger claim is split into three concerns, and each is its own,
independently pluggable, extension point (contributes.eventSources,
contributes.eventBindings, contributes.nodes — all in
libs/shared/src/plugin/manifest.ts):
- an event source is a connection mechanism (
fhir-pollpolling a FHIR server, in principle an HL7 listener) that knows feeds and emits raw records — it has never heard ofBioResultReceived; - an event binding is a declarative
source + query + event + map— the only place a raw FHIRObservationbecomes{ type: "BioResultReceived", analyte: "creatinine", … }, entirely as data, no code; - a trigger claims the resulting
eventTypeand has never heard of FHIR, HTTP polling, or JSONPath.
None of the three needs to be written by the same plugin. @posos/fhir ships the
fhir-poll mechanism and several bindings onto it; @posos/clinical ships the
trigger those bindings target; a future integration plugin could contribute a new
binding onto the existing fhir-poll mechanism, projecting the same warehouse
into a different, brand-new trigger, without either of the first two plugins
knowing it exists. That composability is the whole point of keeping the three
concerns apart instead of shipping one "FHIR connector" abstraction: a mechanism
is reusable across arbitrarily many bindings, a binding is auditable without
reading code, and a trigger stays free of every detail about how its event
happened to arrive. The architecture-level view of this split — including the
sequence diagram from raw record to running workflow — is in
the architecture explanation.
The instance manifest reflects the same separation: it supplies only connection
config per mechanism (sources:) and an optional allow-list of which bindings may
fire (eventBindings:) — never a query, never a map. Query and map are authored
once, by the plugin, and travel unchanged between every instance that installs it.
See the instance manifest reference
and connect an event source
for that side in full.
Ownership of an eventType is not exclusive
Nothing in the model requires exactly one workflow per event type. A workflow's
eventType is derived, at load time, purely by looking up its declared
trigger.type in the node catalogue — so two workflow files can legally declare
the same trigger.type and therefore resolve to the same eventType. When an
event arrives, ingestion picks the first loaded workflow whose eventType
matches; nothing in POST /api/events disambiguates further unless the caller
supplies an internal __workflowFile field pinning a specific spec (this is how
the console's "send test event" panel targets one draft when several workflows
share a trigger during authoring — apps/config-plane/app/console/RunConsole.tsx).
Pinning is also the only road to a stored draft, and that road is opt-in:
only when the caller also sends __allowDraft: true does a pinned file that
matches no active version fall back to the slug's draft in the definition
store — after passing the full publication-gate validation — and the run is
indexed with specVersion: null plus the draft's specHash, so run history
still says exactly which code ran. A pin names its target, but not all pins
are equal: a human at the console's composer is bench-testing (it sets the
flag), a scheduler tick is automation (it does not, so a stale tick still gets
the 400 that lets an orphan schedule stop re-arming). Automatic eventType
routing never consults drafts.
This is a direct consequence of anchoring the claim at the trigger node, not at
some separate event-to-workflow registry: it costs an ambiguous default when two
workflows genuinely compete for the same live traffic, but it means nothing
prevents draft workflows, forks, or versions of "the same" trigger from coexisting
while you iterate — you are never forced to make one of them the sole owner before
the others can even load. Validate a workflow
and simulate a workflow both let
you exercise a specific spec's fromEvent directly, sidestepping this routing
question entirely, which is usually the better tool while a trigger is still
shared across drafts.
Idempotency depends on where eventId comes from
Deduplication is entirely keyed on eventId, and the two ingestion paths default
it differently. A binding-sourced event derives a stable id from the mechanism,
the feed and the upstream record (source:feed:recordId — see
contribute an event source and binding),
so re-polling the same record is always a safe no-op. A hand-built
POST /api/events call that omits eventId gets a fresh random one every time —
so retrying such a call will start a second run. A caller that wants safe
retries has to supply its own stable eventId; the platform does not invent one
for you beyond "something, so the field is never empty."
Where to go next
- Contribute an event source and binding — write a source mechanism and a binding.
- Connect an event source and activate bindings — the operator side: connection config and the binding allow-list.
- Plugin manifest — the exact schema for
contributes.nodes,contributes.eventSourcesandcontributes.eventBindings. - MapExpr — the projection grammar a binding's
mapuses. - The dataflow model — what a trigger's outputs feed into once a run starts.
- The architecture explanation — where ingestion sits relative to the three planes.
- Glossary — event source, event binding, trigger, context.