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 two integrations — serving a subject's record
(GET /api/records/:kind/:id, opaque return) and publishing a human-validation
request to the record when a run suspends. 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. |
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.
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?); 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 |
|
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? |
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 (POC, offline). validateCode returns true for an external value-set (no internal authority — offline mode does not block). |
FhirTerminologyServer |
Delegates to a FHIR server. validateCode → ValueSet/$validate-code; expand → ValueSet/$expand; display finds the code in the expansion; has and subsumes delegate to the internal fallback. Falls back to InternalTerminology on any non-OK response or error (graceful degradation). Uses each value-set's internal system URI as the canonical URL. Constructed with a baseUrl. |
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 (snomed-ct, icd-10…). |
defaultTerminology() returns a FhirTerminologyServer when
TERMINOLOGY_BASE_URL is set, otherwise an InternalTerminology. See
Environment variables.
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.