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). |
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>) => {
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.
fromEvent
For the trigger node. Receives the raw inbound event and returns the ambient
clinical context (a ContextEnvelope) plus the trigger's initial outputs.
Its outputs are validated like any node's. See
The open event model.
RunArgs
export interface RunArgs<
I = Record<string, unknown>,
C = Record<string, unknown>,
> {
inputs: I;
config: C;
context: ContextEnvelope;
}
| 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). |
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;
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. |
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;
}
| 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. |
PluginNodeContext
export interface PluginNodeContext {
ports: Ports;
locale?: string;
t?: (key: string, params?: Record<string, unknown>) => string;
}
| 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. |
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) |
Establishes context; outputs validated; missing fromEvent is an error. |
human-task node |
propose(args) |
null marks the node skipped; otherwise the run suspends and resolves via onResolve / onTimeout. |
| 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.