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 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 type that is neither declared nor an address is refused.

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/build check the declarations (a type cannot be a pinned address), the examples, the default filters and the fields they read, the event: triggers, and the bindings that produce a declared event: an event binding's map keys must be contract keys (a declared field, a context id or occurredAt) and must cover every required field and required context id; the require names of an event or message binding must be contract keys. A problem is a load warning logged under the plugins component, 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 each event.<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_CONTRACTS gate (apps/api/src/server/event-contracts.ts): observe, the default, records the issues and lets the event through, enforce refuses it, off checks nothing. Under enforce, event and message bindings also filter their records on their effective require: the declared require plus 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.json describes the body of POST /api/events with 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-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 declared event is the contract of that type: its context kinds and typed fields, which the binding's map is 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

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