Documentation / Développement / Référence / Port contracts

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 — agent and terminology — plus the platform capability SubjectRecordPort and the platform concept PendingValidationRecord (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-request after a decision taken in the console or through the API (accepted or rejected), after the deadline (expired), and when a cancel reaches the run while it waits (withdrawn);
  • the proxy has the endpoint call it (withdrawn), through the record service, 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.

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