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, orGET /api/catalog(eventTypes), lists the types already declared. A type cannot be the address of a pinned trigger (HL7v2Received,CDAReceived,PN13Receivedin 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 itsdependsOnclosure.@posos/commoncontributespatient,encounter,orderanddocument. - 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 optionalencountercan run without encounter context.fields:requireddefaults tofalse. A field cannot be namedtype,eventId,occurredAt,connectionId,recordId,done, a name starting with__, or the id field of one of the event's kinds: ids are carried bycontext.typeof 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 — anIntegeror aDecimalis a JSON number, aDateTimean ISO 8601 string. It may carry context ids; required ids can be left out, and the console adds a<kind>-exempleplaceholder 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. Withoutputsomitted, 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 byfrom(default: the output's name) — a declared field, the id field of one of the event's kinds, oroccurredAt: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-admittedof@posos/clinicalworks this way: it lists its outputs and takesencounterReffromencounterId. See Declarative triggers.Coded, with
behavior. When an output needs a business projection (anObservationbuilt from several fields, a default value), keepevent:, declare the outputs withoutfrom, and pointbehaviorat the factory. ItsfromEventreceives the workflow's copy of the event, as received. The lab-result, medication, procedure, allergy and condition triggers of@posos/clinicalare 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
mapkey must be a contract key (a declared field, the id field of one of the event's kinds, oroccurredAt); - every
requirename must be a contract key; - every required field, and the id of every required kind, must be a
mapkey.
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>-exempleplaceholder for each required context id it lacks. - In the API log, your plugin is listed in
plugins chargésand no warning under thepluginscomponent 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.eventin 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:
- 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. - On the trigger, replace
eventTypewithevent: <type>and removeestablishesandexample. - Move a
defaultSelectorfrom the trigger to the event, and setsdk: "^0.1.3"(^0.1.1without it). - Keep
behaviorand the outputs when the trigger builds a business projection; itsfromEventis unchanged. Otherwise, removebehaviorand express the outputs withfrom, checking that each output emits the same values as before. - Run
meridian-plugin validateandbuild, 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.
Related
- 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.