Documentation / Développement / Référence / Node behavior contract

Node behavior contract

The behavior contract is the API a plugin compiles against to implement the runtime logic of a node. It is defined in libs/shared/src/plugin/behavior.ts and shipped as part of the SDK surface (@meridian/shared). It is browser-safe: it declares only types, no engine code. Plugin autonomy requires that a plugin depend only on @meridian/shared; the contract lives there so a plugin can import it directly. It is re-exported from libs/engine-core/src/engine/catalog.ts for engine-side consumers.

This page documents the exported shapes and how the host invokes them. For the task of writing a behavior, see Implement a node behavior. For the ports reachable from a behavior, see Port contracts. For the wider published surface, see SDK surface.

Exported symbols

All symbols are exported from libs/shared/src/plugin/behavior.ts.

Symbol Kind Purpose
NodeBehaviorFactory<I, O> type (ctx) => NodeBehavior; the value a node module exports.
NodeBehavior<I, O, C> interface The node's runtime methods (run / propose / fromEvent).
PluginNodeContext interface Ambient context passed to the factory (ports, locale, t, connection, portFor).
RunArgs<I, C> interface Argument bag for run and propose.
Proposal<O> interface Human-validation request produced by a human-task.
ValidationResolution interface The human decision resolving a human-task.

The factory

export type NodeBehaviorFactory<
  I = Record<string, unknown>,
  O = Record<string, unknown>,
> = (ctx: PluginNodeContext) => NodeBehavior<I, O>;

A node module exports one factory. The host calls it once per node type, at catalog construction time, passing a PluginNodeContext; the returned NodeBehavior is merged onto the node's static metadata. The factory is the place to capture ctx (ports, locale, t) in a closure so the returned methods can use it.

The generics I (inputs), O (outputs) and C (config, on NodeBehavior) carry the strong typing derived from the node descriptor by codegen. They all default to Record<string, unknown>, so a behavior written without type parameters remains valid. Generated typed signatures live in each plugin's generated/nodes.ts — see Generate types and TypeRef.

Instantiation

The factory is instantiated in makeCatalog (libs/engine-core/src/engine/catalog.ts):

pluginBehaviors[id]!({ ports, locale: i18n?.locale, t: i18n?.tFor(id) })
Field passed Source When absent
ports The adapter bundle wired for the instance. Always present.
locale i18n.locale (instance locale). undefined if the host supplies no i18n.
t i18n.tFor(id), bound to the contributing plugin's runtime bundle. undefined if the host supplies no i18n.

Engine primitives (the value.*, transform.*, flow.* nodes) are built in to makeCatalog and do not go through a factory; only plugin-contributed node types are instantiated this way.

NodeBehavior

export interface NodeBehavior<
  I = Record<string, unknown>,
  O = Record<string, unknown>,
  C = Record<string, unknown>,
> {
  run?: (args: RunArgs<I, C>) => Promise<O> | O;
  propose?: (args: RunArgs<I, C>) => Proposal<O> | null;
  fromEvent?: (event: Record<string, unknown>, config?: C) => {
    context: ContextEnvelope;
    outputs: O;
  };
}

Which method a node must implement depends on its kind. All three are optional in the type, but the interpreter requires the one matching the kind and throws if it is missing.

Node kind Required method Role
source, sink, compute, gateway run Compute and return the node's outputs by port name.
human-task propose Build a validation request; onResolve / onTimeout produce outputs.
trigger fromEvent Establish the ambient context and initial outputs from the inbound event.

Node kinds are covered in Core primitives.

run

Returns the node's outputs keyed by output-port name. May be synchronous or return a promise. The interpreter runs it inside a durable step (ctx.step) and validates its outputs against the declared output ports (and coded value-sets) before recording them.

Omitting an output port from the returned object means nothing is emitted on that port, and any branch fed by that port stops. This is the mechanism behind guarded branching. See Branching and absence.

propose

For human-task nodes. Returns a Proposal describing the validation to request, or null to emit nothing (the node is marked skipped and no validation is created). When a Proposal is returned the interpreter suspends the run awaiting a human decision, then calls onResolve (on a decision) or onTimeout (on SLA expiry) to obtain the node's outputs. See Two-level validation and Add human validation.

A run keeps the request it published and the outputs it computed. In a run of journal format 2, the serializable part of the proposal (skip, title, payload, deadlineSeconds) is journaled in the durable step <node>/request, and the outputs of onResolve or onTimeout, with their checks, in the step <node>/outputs: a replay reads them from the journal, whatever the plugin version or the instance locale. When a run resumes on a request read from the journal, onResolve and onTimeout come from the propose of the loaded version, called with the same arguments: a task pending when a new version loads gets the outputs of that version. A propose that returns null there is a DeploymentError that the host retries without journaling anything (« Nœud « » : la version chargée du plugin ne propose plus cette validation. Rétablir la version du plugin qui a démarré la tâche. »): the run goes on once a version that proposes the validation is loaded. A run of format 1 calls propose again at each resumption, and a changed title blocks it while it waits on the task.

fromEvent

For the trigger node. Receives the inbound event and the trigger's config from the workflow spec (the webhook trigger reads its typed body fields there; other triggers ignore it), and returns the ambient context (a ContextEnvelope) plus the trigger's initial outputs. Its outputs are validated like any node's.

The event is the workflow's copy: each receiving workflow gets its own, whose eventId is the run id (<eventId>:<slug> for a domain event, the received eventId for a trigger with its own address), so fromEvent is called once per receiving workflow. Apart from eventId, the copy is the event as received — never normalized, even when its type is a declared event. An event projected by an EAI message binding carries connectionId, and its recordId has been removed.

