Documentation / Développement / Guides pratiques / Declare an event

Declare an event

This recipe adds a declared event to a plugin: the contract of an event type (its context kinds, typed fields and example) in contributes.events. Once declared, the event is received by the core trigger Événement reçu (trigger.event-received, the event chosen in trigger.config.event): a plugin needs no trigger for its event to be received. A dedicated trigger that references the event with event: is optional; add one when the outputs need renaming or typing. It assumes a plugin whose plugin.yaml you can edit (see Scaffold a plugin; the scaffold already ships one declared event, ExampleReceived). The field-by-field rules are in contributes.events; the reasons behind the model are in The open event model.

Before you start

  • Pick the event type: PascalCase, letters and digits, 64 characters at most (HeartRateRecorded). One plugin declares a type; a second declaration by another plugin is dropped at load. The Plugins page's Event catalogue, or GET /api/catalog (eventTypes), lists the types already declared. A type cannot be the address of a pinned trigger (HL7v2Received, CDAReceived, PN13Received in the repository): such a declaration is dropped at load.
  • The context kinds the event carries (patient, encounter…) must be registered by your plugin or by its dependsOn closure. @posos/common contributes patient, encounter, order and document.
  • The field types must resolve in the same closure: an object type, the value set of a coded field, the entity of a ref.

1. Declare the event

Add the event under contributes.events:

dependsOn:
  "@posos/common": "^0.4.0"
contributes:
  events:
    - type: HeartRateRecorded
      description: A heart rate is recorded for a patient.
      context: [patient, { kind: encounter, required: false }]
      fields:
        - { name: bpm, type: Integer, required: true, description: Beats per minute. }
        - { name: measuredAt, type: DateTime, description: Date and time of the measurement. }
        - { name: method, type: String, description: Measurement method. }
      example: { bpm: 72, measuredAt: "2026-09-26T08:00:00Z", method: auscultation }
  • context: a bare kind name is required; { kind, required: false } is optional. A producer sends the kind's id field (patientId, encounterId). At run time a kind is established only when the event carries its id, so a workflow on an event with an optional encounter can run without encounter context.
  • fields: required defaults to false. A field cannot be named type, eventId, occurredAt, connectionId, recordId, done, a name starting with __, or the id field of one of the event's kinds: ids are carried by context.
  • type of a field: a type name (String, Integer, Decimal, DateTime, CodeableConcept, String[]), or a full TypeRef for a coded field ({ kind: coded, valueSet: <id> }).
  • example: values of the declared types, without conversion — an Integer or a Decimal is a JSON number, a DateTime an ISO 8601 string. It may carry context ids; required ids can be left out, and the console adds a <kind>-exemple placeholder for each one. A non-conforming example is removed at load.

Two events of the same shape can share their field list with a YAML anchor (& / *).

2. Require the plugin contract 0.1.1

Declared events need the plugin contract 0.1.1:

sdk: "^0.1.1"

With a lower range or none, meridian-plugin validate and build fail, and the host loads the plugin with the warning « plage sdk insuffisante ». A host whose contract is older than 0.1.1 refuses the plugin (contrat plugin incompatible — plugin ignoré) instead of loading it without its events. An event with a default filter (step 8) needs sdk: "^0.1.3", checked the same way.

3. Add a dedicated trigger (optional)

Without a trigger of your plugin, workflows receive the event with Événement reçu: its outputs are the event's fields, in declaration order, and its context is the event's kinds. Add a dedicated trigger when the outputs need renaming, a reference (EncounterRef from an id) or a structured object built from several fields; the assistant proposes the dedicated trigger of an event when there is one.

  nodes:
    - id: acme.heart-rate-recorded
      kind: trigger
      category: Acme
      label: Heart rate recorded
      description: Starts a workflow when a heart rate is recorded.
      event: HeartRateRecorded

