Port contracts
Meridian follows a hexagonal architecture: workflows and the engine depend only on port interfaces. Concrete implementations (FHIR warehouse, editorial DPI, LLM providers, notification buses, terminology servers) are adapters wired at execution time through the plugin mechanism. Changing the target system does not touch the domain or the workflows.
Port contracts live with their owning plugin, not in the core:
- The SDK (
libs/shared/src/domain/ports.ts) types only what the core itself calls —agentandterminology— plus the platform capabilitySubjectRecordPortand the platform conceptPendingValidationRecord(libs/shared/src/domain/validation.ts). - Every business port — patient record, prescriptions, notifications, or any
port a plugin invents — is defined by the plugin that owns it. The TypeScript
contract is exported by that plugin (e.g.
@posos/clinical/ports,@posos/notify/ports) and typed on that plugin's own domain model (e.g.@posos/clinical/domain).
This page is the exhaustive method surface an adapter author must implement, one port at a time. For the task-oriented walkthrough of defining a port and writing an adapter, see Implement a port adapter; for how an instance binds adapters by name, see Configure ports and adapters.
The Ports bundle
Source: libs/shared/src/domain/ports.ts.
The engine, simulation, and console are injected with a single Ports object.
The SDK declares only the ports the core consumes itself:
| Key | Type | Required | Notes |
|---|---|---|---|
agent |
AgentRunnerPort |
no | Required only if AGENT nodes are present. |
terminology |
TerminologyPort |
no | Defaults to internal resolution. |
[pluginPort: string] |
unknown |
no | Ports defined by plugins (contract owned by the contributing plugin). |
The bundle is open and every port is optional: nothing is required at boot, and a behavior whose port isn't bound fails at execution time with a configuration error naming the port.
Module augmentation
A plugin that owns a port augments the open Ports interface so that its
dependents get strong typing back with a plain type import (nothing exists at
runtime):
// In the owning plugin (e.g. @posos/clinical/ports.ts)
declare module "@meridian/shared/domain/ports" {
interface Ports {
patient?: PatientProfilePort;
prescription?: PrescriptionPort;
}
}
Any plugin whose dependsOn closure includes @posos/clinical sees
ports.patient fully typed. See
The open event model and
Plugins concept.
requirePort — the common runtime guard
Types are erased at runtime, so the SDK ships one guard for every port access:
import { requirePort } from "@meridian/shared/domain/ports";
const patient = requirePort<PatientProfilePort>(ports, "patient");
When the instance does not bind the port, requirePort throws a clear
configuration error naming the port and the manifest key to fix
(Port « patient » non branché — sélectionnez un adapter dans le manifeste d'instance…) — never an opaque TypeError.
SubjectRecordPort — the platform record capability
Source: libs/shared/src/domain/ports.ts.
A record port is a queryable record of some subject, whatever its domain.
The host uses it for three integrations — serving a subject's record
(GET /api/records/:kind/:id, opaque return), publishing a human-validation
request to the record when a run suspends, and closing that request when the
validation ends outside the record. An adapter declares itself the record
port of a context kind with record: { contextKind } in its manifest
contribution; its full business contract (defined by its plugin) satisfies this
capability structurally.
| Method | Signature | Returns | Semantics |
|---|---|---|---|
snapshot |
(subjectId: string) |
Promise<unknown> |
Whole-record view of the subject — shape defined by the plugin, opaque to the host. |
publishValidationRequest? |
(subjectId: string, request: PendingValidationRecord) |
Promise<void> |
OPTIONAL. Publishes a human-validation request to the record. |
closeValidationRequest? |
(subjectId: string, closure: ValidationClosure) |
Promise<void> |
OPTIONAL, the pair of publishValidationRequest. Closes a published request with its outcome. Idempotent. |
The host serves snapshot as GET /api/records/:kind/:id: 404 when the kind has no
record port or snapshot returns null or undefined (no record), 502
{ "error": "dossier « <kind> » « <id> » illisible : <message>" } when it throws. A
subject your record does not know is best answered with null; an error is shown as a
failed read. GET /api/catalog lists the kinds that have a record port in recordKinds.
publishValidationRequest is optional. When a run suspends for human
validation, the platform publishes the request to the record of each subject
present in the event (one per context kind that has a record port), so an
external system can discover the "to decide" queue and resolve it through the
record, without coupling to the engine. A backend that does not materialize the
queue leaves the method undefined — the platform does not fail; validation then
stays internal to the engine. See
Two-level validation and
ADR 0002.
PendingValidationRecord (libs/shared/src/domain/validation.ts):
runId: string; pendingId: string (nodeId:runId, resume key);
kind: string; workflowName: string; title: string; payload: unknown.
Write both runId and pendingId on the published record: a resolve binding
that reads the decision back projects pendingId as resolve.pending, and the host
applies the decision only to that validation.
For a run started by a domain event, runId has the form <eventId>:<slug> and
contains : (as does pendingId); the eventId part may contain : too
(<connection>:<feed>:<record> for a record of an event source, such as
fhir-poll:<feed>:<record>). Store both in a field that accepts any text — the FHIR
adapter writes them as Task identifier values (urn:ehr-lab:validation-run,
urn:ehr-lab:validation-pending), not as resource ids. Several workflows can start on
the same event, so several runs can suspend on the same fact and publish one request
each, told apart by their runId.
closeValidationRequest is optional, like the publication. The platform calls it on the
record of each subject it published to, when the validation ends outside the record:
- a run of journal format
2 calls it in a durable step
close-validation-requestafter a decision taken in the console or through the API (acceptedorrejected), after the deadline (expired), and when a cancel reaches the run while it waits (withdrawn); - the proxy has the endpoint call it (
withdrawn), through therecordservice, when it freezes a run whose invocation was killed, or ended in failure, in Restate while the run waited on the validation, before it freezes the run; an error of the adapter is retried for up to one hour.
A run of format 1 does not close its requests. In a run, an
error of the adapter is logged and does not change the run. ValidationClosure
(libs/shared/src/domain/validation.ts): runId and pendingId, as published;
outcome (ValidationClosureOutcome): accepted, rejected, expired or
withdrawn; by?, the author of a decision; at, the time of the
decision, expiry or withdrawal (ISO 8601); comment?, the decision's comment. The
mapping to the record's states is the adapter's. The method can be called again after a
failure, or for a request already decided in the record or absent from it: leave such a
request as it is. A decision taken in the record resumes the run through a resolve
binding, and the run then calls the method for a request its record already decided.
ValidationChannelPort — validation channels (SDK)
Port validation (VALIDATION_PORT), contract in libs/shared/src/domain/ports.ts. A
channel (a messaging system) receives the human validations of suspended runs and sends
the decisions back. The host calls it on its own, like SubjectRecordPort; a channel
plugin contributes the adapter and the connection type it requires.
export interface ValidationChannelPort {
publish(request: ChannelValidationRequest): Promise<{ ref?: string } | void>;
close?(closure: ValidationClosure, ref?: string): Promise<void>;
}
| Member | Description |
|---|---|
publish(request) |
Called when a run suspends (journal format 2), on the adapter bound to the connection the node designates, else on ports.validation. request = the record request (runId, pendingId, kind, workflowName, title, payload) + message?, recipients?, options? ({ id, label }[]), deadline? (ISO 8601), subjects? (context kind → id). The returned ref is journaled and kept in the run's snapshot. A choice validation is published to the channel only, not to record ports. |
close(closure, ref) |
Called when the validation ends: accepted / rejected (with by, at, comment, choice), expired, withdrawn (run cancelled or killed). Idempotent. |
Decisions come back through an inbound connection whose format is validation-decision
(see Plugin manifest — Validation channels and incoming decisions).
PatientProfilePort (owned by @posos/clinical)
Source: external-plugins/clinical/ports.ts, imported as
@posos/clinical/ports. The clinical data model (Observation,
PatientProfile…) lives in external-plugins/clinical/domain.ts
(@posos/clinical/domain); it is deliberately decoupled from the port type
registry (see The type system).
The patient record port. Three deliberately separated method families:
demographics (administrative identity), filtered clinical reads, and unitary
clinical writes. snapshot is a whole-record view for the console and
simulation — not a read path for workflows, which must prefer the filtered
reads. PatientProfilePort satisfies the platform SubjectRecordPort
capability structurally (snapshot + publishValidationRequest?; an adapter can add
closeValidationRequest); the
@posos/clinical and @posos/fhir patient adapters declare
record: { contextKind: patient }, which is how the host serves the record and
publishes validations without knowing anything clinical.
Demographics
| Method | Signature | Returns | Semantics |
|---|---|---|---|
getDemographics |
(patientId: string) |
Promise<Demographics> |
Administrative identity. |
updateDemographics |
(patientId: string, patch: DemographicsPatch) |
Promise<Demographics> |
PARTIAL update — absent fields are kept; returns the resulting state. |
Demographics:
| Field | Type | Notes |
|---|---|---|
patientId |
string |
Never modifiable via patch. |
sex |
"male" | "female" | "other" | "unknown" |
|
ageYears |
number |
Derived from birthDate when known. |
name |
string? |
Display name. |
birthDate |
string? |
YYYY-MM-DD. |
deceased |
boolean? |
|
identifiers |
PatientIdentifier[]? |
{ system?: string; value: string } (INS, IPP…). |
DemographicsPatch = Partial<Omit<Demographics, "patientId">>. The pure helper
applyDemographicsPatch(current, patch) (in @posos/clinical/domain) implements
the keep-absent-fields semantics and is available to in-memory adapters.
Clinical reads
Each clinical family has its own FILTERED read. The default result is the
clinically active subset; options widen the scope. An adapter is expected to push
these filters to the target system (FHIR search). In-memory adapters can use the
pure helpers filterMedications, filterConditions, filterProcedures,
filterObservations from @posos/clinical/domain.
| Method | Signature | Returns | Default scope |
|---|---|---|---|
getMedications |
(patientId: string, opts?: { includeStopped?: boolean }) |
Promise<Medication[]> |
In progress — active/on-hold/intended. includeStopped returns history. |
getConditions |
(patientId: string, opts?: { includeResolved?: boolean }) |
Promise<Condition[]> |
Unresolved (no resolvedAt). |
getAllergies |
(patientId: string) |
Promise<Allergy[]> |
Known allergies and intolerances. |
getProcedures |
(patientId: string, opts?: { horizon?: ProcedureHorizon }) |
Promise<Procedure[]> |
horizon = "past" | "upcoming" | "all" (default "all"). |
getObservations |
(patientId: string, query?: ObservationQuery) |
Promise<Observation[]> |
Observations of one analyte since a floor, most recent first. |
ObservationQuery:
| Field | Type | Notes |
|---|---|---|
code |
string? |
Analyte code (LOINC 2160-0 or local creatinine). |
system |
string? |
Refines the filter when data carries a system. |
since |
string? |
ISO 8601 lower bound. |
limit |
number? |
Max values (most recent first). |
ProcedureHorizon = "past" | "upcoming" | "all". upcoming covers statuses
planned and in-progress; past is the complement.
Clinical writes
Unitary records, one per family. All return Promise<void>.
| Method | Signature | Semantics |
|---|---|---|
addObservation |
(patientId, obs: Observation) |
A DERIVED value (eGFR…) is an Observation carrying derivedFrom/note. |
addCondition |
(patientId, condition: Condition) |
|
addAllergy |
(patientId, allergy: Allergy) |
|
addProcedure |
(patientId, procedure: Procedure) |
Declares a performed procedure or plans one (status planned). |
addMedication |
(patientId, medication: Medication) |
Declares a medication STATEMENT — not a prescription. |
addNotification |
(patientId, notification: RecordNotification) |
Traces an alert/communication TO THE RECORD (re-read by snapshot). |
publishValidationRequest? |
(patientId, request: PendingValidationRecord) |
OPTIONAL. Publishes a human-validation request to the record — see SubjectRecordPort above. |
Clinical value shapes
All clinical elements carry a Coding ({ code: string; display: string; system?: string }).
Medication:
| Field | Type | Notes |
|---|---|---|
coding |
Coding |
|
status |
MedicationStatus? |
"active" | "on-hold" | "stopped" | "completed" | "intended"; default active. |
dosageText |
string? |
Free text. |
startedAt |
string? |
Condition: coding; status: "suspected" | "confirmed"; onsetAt: string;
resolvedAt?: string; recordedBy?: string.
Allergy: coding; criticality?: "low" | "high" | "unable-to-assess";
recordedAt?: string; note?: string.
Procedure: coding;
status: "planned" | "in-progress" | "completed" | "cancelled";
performedAt?: string (past); scheduledAt?: string (upcoming); note?: string.
Observation: coding (the analyte); value: number; unit: string;
observedAt: string; status?: "final" | "preliminary" | "amended";
interpretation?: string; derivedFrom?: string (analyte a derived value is
computed from); note?: string.
RecordNotification:
| Field | Type | Notes |
|---|---|---|
severity |
"info" | "warning" | "critical" |
|
title |
string |
|
body |
string |
|
category |
NotificationCategory? |
"alert" | "notification" | "reminder" | "instruction"; default derived from severity at write. |
recipient |
string? |
Logical role/team label, traced verbatim. |
at |
string? |
ISO 8601. |
inReplyTo |
string? |
Id of a Communication this trace replies to (partOf). |
Whole-record view
| Method | Signature | Returns |
|---|---|---|
snapshot |
(patientId: string) |
Promise<PatientProfile> |
PatientProfile bundles demographics, observations, conditions,
allergies, procedures, medications, notifications. It is the console and
simulation view, not a workflow read path.
PrescriptionPort (owned by @posos/clinical)
Source: @posos/clinical/ports.
| Method | Signature | Returns | Semantics |
|---|---|---|---|
getPrescription |
(prescriptionId: string) |
Promise<Prescription> |
|
addReconciliationNote |
(prescriptionId: string, note: ReconciliationNote) |
Promise<void> |
Prescription: id: string; patientId: string; items: Medication[].
ReconciliationNote: text: string; by: string; at: string.
AgentRunnerPort (SDK)
The LLM is an external system, so it sits behind a port. This is the one port the
ENGINE calls itself (kind: agent nodes). The default adapter is a mock; a real
adapter (Anthropic, OpenAI…) orchestrating the prompt + tool loop is wired
without touching the engine.
| Method | Signature | Returns |
|---|---|---|
run |
(req: AgentRunRequest) |
Promise<Record<string, unknown>> — one value per named output port. |
AgentRunRequest:
| Field | Type | Notes |
|---|---|---|
agent |
object | The resolved agent spec (below). |
inputs |
Record<string, unknown> |
Input values by port name, conforming to declared types. |
context |
ContextEnvelope |
Ambient context (plugin-contributed kinds), propagated to tools. |
tools |
AgentToolHandle[] |
Invocable tools (the agent's selected catalog nodes). |
agent:
| Field | Type | Notes |
|---|---|---|
nodeTypeId |
string |
|
name |
string |
|
provider |
string |
One of anthropic, openai, gemini, mistral, azure-openai, local. |
model |
string |
Required, non-empty (AgentSpec: « Le modèle est requis. »); no default model is applied to an agent. |
prompt |
string |
|
inputs |
AgentPort[] |
Named input ports (types resolved by the runner). |
outputs |
AgentPort[] |
Named output ports (forced-response schema). |
tools |
string[] |
Node type ids exposed as tools. |
maxIterations |
number? |
Turn budget (model call + tools). Runner default (8) if absent. |
AgentPort (from agent-spec.ts): name: string; type: string (primitive or
registry object name); list?: boolean (element list of type);
required?: boolean (inputs only, default true); description?: string.
AgentToolHandle:
| Field | Type | Notes |
|---|---|---|
id |
string |
|
label |
string |
|
description |
string |
|
inputs |
{ name: string; type: TypeRef; required?: boolean; description?: string }[] |
Tool argument schema. |
invoke |
(args: Record<string, unknown>) => Promise<Record<string, unknown>> |
The engine supplies this; it knows how to execute a node. The runner describes the tool to the LLM from inputs and calls it in the tool loop. |
ContextEnvelope is an open map Record<string, { id: string } | undefined> —
one key per context kind established at trigger time (see
Context kinds in the plugin manifest).
For authoring agents and the node exposure of an agent spec, see Author an agent and Use agents in a workflow.
NotificationPort (owned by @posos/notify)
Source: external-plugins/notify/ports.ts, imported as @posos/notify/ports.
Outbound notification — alerting humans through channels (console, webhook,
Slack, pager…). Distinct from the record trace addNotification on the patient
port: that is record data (FHIR Communication); this is BROADCAST, with its own
adapters and routing (instance manifest).
| Method | Signature | Returns |
|---|---|---|
send |
(notification: OutboundNotification) |
Promise<void> |
OutboundNotification:
| Field | Type | Notes |
|---|---|---|
severity |
"info" | "warning" | "critical" |
|
title |
string |
|
body |
string |
|
channel |
string? |
Logical channel (e.g. "garde-pharmacie") — routed by the adapter/its config. |
patientId |
string? |
Context only; never used for uncontrolled display. |
at |
string? |
DecisionPort (owned by @posos/decision)
Source: external-plugins/decision/ports.ts, imported as @posos/decision/ports.
Evaluation of a state against typed questions by a decision model: no generated
text, each answer is a probability, one of the declared options or a position
on a scale. The contract names no provider; an adapter translates it to its
own protocol (typesafe of @posos/typesafe: the System One API).
| Method | Signature | Returns |
|---|---|---|
evaluate |
(request: DecisionRequest) |
Promise<DecisionResult> |
DecisionRequest:
| Field | Type | Notes |
|---|---|---|
provider |
string? |
A provider of the adapter. Absent: the adapter's default. |
model |
string? |
Absent: the provider's default model. |
state |
unknown |
Text, object or list evaluated by the model. |
questions |
Record<string, DecisionQuestion> |
By id; the id is not sent to the model. |
A DecisionQuestion is { type: "yesno", instructions, yesMeans?, noMeans? },
{ type: "choice", instructions, options: Record<option, description | null> }
or { type: "score", instructions, levels: string[] }.
DecisionResult is { model, answers }: model is the versioned id that
answered, answers holds one DecisionAnswer per question id —
{ type: "yesno", probability }, { type: "choice", choice, probabilities, confidence }
or { type: "score", score, probabilities, confidence }. An adapter checks each
answer against its question (type, option, range) and fails otherwise.
TerminologyPort (SDK)
Source: libs/shared/src/domain/terminology.ts. Resolves the layer-1 value-sets.
Coded ports stay typed coded<valueSet>; only the RESOLUTION changes when a
different source is wired. For instance-side configuration see
Configure terminology.
| Method | Signature | Returns | Semantics |
|---|---|---|---|
has |
(valueSet: string) |
boolean | Promise<boolean> |
Is the value-set resolvable by this source? |
validateCode |
(valueSet: string, code: string) |
boolean | Promise<boolean> |
Runtime validation of a code in the value-set. |
expand |
(valueSet: string) |
ValueSetConcept[] | Promise<ValueSetConcept[]> |
Concepts of the value-set (editor dropdowns). |
display |
(valueSet: string, code: string) |
string | Promise<string> |
Human label of a code. |
subsumes |
(valueSet: string, parent: string, code: string) |
boolean | Promise<boolean> |
Hierarchical subsumption (coded-type sub-typing). |
Every method may return synchronously or as a Promise — adapters must tolerate
both when composing.
ValueSetConcept: code: string; display: string; parent?: string (for
hierarchical subsumption).
Bundled implementations
| Class | Behavior |
|---|---|
InternalTerminology |
Synchronous resolution from the internal value-sets.ts registry (offline). validateCode returns true for an external value-set (no internal authority — offline mode does not block). |
RoutingTerminology |
Routes each call to the provider owning the value-set (providers: Record<string, TerminologyPort>), else to a fallback (default InternalTerminology). Lets several plugins each cover a coding system. |
The host builds a RoutingTerminology from the terminology adapters bound in the
instance manifest. A terminology server is reached through such an adapter, contributed
by a plugin: the SDK ships no client for a particular server.
Defining a new port
The Ports bundle is open. A plugin may define a new port — export the
TypeScript contract, augment the SDK Ports interface with a declare module
block, and ship an adapter for it; the instance binds it by name in its manifest
exactly like any other port, and behaviors retrieve it via
requirePort<T>(ports, "<name>"). There is no fixed method surface — it is
whatever the plugin's contract declares. Optionally the plugin also contributes
a simulation sandbox for the port (contributes.portSandboxes) so the port is
available in simulation, and declares an adapter as the record port of a context
kind (record: { contextKind }). For wiring and packaging see
Implement a port adapter,
Autonomy and packaging, and
Bundling and distribution.