In a run of journal format 2, fromEvent runs once, in the durable step trigger, with the checks of its outputs, and a replay reads the context and the outputs from the journal. A run of format 1 calls it outside any durable step, again each time the run resumes. Keep it deterministic: no clock, no randomness, no port calls. A value that varies between two calls (the current time, say) changes the trigger's outputs on the replay of a run of format 1; read time from the event (occurredAt, set at ingestion) instead.

A trigger that references a declared event and has no behavior gets a fromEvent from the core, projected from the event's contract (see Declarative triggers). See The open event model.

RunArgs

export interface RunArgs<
  I = Record<string, unknown>,
  C = Record<string, unknown>,
> {
  inputs: I;
  config: C;
  context: ContextEnvelope;
  workflowTools?: AgentToolSource;
  memory?: NodeMemory;
  connectionId?: string;
}
Field Type Description
inputs I Resolved input-port values, keyed by port name.
config C The node instance's effective configuration.
context ContextEnvelope Ambient context for the run (open map of context kinds).
workflowTools AgentToolSource Set by the interpreter for kind: "agent" nodes only: the source resolving workflow:<ref> tools.
memory NodeMemory Set by the interpreter for the core memory nodes only (memory.write, memory.read, memory.forget): the workflow memory bound to the current workflow, run and node path, whose write takes an optional expected version and answers { written: false, currentVersion } when it does not match. A plugin behavior never receives it.
connectionId string Set when the node instance designates a connection (nodes.<id>.connection), for a node type that declares connection. Pass it to ctx.connection or ctx.portFor.

The same RunArgs value is passed to both run and propose.

ContextEnvelope

Defined in libs/shared/src/domain/types.ts: Record<string, { id: string } | undefined> — an open map, one key per context kind established at trigger time. The keys are the plugin-contributed context kinds (@posos/common contributes patient, encounter, order, document); every entry is optional, so guard the keys your behavior needs.

Proposal

export interface Proposal<O = Record<string, unknown>> {
  title: string;
  payload: Record<string, unknown>;
  deadlineSeconds?: number;
  message?: string;
  recipients?: string[];
  options?: ValidationOption[];
  onResolve: (resolution: ValidationResolution) => O;
  onTimeout?: () => O;
}
Field Type Description
title string Human-readable title of the validation request.
payload Record<string, unknown> Data shown to the validator.
deadlineSeconds number? SLA before escalation; absent means no deadline.
message string? Explanation for the person who decides, shown by a validation channel and the console.
recipients string[]? Destinations in the validation channel's terms (channels, people).
options ValidationOption[]? A choice validation: { id, label } per answer; the resolution carries choice. Absent: accept or reject.
onResolve (resolution) => O Outputs produced once a human decides.
onTimeout () => O? Outputs produced if the deadline expires with no decision. If absent, {} is used.

ValidationResolution

export interface ValidationResolution {
  decision: "accepted" | "rejected";
  by: string;
  at: string;
  comment?: string;
  choice?: string;
}
Field Type Description
decision "accepted" | "rejected" The human decision.
by string Author of the decision.
at string Timestamp of the decision.
comment string? Optional free-text comment.
choice string? The option chosen for a choice validation (decision is then accepted).

PluginNodeContext

export interface PluginNodeContext {
  ports: Ports;
  locale?: string;
  t?: (key: string, params?: Record<string, unknown>) => string;
  connection?: NodeConnectionAccessor;
  portFor?: (port: string, connectionId?: string) => Promise<unknown>;
}
Field Type Description
ports Ports The hexagonal port bundle (adapters). See Port contracts.
locale string? Instance locale — the language of persisted content (notifications, validation titles). See below.
t function? Runtime translator for this plugin's keys. See below.
connection NodeConnectionAccessor? For a node that declares connection: { type }: connection(connectionId?) resolves the designated connection, else the default connection of the type, into a ConnectionHandle, at each call. Absent otherwise.
portFor function? For a node that declares connection: { port }: portFor(port, connectionId?) returns the adapter of the port that requires the type of the designated connection, bound to it; without connectionId, the port of the bundle. Absent on a host older than contract 0.1.7. See Connections that serve a port.

ports

The Ports bundle (libs/shared/src/domain/ports.ts) is open and every port is optional: the SDK types only agent and terminology; business ports (patient, prescription, notification, …) are typed by their owning plugin's declare module augmentation and guarded at runtime with requirePort<T>(ports, "<name>"). Full method signatures are in Port contracts.

locale

The instance locale — the language of persisted content. The contract documents a fallback of "fr" when the host does not supply it; note that at instantiation the host passes i18n?.locale, which is undefined when no i18n is configured, so a behavior that formats persisted content should provide its own default. See Instance vs UI locale and Set the instance locale.

t

Translates a runtime key of the plugin (the runtime section of its i18n/<locale>.yaml bundles), resolved in the instance locale with {param} interpolation. Resolution falls back instance locale → plugin source locale → the key itself. t is undefined when the host provides no i18n, so callers should guard for its absence. See Translate a plugin.

Invocation summary

The interpreter (libs/engine-core/src/engine/interpreter.ts) drives the methods as follows.

Step Method Notes
Trigger fires fromEvent(event, config) Establishes context; outputs validated; missing fromEvent is an error. Journal format 2: in the durable step trigger, once. Format 1: outside any durable step, again on every resume.
human-task node propose(args) null marks the node skipped; otherwise the run suspends and resolves via onResolve / onTimeout. Journal format 2: request in the step <node>/request, outputs in the step <node>/outputs.
Other nodes run(args) Executed in a durable step; outputs validated; missing run is an error.

The durability of run steps and the suspend/resume of propose are provided by the workflow context seam; see The workflow context seam and Interpreter internals.

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