Do not write eventType, establishes or example on the node: the host derives them from the declaration (eventType is the type, establishes lists every kind of the event, required and optional, and example is the event's). pinnedRouting: true, dynamic and derivedPorts are refused next to event.

Then choose how the trigger emits:

  • Declarative, no behavior. With outputs omitted, the trigger has one output per field (same name, type and description). To pick or rename, list the outputs; each takes the value of the contract key named by from (default: the output's name) — a declared field, the id field of one of the event's kinds, or occurredAt:

        event: HeartRateRecorded
        outputs:
          - { name: rate, type: { kind: primitive, name: Integer }, from: bpm }
          - { name: patient, type: { kind: ref, entity: Patient }, from: patientId }
          - { name: at, type: { kind: primitive, name: DateTime }, from: occurredAt }
    

    The core provides fromEvent: it emits an output only when its source value is present and readable, and establishes a kind only when the event carries its id. trigger.patient-admitted of @posos/clinical works this way: it lists its outputs and takes encounterRef from encounterId. See Declarative triggers.

  • Coded, with behavior. When an output needs a business projection (an Observation built from several fields, a default value), keep event:, declare the outputs without from, and point behavior at the factory. Its fromEvent receives the workflow's copy of the event, as received. The lab-result, medication, procedure, allergy and condition triggers of @posos/clinical are coded. See Implement a node behavior.

Either way, a run keeps the projection it started with. fromEvent runs once, in the durable step trigger of the run, and a replay reads the trigger's context and outputs from the journal: a new version that adds a field to the event, removes one, or changes a declarative output or its from applies to the runs that start after it, and the runs in flight go on with the outputs they started with. This holds for runs of journal format 2, the runs started once the proxy sets RUN_JOURNAL_FORMAT=2. A run of format 1 calls fromEvent again each time it resumes: a version that gives its event other outputs no longer matches its journal, and Restate retries the run until the previous version is loaded again (see Recover runs blocked by a missing plugin). The decision is recorded in ADR 0008.

4. Check the manifest

Run the CLI from the plugin directory:

meridian-plugin validate workflows/example.workflow.yaml
meridian-plugin build

Both run the host's load checks on the plugin and its dependsOn closure. validate prints the problems under ✗ plugins : <n> problème(s) de chargement : and exits 1; build prints the built plugin's problems and stops with <name> : <n> problème(s) de chargement, dist/ non écrit. Each message ends with its fix. A note (a default filter that reads a field outside the contract, step 8) is printed without failing either command. The most frequent, with their fixes, are listed in Validate a workflow — fix common failures; every check, with its effect, is listed under Load checks.

5. Align the bindings that produce the event

Each event binding whose event is your type is checked against the contract once every plugin is loaded:

  • every map key must be a contract key (a declared field, the id field of one of the event's kinds, or occurredAt);
  • every require name must be a contract key;
  • every required field, and the id of every required kind, must be a map key.

A mismatch is a load warning (« binding hors contrat »); the binding stays active. A key present in map still yields no value when the record lacks the path. List the required keys in require as well: a record whose listed fields are empty becomes no event whatever the EVENT_CONTRACTS mode, whereas the contract's required keys filter records only under enforce (see Contribute an event source and binding).

  eventBindings:
    - name: fhir-heart-rate
      source: fhir-poll
      event: HeartRateRecorded
      query: { resource: Observation, params: { code: "http://loinc.org|8867-4" } }
      map:
        patientId:   { path: subject.reference, ref: true }
        encounterId: { path: encounter.reference, ref: true }
        bpm:         valueQuantity.value
        measuredAt:  effectiveDateTime
        occurredAt:  effectiveDateTime
      require: [patientId, bpm]

Produce values of the declared types: the map does not convert, so a numeric field reads a JSON number. For an EAI message binding too, each require name must be a contract key. See Contribute an event source and binding.

6. Translate the descriptions

meridian-plugin i18n extract --locale <locale> adds the keys events.<type>.description and events.<type>.fields.<field>.description. A declarative trigger's output without a description takes the field's. See Translate a plugin.

7. Check in the console

Once the host loads the plugin:

  • Plugins → your plugin → Events tab → Declared events (« Événements déclarés »): the contract, Dedicated triggers (« Déclencheurs dédiés »), Produced by (the bindings) and Example. An event or a trigger the host dropped carries ignored at load (« ignoré au chargement »).
  • In the workflow editor, choose Événement reçu and your event in Event (« Événement »), or select your dedicated trigger: the inspector shows the Declared event (« Événement déclaré ») section, and the filter's guided Field list offers the event's fields.
  • Executions → Emit an event: with a workflow on this trigger as target, the payload is prefilled with the example and a <kind>-exemple placeholder for each required context id it lacks.
  • In the API log, your plugin is listed in plugins chargés and no warning under the plugins component names it; see Plugin load warnings.

8. Filter out the platform's own writes

When the platform's own writes come back as this event — a note a workflow writes to the record, read back by a binding — every workflow on the event starts on them. Mark the writes so that a field of the event tells them apart, and declare a default filter on the event. The console proposes it to every workflow that receives the event, whatever its trigger, and publication warns when a workflow's filter lacks it; it is never applied at run time. @posos/common does this for CommunicationReceived: the notifications Meridian writes carry the sender « meridian », and the binding maps it to the sender field.

sdk: "^0.1.3"
contributes:
  events:
    - type: CommunicationReceived
      context: [patient, { kind: encounter, required: false }]
      fields:
        - { name: sender, type: String, description: Émetteur de la communication. }
        # …
      defaultSelector: 'event.sender != "meridian"'

The filter reads the event as received, so it can only use a field of the event, not a trigger output. Declare in the event each field it reads: a field outside the contract gets a load note, and an invalid expression is removed at load with a problem (see Load checks). The console pre-fills it when an author creates a workflow on the event, with Événement reçu or a trigger of the event, and publication warns when a workflow's filter does not contain it. See Default filter and Events and triggers.

Rename or remove an event

A workflow on Événement reçu names its event in trigger.config.event, whereas a dedicated trigger follows its manifest:

  • Renaming a declared type breaks the workflows configured on the old name: at load, the host logs each one as unable to run (« ne peut pas tourner : événement « X » non déclaré par les plugins chargés. Vérifier les plugins résolus. »), publication refuses them, and their new runs fail. Update trigger.config.event in those workflows, or keep the old type.
  • Removing the plugin that declares the event, or unloading it from the Restate endpoint, fails a new run at its trigger (« Événement « X » non déclaré par les plugins chargés. »), and the run's trigger carries the error. A run in flight of journal format 2 reads its trigger from its journal and goes on, unless it uses a node type of the removed plugin. A run in flight of format 1 throws the same way at its next resumption, before its first journaled step: Restate reports a journal mismatch and retries the invocation, and the run keeps its last state (a validation already decided included) until the event is declared again, when the run goes on, or until an operator kills the invocation. The endpoint logs it once at warn (« rejeu interrompu : … ») and does not count it as a failed run (see Run outcome). A dedicated trigger whose plugin is gone behaves the same way. The operator's side is Recover runs blocked by a missing plugin.

Migrate a trigger from eventType to event

A trigger without pinnedRouting that claims its type with eventType is dropped at load (déclencheur non épinglé sans « event ». Déclarer l'événement et le référencer par « event ».), and a defaultSelector on a node is ignored. To migrate:

  1. Add the event under contributes.events, in the plugin that owns the type: its context kinds (those the trigger established, marked required or not), the fields producers send, and the example moved from the node.
  2. On the trigger, replace eventType with event: <type> and remove establishes and example.
  3. Move a defaultSelector from the trigger to the event, and set sdk: "^0.1.3" (^0.1.1 without it).
  4. Keep behavior and the outputs when the trigger builds a business projection; its fromEvent is unchanged. Otherwise, remove behavior and express the outputs with from, checking that each output emits the same values as before.
  5. Run meridian-plugin validate and build, and fix the bindings that produce the type (step 5).

The type itself does not change, so the workflows on the trigger keep receiving the same events once the plugin loads again.

  • Plugin manifest — the fields of contributes.events, declarative triggers and the load checks.
  • The open event model — declared events, pinned addresses, and where the contract is used.
  • ADR 0006: Declared event contracts — why contracts live in plugin manifests, with one declarer per type.
  • ADR 0007: Trigger configured by a declared event — why a declared event is received without a trigger of its plugin.
  • Contribute an event source and binding — the source mechanism and the binding that produce the event.
  • Implement a node behavior — a coded trigger's fromEvent.
  • Inspect loaded plugins — the Plugins page, from the operator's side.
97 documents12 sectionssource : /docs · généré au build