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 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. validateCodeValueSet/$validate-code; expandValueSet/$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.

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