Documentation / Développement / Explications / The open event model

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:

  1. Fill in the envelope. If eventId is absent, it generates one (evt-xxxxxxxx); if occurredAt is absent, it stamps the current time. Nothing else about the body is normalised.
  2. Check the claim. body.type must be a non-empty string, and some loaded trigger — core or plugin, it makes no difference — must declare that string as its eventType. If none does, the request is rejected with 400 and Aucun déclencheur ne revendique l'événement « <type> » ("no trigger claims this event").
  3. Find a workflow to run. A claimed event type is necessary but not sufficient: the platform also needs a workflow whose trigger.type resolves (via the catalogue) to that eventType. 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.
  4. Deduplicate, then run. If a run already exists for that eventId, the existing run is returned with 200 — posting the same event twice is a no-op, not a second run. Otherwise a new run starts and the response is 201.
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-poll polling a FHIR server, in principle an HL7 listener) that knows feeds and emits raw records — it has never heard of BioResultReceived;
  • an event binding is a declarative source + query + event + map — the only place a raw FHIR Observation becomes { type: "BioResultReceived", analyte: "creatinine", … }, entirely as data, no code;
  • a trigger claims the resulting eventType and 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

75 documents8 sectionssource : /docs · généré au build