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 every published workflow that
receives it. 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 declares its contract,
who is allowed to claim it, 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. The core ships four triggers: three with their own address
(trigger.webhook, trigger.schedule and trigger.workflow-called), and
Événement reçu (trigger.event-received), which receives the declared event
chosen in its configuration (config.event). It ships no domain trigger and names no
event: the events come from the plugins that declare them, and a plugin may add a
dedicated trigger for one of its events.
This is the same bet the rest of the platform makes: the engine ships a shape
(kind: trigger, an event reference, or an address for a trigger with its own
routing), and plugins fill
it with content — event contracts included. 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
An event type is known to the platform in two ways: a plugin declares its contract, or a
pinned trigger uses it as its address (a webhook call, a schedule tick, an EAI
reception). The two sets are disjoint: a declaration whose type is the address of a
pinned trigger is refused at load. POST /api/events accepts an event only when a
loaded trigger claims its type (eventClaims, libs/shared/src/domain/trigger-facts.ts),
and Événement reçu claims every declared event: a declared event is admitted whether
or not a plugin contributes a trigger for it, then routed like any other (no workflow
configured on it: 400 « Aucun workflow pour l'événement
A declared event. The plugin declares the event's contract in
contributes.events — its context kinds and its typed fields. A workflow receives it
with Événement reçu, the event chosen in trigger.config.event, or with a
dedicated trigger of the plugin, which references it with event:. Here is a
declaration and its dedicated trigger from @posos/clinical
(external-plugins/clinical/plugin.yaml, shortened — its labels are French because
that manifest declares locale: fr, its source language; see
translate a plugin for the overlay story):
events:
- type: BioResultReceived
description: Un résultat de biologie est reçu pour un patient durant un séjour.
context: [patient, encounter]
fields:
- { name: analyte, type: String, required: true }
- { name: value, type: Decimal, required: true }
- { name: unit, type: String }
# …
example: { encounterId: sejour-7, analyte: creatinine, value: 160, unit: "µmol/L" }
nodes:
- 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.
event: BioResultReceived
outputs:
- { name: result, type: { kind: object, name: Observation } }
behavior: { module: ./behaviors/triggers.ts, export: bioResultReceived }
The trigger receives that event: its type, the context it establishes (every context
kind of the event, required or optional), its example and its default filter are read
from the declaration whenever they are needed (triggerFacts,
libs/shared/src/domain/trigger-facts.ts); they are neither written on the node nor
copied into its catalogue entry. A trigger without behavior also gets its fromEvent
from the core (see Declared events). The same facts, read under
the instance's configuration, describe a workflow on Événement reçu.
An address. A pinned trigger (pinnedRouting: true) names its type with
eventType, declares the context it establishes and an example on the node, and has no
declaration: its payload is described by its own code. Every other trigger of a plugin
references a declared event: a trigger without pinnedRouting and without event is
refused at load (see
Declare an event).
Either way, no list of events is compiled into the core. Declared events form an open
registry, filled at load time (libs/shared/src/domain/event-types.ts), served by
GET /api/catalog (eventTypes) and shown on the Plugins page (Event catalogue).
The type string has to be unique enough, in practice, that two plugins do not mean two
different things by the same name; a declared type belongs to the plugin that declares
it (a second declaration of the same type by another plugin is dropped with a load
warning). Context kinds are 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; what matters here is that a claimed type is what makes
POST /api/events accept a payload at all.
example is an example payload — the object a caller would send, context ids
included — surfaced by GET /api/workflows so the console can pre-fill the
Emit an event composer. exampleEvent (libs/shared/src/domain/trigger-facts.ts)
builds that pre-fill, also used by the filter check and simulation: { type }, a
placeholder id <kind>-exemple for each required context kind the example lacks
(every established kind for an address), then the example. A declared event's
example is checked against its contract at load time; a non-conforming example is
dropped with a warning.
The POST /api/events contract
The handler (apps/api/src/server/ingest.ts) fills in the envelope (eventId,
occurredAt), checks that a loaded trigger claims body.type, checks an event of a
declared type against its contract, finds the published workflows that receive the
type, deduplicates per workflow, then starts one run per receiving workflow, keyed
<eventId>:<slug>. The request body, the refusals, the
response and the run ids are specified in the
Event ingestion API; what an author
sees of it is explained in Events and triggers.
Two checks matter for a plugin author. The claim: the body must carry a type that
some loaded trigger — core or plugin, it makes no difference — claims; every declared
event is claimed by Événement reçu. It is the only check on the payload of an
address; an event of a declared type is also checked against its contract (see
Declared events). And a claimed type is necessary but not
sufficient: the platform also needs published workflows whose trigger receives that
type. 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 declared event is the capability, a workflow file that
starts from it, on its dedicated trigger or on Événement reçu, is the
activation. One event starts, in one fan-out, the workflows of both kinds.
Event-source ingestion (a fhir-poll feed, for instance) goes through the same
routing after its binding's map has projected a raw record into these same
fields, without waiting for the runs — POST /api/events and an active binding
are two doors into the same check, not two different ones. A binding emits without
a designated workflow, so the host keeps it only when a loaded trigger without its own
address claims its type (eventAdmission is routable) — every declared event, which
Événement reçu claims; it drops the others at load.
A failed submission there leaves the record pending in the reception inbox, and its
next delivery starts only the missing runs.
Declared events
A declared event is the contract its producers are expected to follow: its context
kinds (each required or optional), its fields (each typed, required or not), an
example, and optionally a default filter, which the console proposes to every workflow
that receives the event. It lives in the
manifest of the plugin that owns the event and nowhere else, and it forms an open
registry like context kinds (libs/shared/src/domain/event-types.ts). The trigger
inspector reads the registry from GET /api/catalog (eventTypes, descriptions
translated); the Plugins page reads the declarations of every loaded manifest
(contributes.events) from GET /api/plugins. The decision and its consequences
are recorded in
ADR 0006: Declared event contracts.
The contract is open: a field it does not declare is accepted and passed on as is,
to filters and to coded triggers. A declared field that is present is valid when it
conforms to its type without conversion — a Decimal is a JSON number, "182"
is not; the validity and absence rules are listed under
contributes.events
(libs/shared/src/engine/event-contract.ts).
The contract is used at load time, by the tools and at ingestion:
- the host and
meridian-plugin validate/buildcheck the declarations (a type cannot be a pinned address), the examples, the default filters and the fields they read, theevent:triggers, and the bindings that produce a declared event: an event binding'smapkeys must be contract keys (a declared field, a context id oroccurredAt) and must cover every required field and required context id; therequirenames of an event or message binding must be contract keys. A problem is a load warning logged under thepluginscomponent, and a default filter field outside the contract a note, since the contract is open (see Load checks and Plugin load warnings); the repository's own plugins load with none; - the trigger inspector shows it (Declared event), the trigger filter's guided
Field list and the AI assistant's digest list its fields with their types, and
the example prefill (
exampleEvent) starts from its example. The filter check adds a note for eachevent.<field>a filter reads outside the contract, and the filter stays valid (see JEXL — Static validation); - ingestion and simulation check each event of a declared type under the instance's
EVENT_CONTRACTSgate (apps/api/src/server/event-contracts.ts):observe, the default, records the issues and lets the event through,enforcerefuses it,offchecks nothing. Underenforce, event and message bindings also filter their records on their effectiverequire: the declaredrequireplus the contract's required fields and required context ids. The modes, the issues and the refusal are specified in Event ingestion API — Contract check; GET /api/openapi.jsondescribes the body ofPOST /api/eventswith one schema per accepted type, derived from the contract (see Event ingestion API — OpenAPI description).
The check never modifies the event. A coded trigger receives the workflow's copy of
the event: the event as received, with the run id as eventId. An event projected by
an EAI message binding also carries connectionId, set by the host. The event's
TypeScript shape is
{ eventId: string } & Record<string, unknown> (libs/engine-core/src/engine/interpreter.ts),
and the fields a coded trigger needs are read and defended against inside its own
fromEvent.
A trigger that references a declared event and has no behavior is declarative:
the core provides its fromEvent (libs/engine-core/src/engine/declared-trigger.ts),
which projects the contract onto the trigger's context and outputs — see
Declarative triggers. In
@posos/clinical, the stay triggers (admission, discharge, transfer) and
trigger.discharge-prescription-edited are declarative; the triggers that build a
resource keep their code. A run keeps the projection it started with: in a run of
journal format 2, fromEvent runs once, in the durable step trigger, and a replay
reads the trigger's context and outputs from the journal, so editing a declared event or
a declarative trigger's outputs applies to the runs that start after the new version. A
run of format 1 calls fromEvent again each time it resumes:
the same edit blocks a suspended or waiting run of format 1 on a journal mismatch until
the previous version is loaded again (see
Recover runs blocked by a missing plugin
and ADR 0008).
Événement reçu uses the same projection. Its outputs are the fields of the chosen
event, in declaration order, then done — what a declarative dedicated trigger without
declared outputs gets — and its fromEvent reads the contract of config.event in the
registry at each call: for one journaled event, the two return the same bytes. A plugin
keeps a dedicated trigger when its outputs are renamed or typed (a reference, an
Observation); the context ids are not outputs of Événement reçu, the context is
established and read with Lire le contexte. The workflow names its event: renaming a
declared type breaks the workflows configured on the old name (logged at load as unable
to run, refused at publication), and removing the plugin that declares it fails new runs
and holds runs in flight of journal format 1 at their next resumption until the event
returns (see
Declare an event). The
decision is recorded in
ADR 0007: Trigger configured by a declared event.
The default filter belongs to the event, not to one of its triggers: the console
proposes it to every workflow that receives the event, whatever its trigger, and
publication warns when a workflow's filter lacks it; routing never evaluates it.
@posos/common declares event.sender != "meridian" on CommunicationReceived: a
workflow created on that event starts with this filter, and then does not start on the
notifications Meridian writes to the record, which come back as that event.
A context kind of a declared event can be optional (encounter on
CommunicationReceived, MedicationPrescribed or ConditionRecorded, for instance).
The trigger establishes every kind of its event for static validation, but at run time
a kind is present only when the event carries its id: an event without
encounterId starts a run with no encounter context.
Compare two coded triggers contributed by the same plugin
(external-plugins/clinical/behaviors/triggers.ts):
// A context kind is established only if the raw event carries a usable id
// (`refOf`: non-empty text or finite number → `{ id }`; `optional` omits an
// undefined value). An encounter-less event stays valid rather than failing at
// the boundary, and a patient-less one never points at a record named "undefined".
function contextOf(e: Record<string, unknown>) {
return { ...optional("patient", refOf(e.patientId)), ...optional("encounter", refOf(e.encounterId)) };
}
// The allergy is emitted only if the event carries a usable code (`codeableOf`
// returns undefined otherwise): the nodes wired to it are skipped rather than
// handed the code "undefined". 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: optional("patient", refOf(e.patientId)),
outputs: {
...optional("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 does not make them on a coded trigger's behalf. For
an address, or a declared event under off or observe, 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 silently dropped output, a context kind left unestablished)
rather than as a rejected request. A kind the trigger could not
establish is absent from args.context, so the first node that requires it fails on
the missing context instead of reading or writing the wrong record. 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.
Four separately-owned pieces, 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 running workflow is split into four concerns, and each is its own,
independently pluggable, extension point (contributes.eventSources,
contributes.eventBindings, contributes.events, 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 declared event is the contract of that type: its context kinds and typed
fields, which the binding's
mapis checked against at load time; - a trigger receives the declared event — Événement reçu configured on it, or a dedicated trigger that references it — and has never heard of FHIR, HTTP polling, or JSONPath.
None of the four needs to be written by the same plugin. @posos/fhir ships the
fhir-poll mechanism and several bindings onto it; @posos/clinical declares the
events those bindings produce and ships the triggers that reference them. Another
chain: @posos/common declares CommunicationReceived and owns its trigger, and
@posos/fhir binds FHIR Communication resources to it. A future integration plugin
could contribute a new binding onto the existing fhir-poll mechanism, projecting
the same warehouse into a new declared event and its trigger, without either of the
first two plugins knowing it exists. That composability is the whole point of keeping
the concerns apart instead of shipping one "FHIR connector" abstraction: a mechanism
is reusable across arbitrarily many bindings, a binding is auditable without
reading code, the bindings that produce an event are checked against its contract at
load time, 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 reflects the same separation: it declares an inbound connection whose type is the mechanism, with the mechanism's parameters and the bindings it enables — 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, from the facts of its trigger (triggerRoute):
the event its trigger.type fixes, or for Événement reçu the event of its
configuration — so two workflow files can legally resolve to the same eventType,
whatever their triggers. When an event arrives, every published workflow of that
type that receives it starts: a trigger filter restricts its own workflow and takes
nothing from the others. The caller can designate one workflow with an internal
__workflowFile field (this is how the console's executions composer targets one
workflow — apps/config-plane/app/executions/ExecutionsView.tsx); the filter is then
not evaluated, and the designated workflow must receive the event's type: a workflow
whose trigger receives no type (Événement reçu with no event chosen) is refused.
Designation 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 designated 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
designation names its target, but not all designations 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: a new workflow on an existing event adds
its reaction without capturing anyone else's traffic, and draft workflows, forks,
or versions of "the same" trigger coexist while you iterate. The cost is that two
published workflows can react to the same fact; publication and the editor's
filter panel list the other receivers and their effects — see
Choose which events start a workflow. 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 keyed on eventId and the workflow (the run id), and the two
ingestion paths default the eventId differently. A binding-sourced event derives a stable id from the connection,
the feed and the upstream record (connection: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 of every receiving workflow. 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."
A stable id also bounds redelivery in time. An event already received reaches only
the workflows published before its first reception: a re-emitted record (the same
connection:feed:recordId) never starts a workflow published after the record first
arrived. A designated delivery (__workflowFile) is not affected.
Where to go next
- Declare an event — write an event's contract and the trigger that references it.
- Contribute an event source and binding — write a source mechanism and a binding.
- Connect an event source and activate bindings — the operator side: the inbound connection of the source and the bindings it enables.
- Plugin manifest — the exact schema for
contributes.events,contributes.nodes,contributes.eventSourcesandcontributes.eventBindings. - Event ingestion API — the
POST /api/eventsrequest, refusals, response and run ids. - Events and triggers — the same model from the workflow author's side: filters, fan-out, duplicates.
- 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.