Skip to content

Python SDK

The Python SDK is how an Open Meridian plugin talks to the deployment. A plugin's whole contract with the platform is its sidecar, listening on loopback beside it, and the SDK is a client for that surface and nothing more. It never learns the bus's address, never holds a broker credential, and never discovers another plugin. Access control, stamping and the bus all live on the sidecar's side. Anything the SDK offers is a convenience, never a decision.

Install

python3 -m venv .venv && . .venv/bin/activate
pip install open-meridian

Install it in a virtual environment: a bare pip install outside one is refused by some Pythons, such as Homebrew's on macOS (externally-managed-environment).

PyPI name open-meridian
Import name meridian
Version 0.36.0
Python 3.11 or newer
Dependencies grpcio>=1.68,<2, protobuf>=5.28,<7, jinja2>=3.1,<4 (for pages)
Licence Apache-2.0

Not meridian-sdk

The PyPI package meridian-sdk belongs to an unrelated company. Don't install it.

A plugin pins the SDK exactly, open-meridian==0.36.0, in its pyproject.toml. The sidecar it runs beside speaks one version of the interface, and a version range would let a rebuild pick up another. Its Dockerfile builds on the base image for the same version, ghcr.io/open-meridian/plugin-python:0.36.0, so move the two together: meridian plugin migrate moves both, and rewrites the plugin's code where a release changed what it calls (see Moving a plugin to a new release). CLI 0.1.60's meridian plugin new writes a plugin pinned to 0.35.0; meridian plugin migrate moves it to 0.36.0, which runs on chart 0.1.538.

Optional extra Installs For
migrate libcst Running the SDK's own migrations, python -m meridian.migrations, which meridian plugin migrate does in an image of its own. A plugin never needs it.

The package includes the wire bindings it speaks to the sidecar with, as meridian.v1 and meridian.plugin.v1.

Quick start

Each role a plugin holds is one class extending that role's base, meridian.roles.<role>.Role. Its acts are its methods, what it hears is its on_<event> methods, and meridian.run registers the classes on one connection and runs the plugin until it is stopped.

import meridian
from meridian.roles import custody

# What the version says about itself, named in pyproject.toml's [tool.meridian]
# as declaration = "my_plugin.declaration:DECLARATION"
DECLARATION = meridian.Declaration(
    interface=meridian.Interface(port=8000, title="My plugin"),
    reads_external_accounts=True,
    documentation="docs",   # docs/overview.md and docs/setup.md at least
)


class Custody(custody.Role):
    async def on_start(self) -> None:
        print(self.plugin.identity.instance_id, self.plugin.identity.roles)
        await self.report_external_accounts(
            accounts=[meridian.ExternalAccount(external_account_id="acct-1", name="Brokerage")]
        )


if __name__ == "__main__":
    meridian.run(declaration=DECLARATION, roles=[Custody()])

A version registers exactly the declaration it was uploaded with (see The whole declaration). A plugin that serves pages declares them with meridian.Pages, passes it as Interface(pages=...), and gives meridian.run the same pages= and port=. meridian plugin new writes a complete plugin built this way, with one page under Manage and one under Open and View. See Your first plugin.

What the package exports

meridian.__all__:

Name Kind Described in
run, run_async function, coroutine meridian.run: the plugin's entry point
connect async function meridian.connect: the connection beneath run, for a script or a test
Declaration frozen dataclass The whole declaration: what a version says about itself
Plugin class The connection: what is the whole plugin's, never a role's
Identity, Grants, Interface, Page, Setting, Choice, AppliesWhen, Settings, AccountScope, LinkedExternalAccount, Caller frozen dataclasses Types
Column frozen dataclass Column
Pages, Request, Response class, frozen dataclasses Pages
AccessLevel generated protobuf enum AccessLevel
Money frozen dataclass Typed operations
StatementFigures, ReportedCollateral, ReportedLot frozen dataclasses Typed operations
ReportedEncumbrance frozen dataclass Typed operations
OpeningSource, OpeningPosition, OpeningLot, PendingSettlement, LotTerms, PositionKey, BreakDifference, BreakValue, BreakCause, PendingSettlementRef, AgreementFigures, ReportedPositionValue, PositionEncumbrances, Encumbrance, Adjustment, MovementLine, BasisAdjustment frozen dataclasses Typed operations, the book's
BreakCategory, BreakCauseCategory, BreakState, LotReliefMethod, LotSource, OpeningSourceKind, PositionBasis, SettlementBucket, BreakHandling, FigureKey, MarginAgreementRef, PendingState, ResolvedByEntries, Reversal, StatementSegmentRef, StreetRecordRef generated protobuf enums and messages Typed operations, the book's
as_decimal, as_money functions Typed operations
Identifier, MissReason generated protobuf message and enum Types
Figure, FigureState frozen dataclass, generated protobuf enum Figures
AssetClass, CollateralDirection, ExternalAccount, HoldingSide, SyncState generated protobuf enums and message Typed operations
Heard frozen dataclass Events
TicketKind, TicketState, TicketResolution, TicketSubject, TicketReference generated protobuf enums, StrEnum, frozen dataclass file_ticket()
CustodialActivity frozen dataclass Typed operations
Storage, RecordKind frozen dataclasses The archive
StoredSpan, MoveOutcome generated protobuf message and enum The archive
ActivityKind, ActivityRef generated protobuf enum and message Typed operations
DatasetDeclaration frozen dataclass A data plugin's catalogue
Price, Bar, ObservationMeta, SourceTime frozen dataclasses Typed operations
SubjectRef frozen dataclass A row's subject: its entity_id, and from SDK 0.33.0 its role. Changed in SDK 0.33.0: the typed operations take this only, never the wire's (see 0.32.3 to 0.33.0)
ObservationMode, PriceKind, PriceBasis, SourceTimeKind, UnansweredReason, VenueKind, Source, SourceChoice, DatasetRef, Unanswered, VenueRecord generated protobuf enums and messages Typed operations, the lake's
Trade, TradeAttributes, Eligibility, Quote frozen dataclasses Typed operations
Aggressor, Eligible, TradeCharacteristic, QuoteCharacteristic generated protobuf enums Typed operations
roles module Role classes, and each role's common part and logic
helpers module Helpers: logic shared across roles by topic, which grants nothing
LiveValue, LiveValues, LiveView frozen dataclass, classes Live values and views
runs_as function A service account runs the plugin's autonomous work
SubjectKind generated protobuf enum Caller: whom the claims vouch for
AlgorithmDeclaration frozen dataclass Algorithms: one algorithm a version declares
OrderLink, OrderChange, PlacementChange, AllocationInstruction, AllocationLine, Leg, Party, ReceiptQuote, StrategyInstruction, Finality, Fee, LocateAnswer, LocateUse, VenueCatalogue, VenueOrderType, VenueStrategy, StrategyParameter, VenueEligibility, Actor frozen dataclasses and a generated protobuf message The order path
DateBasis, DateCheck, DateRuleRef, SettlementDateInputs, SpotDateInputs, TenorDateInputs, Tenor, RollInputs, BusinessDayInputs frozen dataclasses and generated protobuf messages Settlement calendars
Outcome, OutcomeDetermination, OutcomeStage frozen dataclass, generated protobuf enums Outcomes. Added in SDK 0.27.0
settings, setting, environment, sample_data decorator, functions Typed settings; account_currency added in SDK 0.27.0
session_ceilings, ceilings_from, dataset_thresholds, stale_reference_action, stale_reference_after functions Typed settings: latency's and staleness's settings. Added in SDK 0.32.0
quality_thresholds function Typed settings: a signal's outlier thresholds. Added in SDK 0.33.0
ListedSeries, Statistic, QuoteAtTrade, SeriesWant, LifecycleEvent, LifecycleDelivery, DerivedSeries, SavedScenario, Scenario, ScenarioAxis, ScenarioSet, Shock, ShockBucket, UnitValue frozen dataclasses Helpers and Listed options and futures. Added in SDK 0.33.0
Filing, ReportedFact, HoldingsReport, Holding, CorporateActionAnnounced, RatingAction, QualityAssessment frozen dataclasses The filings slice and quality states. Added in SDK 0.33.0
RefusedSetting frozen dataclass Settings: a saved value this version cannot read. Added in SDK 0.31.1
analytics, Job, WhatIf, WhatIfTrade, ComputedAs, NotEvaluatedReason, ScenarioKey, ScenarioKind, PricedUnit, Sensitivity module, generated protobuf messages and enums The analytics component and a pricing plugin's answers; see The analytics component's operations and a standing job. Added in SDK 0.31.0
MeasureRef frozen dataclass A measure a Job, a DerivedSeries or a CorporateActionAnnounced names: its definition, version and, in a standing job, its refresh, cadence_ns and material_move_bp. analytics.measure(...) answers the wire's, which analytics.job takes. Added in SDK 0.36.0
EventGroups, EventGroup, EventGroupFlag class, generated protobuf message and enum Event groups; see list_event_groups. Added in SDK 0.31.0
reached_environment, is_demo, connection_badge functions The agents' demo-only guard; see Typed settings. Added in SDK 0.31.0
CallerMiddleware ASGI middleware CallerMiddleware
MeridianError, Refused, NoSidecar, NotRegistered, NotGranted, CallFailed, NotLinked, CommandRefused exceptions Exceptions
DEFAULT_ADDRESS str "127.0.0.1:9191", where a sidecar listens
SCHEMA_VERSION str the interface version sent at registration, "v30"; each SDK release's is in the release notes

The module meridian.testing holds PageClient and heartbeat, for a plugin's own tests.

meridian.run

def run(
    declaration: Declaration,
    roles: Sequence[RoleBase],
    *,
    pages: Pages | None = None,
    port: int | None = None,
    on_settings: Callable[[Any], Awaitable[None] | None] | None = None,
    figures: Callable[[], Sequence[Figure]] | None = None,
) -> None

The plugin's process, until it is stopped. run_async takes the same arguments as a coroutine. It:

  1. handles SIGTERM and SIGINT, so a stop leaves the sidecar cleanly;
  2. connects with declaration and registers the classes in roles on one connection: one class per role, two for one role refused naming it, and a class for a role the instance was not launched with not started, which the log says;
  3. delivers the settings to on_settings, typed where the declaration's settings are a @meridian.settings class, the first delivery applied before any class starts, the watch opened again after a backoff when its stream breaks;
  4. runs each class's on_start, then hears one stream for every class, each delivery handed to every class hearing its row, in order, and starts each class's run;
  5. serves pages on port, reports the plugin healthy, and reads figures on every heartbeat;
  6. when stopped, cancels the runs, calls each on_stop and leaves.

A plugin holding no role passes roles=[]. State the classes share is passed to each: roles=[Lake(state), Trading(state)].

meridian.connect

async def connect(
    address: str | None = None,
    *,
    heartbeat: bool = True,
    wait: float = 60.0,
    interface: Interface | None = None,
    settings: Sequence[Setting] = (),
    reads_external_accounts: bool = False,
    declaration: Declaration | None = None,
    roles: Sequence[RoleBase],
) -> Plugin

Registers with the sidecar, the role classes in roles registered on it, and returns the admitted connection. A Plugin you hold is always one the sidecar admitted. It is the context manager beneath meridian.run, for a script or a test driving its classes itself: it starts no class's run and serves no page.

Parameter Type Default Meaning
address str or None None The sidecar's address. When None, the value of MERIDIAN_SIDECAR_ADDRESS, then 127.0.0.1:9191. No other configuration is read.
heartbeat bool True Send a liveness heartbeat to the sidecar every 5 seconds in the background, carrying the health last reported and the plugin's figures.
wait float 60.0 Seconds to wait for a sidecar that is not answering yet. A plugin and its sidecar start together in one pod, in no promised order.
interface Interface or None None The pages the plugin serves on loopback, if any. Declare it in the Declaration instead: given beside a declaration naming one, it must be the same, pages and tools alike.
settings sequence of Setting () The settings the plugin needs an admin of it to give it, on the dashboard's Settings form, or a table on its own tab beside it. A plugin sets none of them itself. Declare them in the Declaration instead: given beside a declaration naming some, they must be the same.
reads_external_accounts bool False True when the plugin reads accounts at an external source and names them by that source's identifiers. An admin of the plugin links those to accounts, and the sidecar translates them on the way in. Declare it in the Declaration instead.
roles sequence of role classes required The plugin's role classes, one per role it holds, as run takes them.
declaration Declaration or None None The version's declaration, the same one meridian plugin upload reads from the image: its settings, its interface, whether it reads external accounts, its documentation, what it does not carry and the storage it asks for (see The whole declaration). See Keep what your custody plugin converts. Where its storage declares kinds of raw record, connect declares each kind's two window settings and the restore route besides; see The archive. For a data plugin, its catalogue; see A data plugin's catalogue.

What connect is given beside a declaration that the declaration does not name is registered all the same, and warned of (UserWarning): the upload reads the declaration alone, so the sidecar refuses the difference.

The interface version it sends is SCHEMA_VERSION. A sidecar accepts a range of versions, v2 through the one its runtime serves: a plugin built for an older version it still supports registers, and one built for a newer version than the sidecar knows is refused at registration, naming both, rather than running without what it was built for. After an upgrade, relaunch plugins so they get the newer sidecar (meridian upgrade-deployment names the ones that need it).

Raises:

Exception When
NoSidecar No sidecar answered within wait seconds.
Refused The sidecar answered and declined to admit the plugin. Its reason says why. Not retried. It refuses a page or setting naming a role the plugin was not launched with, and, on a plugin holding several roles, one naming none, naming it and the plugin's roles. It refuses a registration that differs from the declaration the version was uploaded with, naming the first difference, and a plugin whose version was uploaded with no declaration, naming the missing declaration.
ValueError (from connect) interface or settings given beside a declaration naming different ones: name them once, in the Declaration.
TypeError A Setting's kind is not str, int, bool or list; it has choices and a kind other than str; its default is not of its kind; or its kind is list without columns, or another kind with them.
ValueError A secret Setting declares a default; a default is not one of its choices; a table is secret, or declares a default or choices, names a column twice, or has most_rows outside 0 to 500; a Column's kind is not one of the seven, it is named changed_by or changed_at, or it is a choice without choices; or a Page's path does not begin with /, or it names no level; and, when the declaration is made, a roles= naming something no role could be (a role is lower-case letters, such as custody), or more roles than there are.
grpc.aio.AioRpcError Any other gRPC failure during registration, unchanged.

When run by the development runner on a development deployment, connect also records the ready event once the plugin is admitted. See plugin dev events.

The whole declaration

A version's declaration, meridian.Declaration, is the whole of what it says about itself. meridian plugin upload reads it from the built image, as pyproject.toml's [tool.meridian] names it (declaration = "my_plugin.declaration:DECLARATION"), and the plugin registers with the same one, meridian.run(declaration=DECLARATION, roles=[...]).

src/my_plugin/declaration.py
import meridian

from .page import TITLE, pages


@meridian.settings
class Config:
    api_key: str = meridian.setting("API key", required=True, secret=True)


DECLARATION = meridian.Declaration(
    settings=Config,
    interface=meridian.Interface(port=8000, title=TITLE, pages=pages),  # a fixed port
    reads_external_accounts=True,
    documentation="docs",   # relative to where the plugin runs: /plugin in its image
)

Declaration, a frozen dataclass, keyword only:

Field Type Default Meaning
settings sequence of Setting, or a class decorated @meridian.settings () The settings it declares. See Typed settings.
interface Interface or None None Its pages, and the tools their typed routes make. Added in SDK 0.29.0.
reads_external_accounts bool False Whether it reads accounts at an external source. Added in SDK 0.29.0.
documentation str, path or None None The folder holding its documentation, relative to where the plugin runs: "docs", which is /plugin/docs in its image. Each section, overview.md, setup.md, troubleshooting.md, terms.md and pages.md, is named in the declaration by its sha256: digest and its size, 1 to 65,536 bytes, 256 KiB in all. The SDK reads and digests the files and parses no Markdown. See Write a plugin's documentation. Added in SDK 0.29.0.
affirmation_deadline int 0 An operations plugin's deadline, in minutes after midnight in the deployment's time zone, 1 to 1440, by which the firm allocates, confirms and affirms its trades; a held booking's alert falls due then. 0 declares none; refused on a plugin not holding operations. Added in SDK 0.29.0.
not_carried sequence () What it receives and does not carry. See Keep what your custody plugin converts.
storage Storage or None None The storage an edge plugin asks for. See The archive.
catalogue sequence of DatasetDeclaration () A data plugin's datasets. See A data plugin's catalogue.
algorithms sequence of AlgorithmDeclaration () The algorithms its autonomous work may name. See Algorithms.

Registration is held to the version. A plugin registers exactly the declaration its version was uploaded with, or its sidecar refuses it, naming the first difference; a version uploaded with no declaration is refused, naming the missing declaration. So the interface's port is fixed in the declaration, never read from the environment, and the module naming the declaration imports every module that declares a page or a route. The two window settings the SDK declares for each kind of raw record (see The window settings) are not among the declaration's own and are not compared. On a development deployment, a live instance's difference is a warning on its Summary instead.

meridian-declaration, installed with the SDK, prints a declaration as JSON for meridian plugin upload: meridian-declaration my_plugin.declaration:DECLARATION. With --documentation it prints the documentation's texts instead, a list of each section's section and markdown, which the upload sends beside the declaration. Both read the same files, so what is uploaded is what the image holds.

Role classes

A plugin writes one class per role it holds. Each extends its role's base, meridian.roles.<role>.Role, one module per role: compliance, confirmation, connectivity, custody, data, ems, oms, operations, portfolio, pricing, reference, reporting, servicing, settlement and signal. The bases are generated from the interface's matrix and the role suites.

  • Its acts are its methods, one per typed operation its role makes: await self.record_prices(...). An act two roles hold is the same call from either class, and core checks every act against the plugin's declared roles and grants.
  • Its events are on_<event> methods, one per row its role hears, named from the row (on_observations_wanted, on_activity_re_resolved). The ones its role's suite requires are abstract, so a class missing one is refused when it is made, naming the role and the event. An optional one is heard only where the class overrides it, and a row no class hears is never asked of the sidecar. See Events.
  • Its lifecycle: on_start(), once the plugin has registered and before the stream opens; run(), its long-running work, started after every class's on_start, and restarted after a backoff when it fails, the failure logged and said on the plugin's health; on_stop(), when the plugin stops.
  • What it hears: seed (a class attribute, True by default; False where its on_start reads the store itself), subjects(), the lake's subjects it hears, read when the stream opens, and self.hear_again() when they change, which opens the one stream again and catches up as after a broken stream.
  • The connection, self.plugin: what is the whole plugin's, never a role's. See The connection.
import meridian
from meridian.roles import data


class Lake(data.Role):
    def __init__(self, state):
        self.state = state
        self.wants = data.Wants(self, ("daily",))

    async def on_start(self) -> None:
        await self.wants.read_licences()

    async def on_observations_wanted(self, heard: meridian.Heard) -> None:
        await self.wants.hear(heard, self.state.answer)


meridian.run(declaration=DECLARATION, roles=[Lake(State())], pages=pages, port=PORT)

A helper making an act is given the role object, never the connection: data.Wants(self, ...), roles.Latency(self), chains.read_chain(self, ...). A page acts through the role object the plugin gives it, a closure or the state it shares, as the reference plugin's pages call CUSTODY.record_holdings_statement(...).

The SDK's common logic is role classes, registered as they are or subclassed: connectivity.Trader, pricing.Pricer, and the data feeds data.pairs.Feed, data.event_contracts.Feed, data.listings.Feed and data.option_chains.Feed. See Each role's common logic.

In a plugin's tests the suites drive a class through their recorder: lake = recorder.bind(Lake(...)) makes its acts the recorder's, recorder.answer(row, ...) says what a row delivers, and await recorder.hear(lake) hands the class each row it hears (recorder.hear(lake, *rows) the rows given alone). recorder.plugin stands in for the connection and makes no act, so a class, helper or page acting through the connection fails in the tests as it does when it runs. A PageClient given a recorder hands its pages the same as request.plugin.

The connection, Plugin

Built by connect, and held by every role class as self.plugin and by every page request as request.plugin. It holds what is the whole plugin's: its identity and grants, settings, health and figures, storage, raw records and archive moves, tickets, a person's own settings, views, access, the account scope, and leaving. It makes no act. It is an async context manager: leaving the async with block calls leave(), with the reason "stopping", or the exception's class name if the block raised.

Attributes

Attribute Type Meaning
identity Identity Who the plugin was launched to be: instance, roles, deployment. Read from the registration reply, never sent by the plugin.
grants Grants What the deployment allowed, as the topic patterns it allowed them as.
figures sequence of Figure The figures the plugin reports on its Summary, as last set. Set it to report a new list. See Figures.
stored sequence of StoredSpan What the plugin's storage holds of each kind of raw record it declared, as last set, on every heartbeat from the next on. See What is stored.

grants is for failing early with a good message, at startup, rather than at the first refused operation. The sidecar refuses independently of what the plugin believes, and the SDK offers no "is this allowed" check.

Methods

Method Returns Meaning
settings() AsyncIterator[Settings] The settings the plugin declared, now and again on every change.
account_scope() AsyncIterator[AccountScope] Every account anybody may read or write through this plugin, and the plugin's own links, now and again on every change.
access() Awaitable[PluginAccessReply] Who may use this plugin.
file_ticket(*, title, kind, idempotency_key, for_caller, ...) Awaitable[FileTicketReply] File a ticket for the person whose request the plugin is serving. See file_ticket().
filed_tickets(*, for_caller, ticket_ids=(), idempotency_keys=(), cursor="") Awaitable[ReadFiledTicketsReply] What became of the tickets the plugin filed. See filed_tickets().
archive_unit(record_kind, unit, *, record_count, first_received_ns, last_received_ns) Awaitable[None] Move a unit past its window to the archive. See The archive.
restore_unit(record_kind, unit, *, for_caller) Awaitable[Path] Restore an archived unit for a person, answering where it is readable. See The archive.
delete_unit(record_kind, unit, *, record_count=0, first_received_ns=0, last_received_ns=0, for_caller=None) Awaitable[None] Delete a unit, the deletion reported first. See The archive.
find_record(key) RecordMoveRequest or None Where a raw record's key stands: the last move of the unit holding it. See The archive.
report(*, healthy, detail="", figures=None) Awaitable[None] Report the plugin's health now, outside the heartbeat. It stands until reported again.
leave(reason="") Awaitable[None] Say the plugin is stopping, and close the connection.

Every method raises NotRegistered once the plugin has left.

settings()

async def settings(self) -> AsyncIterator[Settings]

Yields the plugin's settings as the deployment holds them, typed by what it declared at connect, first as they are now and then on every change. Values for names the plugin did not declare are left out. A setting that declares a default and has no value holds its default, so the plugin uses what the form showed. A value that does not parse as its declared kind raises ValueError rather than being guessed at. A boolean accepts true, yes, 1, on, false, no, 0 and off, in any case.

An admin of the plugin sets its settings on the dashboard's Settings form, and each table on its own tab beside it, the only places a setting is set, and the deployment records each change, naming who made it. The plugin only reads them: the SDK has no call that sets a setting, and the deployment refuses an update a plugin sends. See Set a plugin's settings.

async for current in plugin.settings():
    if current.missing_required:
        log.warning("waiting for %s", ", ".join(current.missing_required))
        continue
    token = current.values["api_token"]

While a required setting has no value, the sidecar reports the plugin unhealthy and names the setting.

A table setting arrives as a list of rows, each a dict of its cells as text by column name, with changed_by, the person who added or last changed the row, and changed_at, when, in RFC 3339 UTC. The deployment stamps both when it stores the table; a row left as it was keeps its own. A value that is not a table's rows raises ValueError.

async for current in plugin.settings():
    for row in current.values.get("plan_code_links", []):
        row["code"], row["instrument"], row["changed_by"], row["changed_at"]

account_scope()

async def account_scope(self) -> AsyncIterator[AccountScope]

Yields the plugin's account scope, now and again on every change. The scope is derived from permissions and from the plugin's links to accounts, and never declared: see Accounts. A plugin reads its whole read scope as itself and serves each person only what their access allows. The sidecar refuses a write outside write, whoever it is for.

Beside the scope come the plugin's own links: which of its external accounts an admin has linked, to which account, and that account's name as the deployment holds it now. They are read as the plugin itself, for nobody. The first delivery comes at once, so a plugin that has just started has every link. Another comes whenever a permission changes, a link is made or removed, or a linked account is renamed or closed. Hold the latest one: a plugin keeps nothing of its own across a restart.

async for scope in plugin.account_scope():
    for link in scope.links:
        print(link.external_account_id, "->", link.account_id, link.account_name)
    if scope.link_of("acct-3") is None:
        ...  # not linked: a row for it raises NotLinked

access()

async def access(self) -> meridian.v1.sidecar_pb2.PluginAccessReply

Returns who may use this plugin: each user group naming it, and each person who has signed in to the deployment, with their access. It is for shaping an interface; nothing here is an access decision. The reply is the generated protobuf message:

Field Type Meaning
user_groups repeated UserGroupAccess user_group_id, name, read_account_ids, write_account_ids and roles.
people repeated PersonAccess subject, display_name, user_group_ids, last_signed_in_at_ns, read_account_ids, write_account_ids and roles. Only people who have signed in are listed: the deployment holds no directory.

The account sets are this plugin's: what the group or person may read through it, and write through it. Every write account is also listed as read.

Each also carries roles, a RoleAccess for each of the plugin's roles the group or person holds account access on: role, level (read or write, never admin, since the table lists account access only), and read_positions and write_positions, the role's accounts as positions in that same message's read_account_ids. The two sets above stay the union over the roles.

file_ticket()

async def file_ticket(
    self,
    *,
    title: str,
    kind: str | int,
    idempotency_key: str,
    for_caller: Caller | str,
    seen: str = "",
    concerns: TicketSubject | str = TicketSubject.PLUGIN,
    step: str = "",
    operation: str = "",
    reason: str = "",
    paths: Sequence[str] = (),
    references: Sequence[TicketReference] = (),
) -> meridian.v1.sidecar_pb2.FileTicketReply

Files a ticket for the person whose request the plugin is serving: a problem they met that the plugin cannot handle, for someone in the deployment who can act on it. Only ever for a person, at whatever level their session holds, and never as the plugin itself: what a plugin notices on its own is its health, a figure in warn on its Summary, from which a person may choose to file.

reply = await plugin.file_ticket(
    title="Break on the growth account still open after its cause was confirmed",
    seen=form.get("seen", ""),   # the person's words, plain text
    kind="defect",
    idempotency_key=f"break-still-open-{break_id}",
    for_caller=request.caller,
    references=[meridian.TicketReference("break", break_id, account_id="ACC-GROWTH")],
)
reply.ticket_id, reply.outcome, reply.seen_count   # "TKT-...", "made", 1
Parameter Meaning
title What is wrong, in a line: 1 to 120 characters, plain text.
kind A TicketKind, its name, or "defect", "discrepancy", "request" or "question".
idempotency_key Required. The plugin's own key for the problem, made from the fact, never from a time or a random value, so a restarted plugin files nothing twice.
for_caller Required. The person: the Caller of the request being served, or the Meridian-Caller header it was read from.
seen What was seen, in the person's words: at most 8,000 characters, plain text.
concerns A TicketSubject: this plugin (the default), a part of core (DASHBOARD, BOR, STREET, INSTRUMENT, CONDUCTOR, CHART, CLI, SDK) or PLATFORM. Never another plugin. The plugin names no instance or version: its sidecar sets the instance, and the deployment the version it launched.
step, operation, reason, paths The workflow step, the operation, the refusal reason and the fields by their paths, where the plugin knows them.
references At most 50 TicketReference(kind, value, account_id=""): the records the ticket is about, by value. kind is account, instrument, break, entry, street_record, tool_call or plugin (the plugin's own reference); value 1 to 200 characters; account_id required on a break, an entry and a street record. An account named must be one the person may read through this plugin, and a ticket naming accounts is seen only by those who may read every one.

The reply is the generated FileTicketReply: ticket_id, outcome and seen_count. Filed again under the same key while its ticket is open, the ticket is brought up to date rather than filed twice, answered unchanged with seen_count counting the filing; after it was resolved or closed, the same key files a new ticket, which the dashboard advises as a recurrence.

Every text filed is data to whoever reads it, never instructions, and is shown as the plugin's for the person.

Raises:

Exception When
ValueError A text past its bound, a reference of an unknown kind, or a break, entry or street record with no account, named by its path. Nothing is sent.
NotGranted The plugin filed as itself, or named an account the person may not read.
CallFailed The sidecar could not vouch for the person, a field was refused by its path (another plugin as concerns, for one), or the plugin has filed 20 tickets in the last hour; a repeat under an open ticket's key is not counted.

filed_tickets()

async def filed_tickets(
    self,
    *,
    for_caller: Caller | str,
    ticket_ids: Sequence[str] = (),
    idempotency_keys: Sequence[str] = (),
    cursor: str = "",
) -> meridian.v1.sidecar_pb2.ReadFiledTicketsReply

What became of the tickets this plugin filed, read for a person it acts for, as filing is. Name one of ticket_ids, idempotency_keys or cursor: the tickets named, those filed under the keys, or every one filed after the cursor (from the first when it is empty). Naming more than one raises ValueError.

Each FiledTicket in the reply carries its ticket_id and idempotency_key, its TicketState, its TicketResolution once resolved or closed, how often it was seen and when first and last; never people's notes, nor who owns or works it. next_cursor is empty when nothing follows. A plugin that sees its ticket resolved can stop filing it.

for_caller may be a service account: in an act's route the dashboard reached for the role's service account, request.caller. The ticket, and what is read back, is then the service account's, naming it and its owner, within the plugin's rate. Filing a ticket and reading back what was filed are the only calls to the deployment's configuration a service account's claims reach.

report()

async def report(self, *, healthy: bool, detail: str = "", figures: Sequence[Figure] | None = None) -> None

Reports the plugin's health straight away. Ordinary liveness is already handled by the heartbeat. This is for a plugin that knows it is unwell and should say so.

The health reported stands, with its detail, on every heartbeat after, until the plugin reports again: report(healthy=False, detail="...") keeps it not healthy until report(healthy=True).

It carries the plugin's figures as they stand. figures sets them first, as setting plugin.figures does, and sends them now. Figures refused raise as setting them does, leave the health as it was, and send nothing.

leave()

async def leave(self, reason: str = "") -> None

Stops the heartbeat, tells the sidecar the plugin is stopping, and closes the channel. It does nothing when called a second time. A sidecar that is already gone is ignored. Saying so is what tells a planned stop apart from a failure. A crash skips it, which is why it is optional.

Events

A role class hears the rows its role hears through its on_<event> methods, one per row, each handed a Heard. The street store's rows are heard by operations, the custodian's activity, each sync status and an activity re-resolved among them; the book of record's by the roles that read the book; the lake's by the roles each row names:

Method Row heard.message Workflow step
on_statement_recorded StatementRecorded A StatementRecordedEvent: a completed statement, with its account, external account and institution, its counts and its figures per margin segment, as the street store announced it. The same message list_statements reads. W2.5
on_custodial_position_updated CustodialPositionUpdated A CustodialPositionUpdatedEvent: the position's whole new state in position, removed set when it was removed, the statement that changed it, and its previous quantity. W2.6
on_position_changed PositionChanged A PositionChangedEvent: the book's position whole in position, a BookPosition with its lots, pending settlements and encumbrances, removed set when it was moved off a placeholder; its previous_trade_date_quantity; and the entry that changed it. Heard by portfolio, reporting, compliance, oms and operations. W9.8
on_break_changed BreakChanged A BreakChangedEvent: the Break whole in break_record, and the entry. Heard by operations, oms, compliance, portfolio and reporting. W9.8
on_account_figures_recorded AccountFiguresRecorded An AccountFiguresRecordedEvent: one agreement's AccountFigures in figures, and the entry. Heard by operations, portfolio, compliance and reporting. W9.8
on_account_attribute_changed AccountAttributeChanged An AccountAttributeChangedEvent: the account's AccountAttributes in attributes, its standing opening balance among them, and the entry. Heard by portfolio, reporting, compliance, oms and operations, and by an ems and a connectivity plugin for the accounts its ems mapped on it, which may use whether their releases wait for compliance. W9.8
on_activity_recorded ActivityRecorded An ActivityRecordedEvent: an activity on the account as the custodian stated it, recorded once. The same message list_activities reads, so a break waiting on its cause can be compared again. preview. W2.12
on_sync_status_recorded SyncStatusRecorded A SyncStatusRecordedEvent: a sync status the street kept, so "needs sign-in" is told apart from merely old. The same message list_sync_statuses reads. preview. W2.13
on_activity_re_resolved ActivityReResolved An ActivityReResolvedEvent: an activity recorded before its instrument resolved, re-resolved, its re-resolution kept beside it. Caught up from the re_resolutions of list_activities. preview. W2.16
on_prices_recorded PricesRecorded A PricesRecordedEvent: one Price the lake recorded, in price, for a subject the class's subjects() names. Heard by reporting, portfolio, compliance and signal, from a dataset the plugin is entitled to. Caught up by list_prices. preview. W10.5
on_bars_recorded BarsRecorded A BarsRecordedEvent: one Bar, in bar, likewise. Caught up by list_bars. preview. W10.5
on_observations_wanted ObservationsWanted An ObservationsWantedEvent, heard by the data plugin serving its dataset alone: want_id, dataset, data_type, subjects, kinds, interval_ns, the business_date or valid range wanted, and standing. See Wants. preview. W10.7
on_want_withdrawn WantWithdrawn A WantWithdrawnEvent, want_id and dataset: a standing want no reader asked for within its dataset's cadence. preview. W10.7
on_trades_recorded TradesRecorded A TradesRecordedEvent: one Trade the lake recorded, in trade, for a subject the class's subjects() names. Heard by signal and ems, every one, never conflated. Caught up by list_trades after the watermark last seen. preview. W10.5
on_quotes_recorded QuotesRecorded A QuotesRecordedEvent: one Quote, in quote, latest value first per subject, dataset, venue and asset. Heard by signal and ems. Caught up by list_quotes. preview. W10.5
on_order_updated OrderUpdated An OrderUpdatedEvent: the order whole, with what changed. Heard by oms, ems, compliance, portfolio, operations and reporting within their read scopes. Caught up by list_orders. preview. W11.13
on_placement_updated PlacementUpdated A PlacementUpdatedEvent: the placement whole, with its own change requests (placement.change_requests). Heard by the ems, oms and compliance within scope, and by the connectivity plugin it names alone of its role. preview. W11.13
on_execution_recorded ExecutionRecorded An ExecutionRecordedEvent: an execution, never edited; a bust or correction is another, naming the one it supersedes. Heard by the order's hearers, by the connectivity plugin that reported it too (added in SDK 0.27.0), and an unmatched one by the ems linked to it. preview. W11.13
on_unplaced_fact_recorded UnplacedFactRecorded An UnplacedFactRecordedEvent: a venue's fact the book could not place, with its reason. Heard by operations. preview. W11.13
on_trading_instruction_changed TradingInstructionChanged A TradingInstructionChangedEvent: an instruction set, whatever the plugin's accounts. Heard by oms, ems, connectivity, portfolio and compliance; caught up by list_trading_instructions, a read naming no account. preview. W11.16
on_locate_updated LocateUpdated A LocateUpdatedEvent: a locate and its uses. Heard by compliance, the oms and portfolio; caught up by list_locates. preview. W11.18
on_outcomes_recorded OutcomesRecorded An OutcomesRecordedEvent: one Outcome, in outcome, every version, never conflated, for a subject the class's subjects() names. Heard by reporting, portfolio, compliance, signal and operations; caught up by list_outcomes after the watermark last seen. Added in SDK 0.27.0; preview. W10.5
on_report_venue_catalogue ReportVenueCatalogue A VenueCatalogueEvent: a connectivity plugin's venue catalogue for one venue account code, latest per plugin and code. Heard by the ems and the oms; read again with read_venue_catalogue on start and after a loss. preview. W11.20
on_rates_recorded RatesRecorded A RatesRecordedEvent: a rate, conflated, the latest per subject, dataset, kind, tenor, maturity and convention, for a subject the class's subjects() names. Heard by reporting, portfolio, compliance and signal; caught up by list_rates. Added in SDK 0.32.0
on_liveness_recorded LivenessRecorded A LivenessRecordedEvent: a dataset's latest sign of life per venue, conflated; roles.Liveness().heard keeps it. Heard by reporting, portfolio, compliance, signal and the ems; caught up by list_datasets. Added in SDK 0.32.0

Since SDK 0.35.0 the lake's factors and series are heard too: on_factors_recorded (operations, portfolio, reporting, servicing, signal; every version, never conflated, caught up by list_factors) and on_series_recorded (ems, oms, portfolio, signal).

import meridian
from meridian.roles import operations


class Operations(operations.Role):
    async def on_activity_recorded(self, heard: meridian.Heard) -> None:   # required of operations
        activity = heard.message.activity
        ...

    async def on_statement_recorded(self, heard: meridian.Heard) -> None:  # optional: heard because overridden
        statement = heard.message   # the whole statement, as the store announced it
        ...
  • Seeded. It reads the store first, every row's records across the plugin's read scope, and hands each on, marked caught_up. With a class's seed = False it reads the store without handing on what it holds to that class, and hands on only what changes after.
  • Once and in order. Then it hands on each change heard, once, and in order for each account.
  • Caught up. Deliveries are at most once: the sidecar queues at most 1024 for the plugin and drops past that, and marks each drop in the stream as a Lost. Where one was missed (a gap in an account's changes, a Lost, a stream that broke, or an account entering the read scope), it reads the changes since from the store and hands them on before anything heard after, each marked caught_up. A stream that broke is opened again, half a second later and doubling to 15 seconds. An account leaving the read scope is heard of no more.

A plugin catches up from the store, never from the bus, and the SDK does it: a handler never sees the store's numbers, and keeps nothing to catch up with. A change carries its whole new state, so one handed on twice changes nothing a handler keeps; where reading a page at a time cannot say exactly where the store was, a change may be handed on twice rather than never.

The lake's rows. A price or a bar is heard only for the subjects named in subjects=, by their instrument IDs, at most 500, and none without them, from the datasets the plugin is entitled to. Each is delivered latest value first: the sidecar keeps one value waiting per key (a price's dataset, subjects, venue and kind; a bar's dataset, subjects, venue and interval start), a later value replacing one not yet delivered, and the SDK hands on the newest per key, never an older value after a newer one. On start, after a Lost and after a broken stream it reads them again by list_prices or list_bars, latest first and side by side, marked caught_up. Each carries its dataset in Heard.dataset. A price's key holds the asset it is in too, so BTC priced in USD and in USDC on one venue are two values, neither replacing the other.

Trades and quotes. Quotes are heard as prices are: latest value first per subject, dataset, venue and asset, read again at the latest after a loss. Trades are handed on every one, once, in their dataset's order, never dropped for a later one. After a Lost naming them, or a broken stream, the SDK reads the trades recorded after the watermark it last saw (per dataset, the last trade it handed on), side by side for the subjects named, and hands them on marked caught_up, a late print among them, before anything heard after; a trade the stream delivers after the read had it is handed on once. On start it reads none: there is no latest trade to begin from, so a plugin wanting the day's trades reads them by range with list_trades. Each carries its dataset in Heard.dataset.

The order path's rows. A placement is delivered only to the connectivity plugin instance it names; no other instance of the role hears it. The trading instructions name no account: each is heard by every subscriber whatever its read scope, kept under no account, and caught up after a gap or a loss by a read naming none (list_trading_instructions, since the watermark last seen). The venue's catalogue is delivered latest per connectivity plugin and venue account code, a later one replacing one not yet delivered.

Everything heard is within the plugin's read scope, as the reads are, and a plugin whose read scope is empty hears nothing. It reads and hears for its whole scope, as itself: serve each person from it with caller.read, as always.

Heard

@dataclass(frozen=True)
class Heard(Generic[Message]):
    row: str
    message: Message
    caught_up: bool = False
    own: bool = False
    cause: ChangeCause | None = None
    message_id: str = ""
    correlation_id: str = ""
    causation_id: str = ""
    published_at_ns: int = 0
    dataset: DatasetRef | None = None
Field Meaning
row The row, such as "CustodialPositionUpdated".
message The row's message, from meridian.plugin.v1.operations_pb2, with the store's numbers taken off.
caught_up True when it was read from the store rather than heard: when the plugin started, after a gap or a Lost, after the stream broke, or when an account entered its read scope.
own True when the plugin's own act caused it.
cause Who caused it, where the store recorded it, a ChangeCause: instance_id, the instance that sent the command; acting_for_subject, the person it was sent for, empty when the plugin acted as itself; correlation_id; causation_id, the command's message_id; and committed_at_ns.
message_id, correlation_id, causation_id, published_at_ns From the envelope it was heard in. Empty, and 0, for what was read.
dataset For a price, a bar, a trade or a quote: the dataset it came from, a DatasetRef with its instance, vendor, aggregator and catalogue entry, as list_datasets answers it, which the lake names once rather than on every row. None for any other row, and where the plugin may not list the datasets.

Raises: a class missing a required event is refused when it is made, naming the role and each event. NotGranted for a row none of the plugin's roles hears. A read to catch up that finds nothing serving it, has no answer in time, or is refused by what serves it is tried again with the stream; any other refusal raises as the read's own would.

Figures

Manage opens on the plugin's Summary, which core draws: its status first (its health and why, the version running and the interface version it registered with), then a few figures the plugin reports about its own work, each a tile. A plugin gives them on its heartbeat and builds no summary page of its own. See Manage, Open and View.

from datetime import datetime, timezone
import meridian

plugin.figures = [
    meridian.Figure("Connections", 3, state="warn",
                    why="1 connection needs attention: the brokerage asked to reconnect"),
    meridian.Figure("Accounts reached", 7),
    meridian.Figure("Last read", datetime.now(timezone.utc)),
]

The list goes on every heartbeat from the next on, in its order, each heartbeat replacing the last; an empty list clears it. await plugin.report(healthy=True, figures=[...]) sends it at once. A figure is about the plugin's own work: it names no account and carries none of an account's data, since Manage shows none. Report figures only where the plugin has work worth counting.

Figure

@dataclass(frozen=True)
class Figure:
    label: str
    value: int | Decimal | str | datetime
    as_of: datetime | None = None
    state: str | int | None = None
    why: str | None = None
Field Meaning
label As a person reads it, "Connections". 1 to 40 characters, and given once in the list.
value What kind of figure it is: an int is a count, a Decimal a decimal, a str a text, at most 40 characters, and a timezone-aware datetime a time, such as "Last read". Always given.
as_of When the value was true, where that is not the heartbeat's moment. A timezone-aware datetime.
state The tile's mark: "ok", "warn" or "error", or a meridian.FigureState. None is no state, drawn plain.
why The note beside the tile. At most 200 characters.

A decimal is carried exactly, as typed operations carry one: at most 18 decimal places and 38 digits, refused rather than rounded. Core writes a time as the dashboard writes every moment.

Bounds

At most 8 figures; a label of 1 to 40 characters, given once; a text of at most 40; a why of at most 200; a state of ok, warn or error; a value always given; a decimal within what the wire carries. Anything past a bound is refused, never cut, at the line that sets the list, in the words the sidecar would refuse it with, and nothing is set: the last list stands.

Raises When
ValueError A bound is broken, for example 9 figures; a plugin reports at most 8, figures[0].label is 44 characters; a label is at most 40, or figures[0].state is 'amber', which the interface does not define.
TypeError A value is none of a figure's kinds, a datetime has no timezone, or one Figure is given where a list is.

The sidecar checks them again. A heartbeat it refuses still says the plugin is alive; the plugin is then reported not healthy, the refusal as its why, with no figures. Core draws no figures for a plugin that is not registered or has fallen silent, since they would be stale; its status says why.

In a plugin's tests, meridian.testing.heartbeat is the heartbeat its sidecar receives, or the refusal.

The archive

A plugin holding an edge role declares the kinds of raw record it keeps; past each kind's window it archives, keeps or deletes them, as its admin chose, in units it can find again; and it reports every move through its sidecar before it removes anything. What that means for the people using it is in The archive. Each field on the wire is in the data dictionary: RawRecordKind, StoredSpan and RecordMoveRequest.

import meridian
from meridian.declaration import Declaration, RecordKind, Storage

DECLARATION = Declaration(
    settings=SETTINGS,
    interface=INTERFACE,
    storage=Storage(kinds=[
        RecordKind("activity", "Reported activity", window_days=2555),
        RecordKind("responses", "Raw responses", window_days=30),
        RecordKind("session", "Session state", window_days=7, archivable=False),
    ]),
)

async with await meridian.connect(declaration=DECLARATION, roles=[Custody()]) as plugin:
    async for settings in plugin.settings():
        window = settings.values["activity_window_days"]   # days, the admin's
        past = settings.values["activity_past_window"]     # archived, kept or deleted
        for unit, count, first, last in units_past(window):  # the plugin's own units
            if past == "archived":
                await plugin.archive_unit("activity", unit, record_count=count,
                                          first_received_ns=first, last_received_ns=last)
            elif past == "deleted":
                await plugin.delete_unit("activity", unit, record_count=count,
                                         first_received_ns=first, last_received_ns=last)
        plugin.stored = [meridian.StoredSpan(record_kind="activity", record_count=...,
                                             first_received_ns=..., last_received_ns=...)]

Storage and RecordKind

@dataclass(frozen=True)
class Storage:
    retention_days: int = 0
    kinds: Sequence[RecordKind] = ()

@dataclass(frozen=True)
class RecordKind:
    name: str
    label: str
    window_days: int
    archivable: bool = True

Storage is the storage a version asks for, on its Declaration; only a plugin holding an edge role may ask for it. It takes kinds.

Field Meaning
Storage.kinds The kinds of raw record the version keeps, at most 16, each named once.
Storage.retention_days The reach of a backfill, 1 to 36,500 days. Left 0 with kinds, it is the longest of their windows. A version declaring no kinds keeps its records under it.
RecordKind.name The name its code and settings use: 1 to 40 lowercase letters, digits and underscores, beginning with a letter, such as activity.
RecordKind.label The label a person reads, 1 to 40 characters, such as Reported activity.
RecordKind.window_days Its default window, 1 to 36,500 days from when a record was received.
RecordKind.archivable Whether a unit of it can be moved to an archive. False for state read and updated in place.

Raises ValueError when the declaration is built: a bound broken, a kind declared twice, or a setting of the plugin's own taking one of the window settings' names below.

The window settings

For each kind, connect declares two settings beside the plugin's own, the same for every edge plugin, which an admin of the plugin sets on its Settings form. On a plugin naming roles, they serve its edge roles.

Setting Label Kind Default
<kind>_window_days Label: window int, days the kind's window_days
<kind>_past_window Label: past the window a choice of archived, kept or deleted archived where the instance was given an archive and the kind is archivable; kept otherwise

The names are the SDK's: a plugin declaring a setting of either name for a kind it declares is refused by the SDK, by the sidecar at registration, and by meridian plugin check (window-settings). Read them from settings() as delivered. The deployment refuses a save setting a window below the hold over the instance, naming the setting, and archived for a kind not archivable or an instance allowed no archive.

edge.archive_dir()

def archive_dir() -> Path | None

In meridian.edge. The archive a deployment admin allowed this instance, where it is mounted (MERIDIAN_ARCHIVE_DIR), or None: in a test, for an instance allowed none, or where the archive is a bucket instead (MERIDIAN_ARCHIVE_BUCKET, an s3:// URL read through boto3, which a plugin deployed with one installs). The helpers below reach either through the same interface; a plugin need not read the archive itself. Its storage is edge.storage_dir(), as before.

A unit

A unit is a file or a directory in the plugin's storage, named by its path there, such as activity/ACC-1/2019-03. The records it holds are the paths within it, which rows name as their raw record's key (plugin.raw_record("activity/ACC-1/2019-03/act-77.json")). The helpers keep an index of what moved inside the storage, under .meridian/, which is never a unit.

archive_unit()

async def archive_unit(
    self, record_kind: str, unit: str, *,
    record_count: int, first_received_ns: int, last_received_ns: int,
) -> None

Moves one unit past its window to the archive: writes it there, checks that each file landed (its size and SHA-256), reports the move through the sidecar with its rule, the kind's window setting and its value (activity_window_days 2555), and only then removes it from storage. record_count and the span are the records the unit holds, 1 or more, the span from the first received to the last.

Raises When
ValueError The kind is not declared, or declared not archivable; the unit is archived already; the count or span is out of bounds; or the kind's <kind>_past_window is not archived.
RuntimeError The instance is allowed no archive; the settings have not arrived yet; or the unit would take the archive past its bound (MERIDIAN_ARCHIVE_MOST_BYTES), saying what the archive holds, its bound and the unit's size. Nothing is written or reported, and the unit stays in storage.
FileNotFoundError The unit is not in the plugin's storage.
CallFailed, NotGranted The sidecar refused the move: nothing is recorded, and the unit is kept.

restore_unit()

async def restore_unit(self, record_kind: str, unit: str, *, for_caller: Caller | str) -> Path

Restores an archived unit for the person who asked: copies it back from the archive to the restore area in storage, each file checked against what was archived, reports the restore for for_caller (their Caller, or the header it was read from), and answers where the unit is readable. It stays readable for seven days, the restore period; then it is removed and its return reported, under the rule restore period 7 days. Asked again meanwhile, it answers the same path and reports nothing.

Raises When
ValueError The unit is not archived.
RuntimeError The instance has no archive now: what it holds is kept, and restorable once a deployment admin allows one again.
NotGranted The person does not hold write on one of the plugin's edge roles. The copy is removed.
OSError A file did not come back as it was archived.

The restore route

An edge plugin declaring kinds and serving pages through meridian.Pages offers POST /archive/restore on its host, which connect declares at write on the plugin's edge roles and the deployment derives as the restore_unit tool on its MCP surface. It takes record_kind (at most 40 characters) and unit (at most 512), as a form or as the tool's input, restores the unit for the person the claims name, and answers a form with a redirect to the page it came from and the tool with {"record_kind": ..., "unit": ..., "outcome": "restored"}. A refusal names the field (record_kind or unit), or says not_granted or not_restored. A plugin's page offers it as a form under Open, posting the two fields:

{% if level == "write" %}
<form method="post" action="/archive/restore">{{ csrf_input }}
  <input type="hidden" name="record_kind" value="responses">
  <input type="hidden" name="unit" value="{{ unit }}">
  <button>Restore</button>
</form>
{% endif %}

delete_unit()

async def delete_unit(
    self, record_kind: str, unit: str, *,
    record_count: int = 0, first_received_ns: int = 0, last_received_ns: int = 0,
    for_caller: Caller | str | None = None,
) -> None

Deletes one unit, the deletion reported before anything is removed, so a refusal keeps it. Either a unit in storage past its window, as the plugin itself, where the kind's <kind>_past_window is deleted, its count and span given; or an archived unit, which is an admin's act, for_caller the person holding admin on one of the plugin's edge roles, its count and span the index's.

Raises When
CommandRefused The unit's last record was received inside the deployment's hold over the instance: reason_name is REFUSAL_REASON_WITHIN_HOLD. Nothing is recorded, and nothing is deleted. The plugin is never told the hold; try again on a later pass.
ValueError The kind is not declared; the unit is deleted already; the unit is archived and no for_caller is given; or, as the plugin itself, the kind's <kind>_past_window is not deleted.
RuntimeError The settings have not arrived yet.
FileNotFoundError The unit is not in the plugin's storage.
NotGranted The person does not hold admin on one of the plugin's edge roles.
CallFailed The sidecar or the deployment refused it otherwise.

find_record()

def find_record(self, key: str) -> RecordMoveRequest | None

Where a raw record's key stands, by the index: the last move of the unit holding it, the unit itself or a path in it, whose outcome is MOVE_OUTCOME_ARCHIVED (archived, restorable), MOVE_OUTCOME_RESTORED (readable in the restore area) or MOVE_OUTCOME_DELETED; or None for a record never moved, in storage where the plugin put it. A row's raw-record key resolves through it on the plugin's own page, never to nothing.

What is stored

plugin.stored is what the plugin's storage holds of each kind, as only the plugin knows it: one StoredSpan per kind, set whenever it changes and sent on every heartbeat from the next on, which the plugin's Summary draws beside what the archive holds.

StoredSpan field Meaning
record_kind A declared kind, once.
record_count How many records of it the storage holds.
first_received_ns, last_received_ns When the first and the last of them were received; both 0 with no record.
bytes The bytes the kind uses of the archive. The SDK fills it in from its index on every heartbeat, in place of any the plugin set, and adds a kind the plugin left out that the archive holds some of.

Raises ValueError for a kind not declared, a kind twice, more than 16, or a span that is not from the first record to the last, and TypeError for anything but a StoredSpan; nothing is set then.

The moves

What a move carries, as the sidecar records it (RecordMoveRequest): record_kind, unit, record_count, first_received_ns, last_received_ns, outcome (MoveOutcome: MOVE_OUTCOME_ARCHIVED, MOVE_OUTCOME_RESTORED, MOVE_OUTCOME_RETURNED or MOVE_OUTCOME_DELETED) and rule, a window's or the restore period's. A move made for a person names no rule: the person is read from the assertion, never a field. Nothing of a record's content is in a move.

The sidecar checks each before it leaves, refusing CallFailed with kind="invalid" naming the field by its path: a kind the version did not declare, archived of a kind not archivable, a key, count, span or rule out of bounds. The deployment refuses archived for an instance allowed no archive. A restore, its return and an archiving for a person need write on one of the plugin's edge roles, and a deletion for a person admin (NotGranted).

The lake

preview. The lake keeps what sources say about prices, append-only and point in time. A data plugin puts prices and bars in; reporting, portfolio, compliance and signal read and hear them. It puts trades and quotes in too, which signal and ems read and hear: see Trades and quotes. The SDK carries the typed operations and nothing of any vendor's: no HTTP or WebSocket client, no vendor-file parser, no reconnecting stream. Each operation is in Typed operations; each field in the lake's data dictionary.

A data plugin's catalogue

A data plugin declares the datasets it serves in its declaration, from code, beside its settings and storage: Declaration(catalogue=[DatasetDeclaration(...)]). A dataset's ID in a deployment is the instance, a colon and its key, f"{plugin.identity.instance_id}:daily", which each row names.

from meridian import DatasetDeclaration, Declaration, RecordKind, Storage

DECLARATION = Declaration(
    settings=SETTINGS,
    storage=Storage(kinds=[RecordKind("responses", "Raw responses", window_days=30)]),
    catalogue=[
        DatasetDeclaration(
            key="daily",
            vendor="Coinbase",
            data_types=["meridian.v1.Price", "meridian.v1.Bar", "meridian.v1.Bar.trade_count"],
            modes=["pull", "push"],
            cadence=86_400,
            history=3650,
            terms_and_pricing="Public market data, free of charge; one person's own use.",
            day_time_zone="Etc/UTC",
            day_end_minute=0,
            gap_recovery="backfill",
        ),
    ],
)

DatasetDeclaration, keyword only:

Field Type Meaning
key str Required. 1 to 40 lowercase letters, digits and underscores, a letter first; once in the catalogue.
vendor str Required. Who originated the data, 1 to 64 characters, as the plugin names it.
data_types sequence of str Required, 1 to 32: the lake's types it serves by their message (meridian.v1.Price, meridian.v1.Bar), and the optional fields it fills by their dictionary entry (meridian.v1.Bar.vwap), each once.
modes sequence of str or ObservationMode Required, 1 to 3, each once: pull, push, stream.
aggregator str Who carries it, where an aggregator does; empty when reached directly.
cadence int Seconds between the source's updates, which say when it is silent; 0 when it updates only when asked.
history int Days of history the source serves; 0 for none stated.
terms_and_pricing str The vendor's terms and pricing in plain words, at most 2,000 characters: the sources behind it, what it charges and on what meters, what it forbids. Shown on Data sources and in the plugin's docs, read by the deployment admin's agent as data; never a licence, which is the deployment admin's (see Licences and entitlements). Added in SDK 0.36.0, in place of licence_default.
day_time_zone str The IANA zone its business dates are in; empty for a dataset that is not daily.
day_end_minute int Minutes after local midnight the day ends, 0 to 1,439. See Dates and time.
venue_id str The venue the dataset is, a venue master ID (VEN-); empty for a consolidated dataset.
calendar_id str The settlement calendar its business dates follow, its CAL- ID; empty for none. The lake flags a daily value dated on a day the calendar says is closed, and refuses nothing by it. Added in SDK 0.28.0.
sessions_calendar_id str The trading-sessions calendar its live rows follow, a CAL- ID beside calendar_id: an FX dataset names the FX week. helpers.sessions.read_sessions(self, dataset) reads its sessions. Added in SDK 0.36.0.
declared_delay_ns, price_source_class int, str How far behind real time the vendor serves it (0 for real time), and the class of source its prices come from (traded, firm_quote, composite or evaluated; empty for not stated). See Data age and liveness.
gap_recovery str How a gap in it is recovered: backfill (the vendor serves history, so the lake asks again through the want), stream_only or none; empty reads as none. See Gaps. Added in SDK 0.36.0.

The catalogue declares no licence: DatasetLicence and DataEligibility left the SDK in 0.36.0, and meridian plugin migrate writes a licence_default as terms_and_pricing (see 0.35.5 to 0.36.0). A dataset nobody has licensed is kept and read, each use right read restrictively, until a deployment admin licenses it.

Shards. Declaration(most_shards=...), 0 to 64, is the most pods of one instance its vendor allows; 0 or 1 says it is not shardable. A deployment admin may launch the instance with up to that many shards; the deployment keeps them on the launch, and the plugin runs one pod today (see Its limits, shards and standby pod). Added in SDK 0.36.0.

Raises ValueError when the declaration is built, naming the dataset: a key out of form, a vendor or aggregator too long, a data type or field no dictionary entry names, one named twice, no mode or a mode twice, a negative cadence or history, a zone that is no IANA zone, a day end out of range, a venue that is no VEN- ID, a calendar that is no CAL- ID, terms and pricing past 2,000 characters, a gap_recovery that is none of its three words, most_shards outside 0 to 64, and a catalogue on a version not holding data. The upload's JSON carries the catalogue only where one is declared, its modes as words. The sidecar holds a catalogue to the same rules at registration, and refuses the plugin naming the field.

Recording

A data plugin records with record_prices and record_bars, 1 to 500 rows a call, refused outside the bound before anything is sent, recorded whole or refused naming the row and field. Resolve each subject first with resolve_identifier, and each venue with resolve_venue; report what does not resolve. A want for an instrument another source resolved, such as a custodian's, names the deployment's ID: read its record with resolve_instrument and name it to the vendor by an identifier the vendor takes. The answer counts the rows recorded, restated and unchanged.

Wants

A data plugin hears what the lake wants of it with its data class's on_observations_wanted (required of data) and on_want_withdrawn, records against a want with want_id=, and declines per subject with decline_want. data.Wants(self, ...) does this for a plugin's own conversion (see Each role's common logic); by hand:

class Lake(data.Role):
    async def on_observations_wanted(self, heard: meridian.Heard) -> None:
        want = heard.message       # dataset, data_type, subjects, kinds, business_date or a range
        await self.record_prices(prices=fetched(want), want_id=want.want_id)
        await self.decline_want(want_id=want.want_id, subjects=uncovered, reason="not_covered")

    async def on_want_withdrawn(self, heard: meridian.Heard) -> None:
        stop_keeping_current(heard.message.want_id)

A standing want asks that its subjects be kept current until it is withdrawn.

Reading

A reader reads with list_prices and list_bars, and learns the datasets it may read with list_datasets. The readers' choices are the reads' parameters, not a library:

from datetime import date

closes = await role.list_prices(subjects=held, kinds=["close"], business_date=date(2026, 10, 8))
then = await role.list_prices(subjects=held, business_date=date(2026, 10, 8), as_of_ns=cut)
both = await role.list_prices(subjects=held, sources=meridian.SourceChoice(side_by_side=True))
named = await role.list_prices(subjects=held, sources=meridian.SourceChoice(named=["alpaca-1:daily"]))

and hears what is recorded after with on_prices_recorded and on_bars_recorded, for the subjects its class's subjects() names; see Events.

The source priorities. A role that reads the lake (reporting, portfolio, compliance, signal, ems, oms, operations, servicing) reads the order a default read takes its sources in, list_source_priorities: each priority with only the datasets the plugin is entitled to read, and a priority naming none of them not at all, so a page can say which source its values come from next. Setting one stays a deployment admin's. Added in SDK 0.36.0.

read = await role.list_source_priorities()
for priority in read.priorities:
    print(priority.data_type, priority.kind, list(priority.datasets))

Rates

Added in SDK 0.32.0; preview. meridian.roles.data.rates is the shared rates and curve conversion: each publisher's client stays in its own plugin. See Rates and Record rates in a data plugin.

Name What
Feed(plugin, source_name, *, wants=None, day_zone="UTC") A data plugin's rates: subject(*identifiers, description="", instrument_type="") resolves a referential record once per process, reporting one not held as a miss once a day and, changed in SDK 0.32.3, asking it again the same day after 5, then 15, then every 60 minutes; rate(subject, key, kind, value, *, business_date, published_ns=0, convention=None, tenor=None, maturity_date="", preliminary=False, volume=None, transaction_count=None, percentiles=None, series="", raw_key="") builds one Rate, and, changed in SDK 0.32.3, refuses one with no convention, an index level taking the convention of the rate it compounds; curve(subject, key, kind, points, **given) one row per tenor published, none for one left empty; record(key, rows, want_id="") sends them 500 a batch, through wants where given
read_curve(plugin, subject, business_date, *, kinds=(), tenors=(), sources=None) A curve's points for a date, every page of list_rates, in tenor order, a point with an exact maturity after the tenors
tenor(label), tenor_label(tenor), tenor_days(tenor) A publisher's label (1 Mo, 13 WEEKS, 10 Yr, 3M, ON, TN, SN) as the interface's Tenor, refusing a fraction; its one label (3M); its length in days, to order a curve
percent(value), index_level(value, base="") A UnitValue in per cent, or an index's level with its base period
MONEY_MARKET, BOND_EQUIVALENT, DISCOUNT, CONTINUOUS The conventions the US and euro publishers state: actual/360 simple; semi-annual compounding; a bill's actual/360 discount; continuous on actual/365 fixed

In meridian.roles.data.testing: rates_producers(cases, *, source), the data suite's rates cases for a plugin built on rates.Feed; RATES_CASES, their names; and, added in SDK 0.32.1, RATES_NOT_PRESENTED, every case a plugin recording only rates never presents, each with why ("it records rates, never prices" among them). Changed in SDK 0.32.2: it no longer names records-a-datasets-liveness, which a plugin recording each poll as its dataset's liveness presents, and one recording none adds itself, with why. The testing Recorder answers RecordRates with its count, as it does prices, from SDK 0.32.2. PAIRS_NOT_PRESENTED and EVENT_CONTRACTS_NOT_PRESENTED do the same for a pairs venue and an event-contract venue, which publish no rates.

A reader reads rates with list_rates, by publication time with published_as_of_ns=, and hears them with on_rates_recorded.

Latency and liveness

Added in SDK 0.32.0; preview. In meridian.roles, the recorders the data and connectivity helpers and an ems use. See Data age and liveness.

Name What
Latency(plugin, *, ceilings=None) A plugin's recorders: rtt, order_ack and fill_delay per session, age(dataset, hop, value_ns) and aged(...) per hop, count, backlog, alive and clock_skew; one log-linear histogram per session and metric, every value recorded, never sampled. session_latency() answers each open session's SessionLatency, for a connection page's Sessions section; report() takes and resets the snapshots every 10 seconds, and run() does it in a loop
SessionLatency, Percentiles, FeedDelay, SessionCeilings, Histogram A session's latency: p50, p95 and p99 over the last minute, each dataset's feed delay, congestion over the last hour, the backlog, the clock skew, its last sign of life and the ceilings in force
Liveness() The latest liveness heard per dataset and venue: heard, given each on_liveness_recorded; listed(datasets) from a listing; at(dataset, venue_id="")
confirmed_ns(row, liveness=None), source_ns(row) A value's confirmed time, the later of its source time and its dataset's liveness after it; a row's source time
in_session(sessions, at_ns, *, extended=False) Whether a moment falls in a venue's trading sessions, a dated day's in place of its weekday's

Changed in SDK 0.33.0: Latency.report() sends the snapshots it takes as ReportLatency, every 10 seconds under run(), and core's latency monitor judges them (see Data age and liveness). In SDK 0.32 it sent nothing. Nothing in a plugin's code changes.

A data plugin's Wants takes latency= and records each row's age at the edge hop as it records it, and Wants.liveness(key, alive_at_ns=0, venue_id="") records a dataset's sign of life (record_liveness). A connectivity.Trader takes latency= and session=. A pairs venue's feed records its stream's heartbeats (alive(), alive_ns, liveness_ns), and, added in SDK 0.32.3, so does a listings feed (listings.Feed): heard notes the stream's last message and flush records its live dataset's liveness, with no venue, once a pass when a message was heard since the last. A data plugin's Connection draws Sessions only once a session is measured, from SDK 0.32.2, so a polled plugin's page has none.

The data suite

meridian.suites carries each role's conformance suite, as for custody: a plugin maps each case to its own recorded or synthetic exchange with its vendor, runs its own conversion against a Recorder (its typed operations, recorded rather than sent, answered as a sidecar answers them), and run compares what it sent with the case.

from meridian.suites import run

report = run("data", PRODUCERS, instance_id="my-prices-1")
assert report.passed, report.failures

A case naming a row the plugin hears, such as a want, gives it what recorder.answer(row, reply) returns, heard by the plugin's own class bound to the recorder:

async def a_want_recorded_against(recorder):
    recorder.answer("ObservationsWanted", lambda _: synthetic_want())
    await recorder.hear(recorder.bind(Lake(State())))

The cases are listed in Write a data plugin against its suite. A plugin holding data is verified for it only by passing every case it presents, which meridian plugin check --verified --run-tests holds it to. The reading roles, reporting, portfolio, compliance, signal and ems, have suites of their own, run the same way, as do connectivity, oms, confirmation, servicing and settlement; meridian plugin check --verified holds only the custody, data and connectivity suites, so a reading role's suite is run by the plugin's own tests alone.

A case not presented

A case asserting one value of a closed list (Case.closed_list) that the plugin's source never presents may be named in not_presented, with why. So may a case about one kind of data (Case.about, in the interface's words: closes, bars, trades, quotes, prices on a venue, a currency's rate against another currency, a crypto asset's price, a security's price) that the plugin declares it never publishes. Every other case is required, and a case not presented without a reason fails:

report = run("data", PRODUCERS, not_presented={
    "a-trade-recorded": "daily closes and bars only: this source states no trades",
})

So may a case about one field (Case.about_field, a dictionary entry such as meridian.v1.Trade.aggressor) that the plugin's source never states, though it publishes the kind: see A field your source never states.

Case.about names a kind of work as well as a kind of data, in any role's suite: every role's reads-a-calendar is about calendars, asks-for-a-settlement-date (every role's but connectivity's and operations') about settlement dates, connectivity's records-a-locate and resolves-a-venue-symbol-by-the-facts-date about locates, and its reads-whether-releases-wait-for-compliance and hears-an-accounts-attributes-change about compliance waits. A plugin whose work never uses one names the case in not_presented, with why, and is verified on the rest:

report = run("custody", PRODUCERS, not_presented={
    "reads-a-calendar": "this plugin reports what its custodian states; it reads no calendar",
})

connectivity's resolves-an-executions-venue and reports-a-venue-not-held carry Case.about_field, meridian.v1.ReportExecutionRequest.venue_id: a plugin whose venue never states a fill's place of execution names both. The runner refuses, as a failure, a name in not_presented that is neither a closed-list case nor one with an about or an about_field.

More cases carry about (added in SDK 0.27.0), each a thing the plugin's source never states or its work never does:

about Cases
plan fund codes custody's activity-plan-code-not-linked, activity-plan-code-linked-by-a-person, activity-re-resolved-when-a-plan-code-is-linked-later
sweep funds custody's cash-net-of-a-fund-counted-in-cash, fund-larger-than-cash
splits custody's activity-moves-no-cash
currencies not stated custody's currency-not-stated, for a custodian that always states the currency
charges without a fill connectivity's reports-a-charge
busts connectivity's reports-a-bust
venue-originated fills connectivity's reports-a-fill-on-no-placement, reports-a-later-fill-on-the-accepted-placement, re-reports-a-charge-on-the-accepted-placement
value-date rolls connectivity's reports-a-value-date-roll, for a venue that settles its trades
outcomes the data suite's records-an-outcome and records-a-partial-payout; hears-and-reads-an-outcome in reporting's, portfolio's, compliance's, signal's and operations' suites
disputed outcomes the data suite's restates-a-disputed-outcome, for a source that states no dispute
rates Added in SDK 0.32.0. The data suite's seven rates cases (a-rate-fixing, a-same-day-revision, a-curve-for-a-date, a-tenor-not-published, two-conventions-one-tenor, an-index-level, a-curve-point-from-another-source)
streams Added in SDK 0.32.0. The data suite's records-a-datasets-liveness, for a source that is polled and streams nothing
rfqs Added in SDK 0.32.0. Connectivity's records-a-dealers-quote-on-an-rfq-placement, for a venue that takes no RFQ
option chains Added in SDK 0.33.0. The data suite's records-a-chains-series, records-open-interest and a-chain-row-is-never-resolved
filings Added in SDK 0.33.0. The data suite's a-filing-recorded, a-fact-restated, a-fact-confirmed and resolves-a-filer
holdings Added in SDK 0.33.0. The data suite's a-complete-holdings-report, a-cusip-used-as-key and a-stake-crossed
corporate actions Added in SDK 0.33.0. The data suite's a-dividend-declared
ratings Added in SDK 0.33.0. The data suite's a-rating-action
quotes Added in SDK 0.33.0. The data suite's a-crossed-quote-is-recorded-invalid, a-duplicate-is-recorded-suspicious and rows-after-a-gap-are-suspicious, which expect quote rows
options Added in SDK 0.33.0. Custody's reports-an-assignment, connectivity's reports-an-expiry and servicing's proposes-an-exercise-by-exception
packages Added in SDK 0.33.0. Connectivity's reports-a-package-fill
derived series, scenarios, quality states Added in SDK 0.33.0. Signal's records-a-derived-series, saves-a-scenario-set and escalates-an-outlier

Changed in SDK 0.33.0: the data suite's reports-its-feed-delay, connectivity's reports-a-sessions-round-trip and the ems's reports-its-pre-trade-checks-data-age are no longer about: latency, and are required. The reading roles' v28 cases (a chain, open interest, a statistic heard, an account's lifecycle events, a derived series, a saved scenario set, declared dividends, a company's filings, reported facts, who holds a security and a rating history, each read or heard) are required. meridian.roles.data.testing.feed_delay maps the data suite's feed-delay case for a plugin built on the SDK's kinds. | trades | Changed in SDK 0.32.0: the data suite's queries-the-lake-within-bounds, which a data plugin recording no trades marks "it records no trades" | | closes | Changed in SDK 0.32.1: the data suite's an-exact-price and a-want-recorded-against, which a plugin recording only rates marks "it records rates, never prices" | | latency | Removed in SDK 0.33.0. In SDK 0.32 the data suite's reports-its-feed-delay, connectivity's reports-a-sessions-round-trip and the ems's reports-its-pre-trade-checks-data-age carried it, every plugin holding the role naming them not presented; they are required in full |

report = run("connectivity", PRODUCERS, not_presented={
    "reports-a-value-date-roll": "an equity broker: its trades settle, and nothing rolls",
    "reports-a-charge": "commission-free: the broker states no fee without a fill",
})

any_of. Added in SDK 0.27.0. An expectation may name its fields any_of, in place of fields (Expectation.any_of), where the interface answers a case in more than one way: a message on the row passes when it matches one of the ways in full. Connectivity's reports-a-fill-with-its-settlement-date asks the date read with a security's settlement inputs or a pair's spot inputs, from its trade date or the fill's moment; custody's currency-not-stated takes a currency derived by a named rule or supplied by a person (PROVENANCE_KIND_SUPPLIED); and the data suite's records-an-outcome an outcome at OUTCOME_STAGE_DETERMINED or OUTCOME_STAGE_FINAL. A plugin sends whichever its market needs, named in full:

async def fills_with_its_settlement_date(recorder):
    await recorder.compute_date(spot=SpotDateInputs(pair=[eur, usd], at_ns=fill_ns))
    ...

A restart's gap

A data plugin that records trades reads, on start, the last trade its own instance recorded of each subject it streams, list_trades(subjects=..., latest_recorded=True), and records what the venue's history states since. meridian.roles.data.backfill_restart(plugin, subjects, history) does it, in batches of 500, history answering the trades after a given one (the lake's row, its subject, dataset, venue and time in its meta), and answers how many it sent. A trade recorded again is answered unchanged by its row key. The data suite's a-restarts-gap-backfilled holds it.

A data plugin also reads list_datasets, answered only its own declared datasets with their licences, so its Datasets page says whether each is licensed (Served.licensed in meridian.roles.data).

Trades and quotes

preview. Still typed operations only: no WebSocket client, no reconnecting stream, no bar builder.

A data plugin records them from a dataset declaring data_types=["meridian.v1.Trade", "meridian.v1.Quote"] (or one of them) and modes=["stream"], with record_trades and record_quotes, 1 to 500 a call. Each is keyed by UTC instants and names no business date. A trade carries its TradeAttributes, converted at the edge from its source's condition codes, a code with no conversion kept in meta.unconverted; and its aggressor where its source says which side took liquidity (a source naming the resting order's side records the other). A withdrawn trade is the next version under its row key, cancelled=True. A quote's empty side is left unset. Keep a standing want of a streamed dataset current from the stream.

signal and ems read them, with list_trades (over a range within a day, or after_watermark=) and list_quotes (at the latest, per asset, or over a range), and hear them with on_trades_recorded and on_quotes_recorded, for the subjects their class's subjects() names; see Events. reporting reads neither. See Read trades and quotes in a signal or ems plugin.

Outcomes

Added in SDK 0.27.0; preview. A data plugin records an event contract's outcome from a dataset declaring data_types=["meridian.v1.Outcome"], with record_outcomes, 1 to 500 a call, one row key per contract, each change of stage the next version under it:

await role.record_outcomes(outcomes=[meridian.Outcome(
    meta=meridian.ObservationMeta(row_key=f"{ticker}:outcome", subjects=[subject], ...),
    determination="pays_part", stage="final",
    settlement_value=meridian.Money(Decimal("0.4500"), "USD"),
    settled_at_ns=settled,
    determination_as_reported=edge.as_reported("kalshi:result", "scalar"),
)])

reporting, portfolio, compliance, signal and operations read them with list_outcomes by subject, the version in force or as_of_ns= a recorded time, or after_watermark= the last seen, and hear every version with on_outcomes_recorded, for the subjects their class's subjects() names, caught up from the watermark after a loss as trades are. The data suite's records-an-outcome, restates-a-disputed-outcome and records-a-partial-payout hold it.

A daily value on a closed day. Added in SDK 0.27.0. A price or bar read back carries ObservationMeta.closed_day, a DateCheck, where the lake found it dated on a closed day of its dataset's calendar, and a listed dataset its closed_day_count and outside_coverage_count. The lake sets them; a data plugin sends neither.

Money and dates

A Money names its cash instrument: Money(Decimal("12.50"), "USD") keeps its shape, and core resolves the code; a token is named by instrument_id alone. Every field the dictionary types date takes a datetime.date or its ISO text, and refuses text that is no date and a datetime. See Money and instruments and Dates and time.

The order path

An order reaches a venue through the book. Each role does its part through typed operations, one per row it may send, and hears the book's records as they change (see Events); the book holds every order, placement, execution and charge, and alone decides what an order's state allows. See The order lifecycle. Every operation here is preview.

# portfolio, or the oms: propose. A unit quantity or a cash quantity, never both.
await role.propose_order(
    account_id="ACC-1", instrument_id=aapl, side="buy", quantity=Decimal("10"),
    order_type="limit", limit_price=Money(Decimal("227.50"), "USD"),
    time_in_force="day", idempotency_key="propose:ACC-1:pm-ticket-4471",
    acting_for=request.caller.header,
)

# the oms: release it to be worked.
await role.release_order(order_id=order_id, idempotency_key=f"release:{order_id}",
                           acting_for=request.caller.header)

# the ems: claim it, and place at a connectivity plugin linked to it, with the
# venue account code its map holds for the account.
await role.set_working_condition(order_id=order_id, condition="claimed", set=True,
                                   acting_for=request.caller.header)
await role.create_placement(
    order_id=order_id, connectivity_instance_id="venue-1", venue_account_code="X12345",
    quantity=Decimal("10"), idempotency_key=f"place:{order_id}:1",
    acting_for=request.caller.header,
)

# a connectivity plugin: say what the venue said of the ems's placement, each
# with its raw record.
await role.report_placement(
    placement_id=placement_id, event="acknowledged", venue_order_id="V-881",
    raw_record=role.plugin.raw_record(key),
)
  • Who calls what. compliance checks and decides (record_check, decide_approval); an ems works a strategy in house as child orders of a released order (propose_order(link=OrderLink(kind="split", parent_order_id=...))); the oms records a telephoned or outside order for a person (record_order, origin="telephoned"); a connectivity plugin reports fills, busts and charges on the ems's placements (report_execution, report_charge), records a locate (record_locate), and publishes its venue's catalogue (report_venue_catalogue), which an oms and an ems read (read_venue_catalogue). An ems's admin page maps an account to a venue account code with map_venue_account, acting for the admin viewing it.
  • Orders are portfolio's and the oms's, placements the ems's, fills the connectivity plugin's. A connectivity plugin records no order and creates no placement: its record_order or create_placement is NotGranted, refused by its sidecar. It reports a fill the venue originated on no placement, which the ems answers: see Venue-originated fills.
  • Every command but a fact report acts for someone: a person, through acting_for=request.caller.header, or the role's service account. A fact a connectivity plugin reports names nobody. record_order is a command: the oms's, for a person or its service account.
  • A placement reaches only the connectivity plugin it names. An ems page offers the instances in request.caller.connectivity_instance_ids; the sidecar checks the placement against them whatever the page draws, and the book refuses and records one at any other (REFUSAL_REASON_CONNECTIVITY_NOT_ALLOWED).
  • A placement carries its own change requests. When the ems or the oms asks to cancel or replace a placement (request_placement_change), the placement its connectivity plugin hears holds each request in change_requests, with its PlacementChange and its outcome. Tell a cancel from a replace by the change's kind, and read a replace's new quantity, limit and time in force from it, never from a condition's reason (see A placement's change requests).
  • A connectivity plugin states an execution's settlement date: the venue's, or one asked of core with compute_date, carried with its basis (computed_settlement_date, settlement_date_basis). A fill with neither is refused, naming settlement_date.
  • The trading instructions reach every subscriber whatever its accounts. Read those in force on start with list_trading_instructions and hear each change with on_trading_instruction_changed; honour a pause and a stop as your plugin's role requires (see Trading instructions).
  • Refusals. One by the order's state, a claim or a link is a CommandRefused with its code: REFUSAL_REASON_ORDER_STATE, _CLAIMED, _CHECK_BLOCKS, _APPROVAL_REQUIRED, _ALLOCATION_UNBALANCED, _IDENTIFIER_IN_USE, _CONNECTIVITY_NOT_ALLOWED, _CONNECTIVITY_LINKED, _QUANTITY_EXCEEDS_ORDER, _EXECUTION_STATE and _EXECUTIONS_DIFFER (the last three added in SDK 0.27.0). A command naming an order or a placement by its ID whose account is outside the plugin's write scope or the person's write access is NotGranted, with no code. An order stating both or neither of quantity and cash_quantity is CallFailed, kind "invalid", naming both in .fields.
  • The suites. connectivity and oms each have a suite, and portfolio's, ems's, compliance's, operations' and reporting's hold the order path's cases. Every connectivity case is required but seven: records-a-locate and resolves-a-venue-symbol-by-the-facts-date (about locates; the second resolves the symbol on a prime's answer to a locate), reads-whether-releases-wait-for-compliance and hears-an-accounts-attributes-change (compliance waits) and reads-a-calendar (calendars), which a plugin whose work never uses them names in not_presented, with why; and resolves-an-executions-venue and reports-a-venue-not-held (about_field), which one whose venue never states a fill's place of execution names. Six more may be (added in SDK 0.27.0): reports-a-bust (busts), reports-a-charge (charges without a fill), the three venue-originated fill cases and reports-a-value-date-roll; and the ems suite holds the accept's and the DK's four cases, required. See A case not presented.

The transition table. The order's states, conditions and transitions are one table, which the SDK carries as published, meridian/transitions.json:

from importlib.resources import files
import json

table = json.loads(files("meridian").joinpath("transitions.json").read_text())
  • An FX order names its pair's quote currency (added in SDK 0.27.0), propose_order(quote_currency=usd), the second currency as the pair is quoted, by its cash instrument's ID; a quote currency naming the order's own instrument, or a limit or stop priced in another currency, is CallFailed, kind "invalid", naming the field. A fee's kind may be financing or commission (added in SDK 0.27.0).

Venue-originated fills

Added in SDK 0.27.0. A connectivity plugin reports a fill the venue originated on no placement with report_execution, naming its instrument_id, side, venue_order_id and, where sent, venue_client_order_id; the book holds it unmatched (Execution.state, EXECUTION_STATE_UNMATCHED). The ems lists them, list_orders(execution_state="unmatched"), and answers each, for a person or as its service account:

from meridian.plugin.v1 import operations_pb2 as ops

await role.accept_execution(execution_ids=[e1, e2], order_id=order_id,
                              note="Placed in the broker's app.", acting_for=request.caller.header)
await role.accept_execution(execution_ids=[e3], new_order=ops.NewOrderFromExecutions(sleeve="core"),
                              note="The broker's liquidation.", acting_for=request.caller.header)
await role.dont_know_execution(execution_ids=[e4], reason="no_matching_order",
                                 acting_for=request.caller.header)

accept_execution takes one of order_id and new_order (the oneof matched_order; both is refused naming it), answers the executions, the order, the placement created at filled and the journal reference; dont_know_execution takes a DkReason and text, required with other, and answers the executions. The connectivity plugin hears each answer (on_execution_recorded, the executions it reported) and tells its venue as its catalogue says (VenueCatalogue(execution_acknowledgement="bn" | "dont_know_trade" | "none")). See Answer a venue-originated fill and The order lifecycle.

A value-date roll

Added in SDK 0.27.0. A connectivity plugin whose venue rolls spot FX positions reports each roll, the new value date asked of core for the roll's moment, after the financing charges it made:

answer = await role.compute_date(spot=meridian.SpotDateInputs(pair=[eur, usd], at_ns=rolled_ns))
rule = answer.basis.rule
basis = meridian.DateBasis(
    rule=meridian.DateRuleRef(kind="spot", subject=rule.subject, version=rule.version),
    calendars=list(answer.basis.calendars),
)
await role.report_value_date_roll(
    venue_account_code="X12345", pair=[eur, usd],
    from_value_date="2026-10-14", to_value_date=answer.date, value_date_basis=basis,
    rolled_at_ns=rolled_ns, charge_ids=[financing_charge_id],
    raw_record=role.plugin.raw_record(key), idempotency_key=f"roll:X12345:{rolled_ns}",
)

Each financing charge is reported first with report_charge, its fee's kind financing. A fill booked after its trade was rolled gets a catch-up roll from the value date it was recorded with. See A venue's value-date roll.

A placement's change requests

A connectivity plugin hearing its placement reads each cancel or replace asked of it from the placement itself, each tracked to its outcome (pending, applied or rejected); the order's own change requests stay on the order:

from meridian.plugin.v1 import operations_pb2 as ops

async def on_placement(heard: meridian.Heard[ops.PlacementUpdatedEvent]) -> None:
    placement = heard.message.placement
    for request in placement.change_requests:
        if request.outcome != ops.CHANGE_REQUEST_OUTCOME_PENDING:
            continue
        change = request.placement_change
        if change.kind == ops.PLACEMENT_CHANGE_KIND_CANCEL:
            ...  # cancel placement.venue_order_id at the venue
        elif change.kind == ops.PLACEMENT_CHANGE_KIND_REPLACE:
            limit = meridian.as_money(change.limit_price)  # and the rest

Algorithms

A version declares the algorithms its autonomous work may name, each identifier stable across versions and reinstalls, with a description a person reads on the plugin's page (1 to 1,024 characters), and the work names the one it runs; the book records it on each entry's service account actor, where a reporting plugin reads which algorithm decided or executed:

from meridian import AlgorithmDeclaration
from meridian.declaration import Declaration

DECLARATION = Declaration(algorithms=[
    AlgorithmDeclaration("twap-v1", "Time-weighted slices over the order's window."),
])

async with meridian.runs_as("ems", algorithm_id="twap-v1") as act:
    await act("work_order", {"order_id": order_id}, reason="TWAP slice 3 of 12.")

Each identifier is declared once, with no spaces around it. One the version does not declare is refused by runs_as before anything is sent, as the sidecar refuses it (CallFailed, kind "invalid", naming algorithm_id). An empty algorithm_id names none; a person's act names none. The declaration's algorithms are uploaded with it and shown on the plugin's Summary.

Settlement calendars

A plugin never works out a settlement or value date itself: it reads a calendar's days (read_calendar) or asks core for the date (compute_date), answered with the rule and calendar versions it was worked out on, the same answer for every plugin. Any role calls both, whatever its accounts. The SDK carries the two operations and no calendar library. See Settlement calendars.

from meridian.plugin.v1 import operations_pb2 as ops

answer = await role.compute_date(
    settlement=meridian.SettlementDateInputs(instrument_id=aapl, trade_date="2026-10-09")
)
if answer.outcome == ops.DATE_OUTCOME_COMPUTED:
    settles = answer.date                            # "2026-10-13": Columbus Day skipped
    basis = answer.basis                             # the rule and each calendar, with versions

await role.compute_date(spot=meridian.SpotDateInputs(pair=[eur, usd], trade_date="2027-03-24"))
await role.compute_date(
    tenor=meridian.TenorDateInputs(pair=[usd, jpy], tenor=meridian.Tenor(count=1, unit="month"),
                                   trade_date=date(2026, 10, 30))
)
days = await role.read_calendar(calendar_id=cal, from_date="2026-10-01", to_date="2026-10-31")

compute_date takes exactly one question -- settlement, spot, tenor, roll (a date rolled by a convention on a set of calendars) or business_day (whether a date is a business day on a set of calendars) -- and as_of_ns, a recorded time, for the versions in force then; 0 for the latest. Its answer's outcome is one of:

outcome Means
DATE_OUTCOME_COMPUTED date, with its basis
DATE_OUTCOME_CALENDAR_NOT_HELD A calendar is not held yet: core asks the platform for it; ask again later
DATE_OUTCOME_OUTSIDE_COVERAGE A day the question needs is outside a calendar's coverage; nothing is computed
DATE_OUTCOME_RULE_NOT_HELD No rule is held for the subject yet, asked of the platform as a calendar is

A malformed question is refused, CallFailed of kind "invalid", naming the field. read_calendar answers a calendar's weekends, listed days (each closed weekday or weekend business day, with its name and source), coverage, sources and version over the range, as of a recorded time or its latest version; one not held is answered not found and asked of the platform.

A value a plugin records from the date read carries the provenance computed, with the date's basis (Provenance.date_basis), since a date core worked out is not the plugin's own rule. Every role's suite holds reads-a-calendar, about calendars, and every role's but connectivity's and operations' asks-for-a-settlement-date, about settlement dates: a plugin whose work never uses them names them in not_presented, with why (see A case not presented). connectivity's reports-a-fill-with-its-settlement-date and operations' re-checks-a-pending-value-date ask compute_date too, and carry no about: they stay required. The first passes on whichever inputs the fill's market needs, a security's settlement inputs or a pair's spot inputs from its trade date or the fill's moment (any_of).

A pair named by a deployment's provisional LCL- records finds no platform spot rule: name a pair by the platform's INS- IDs of its currencies' cash instruments. A spot-date rule's counting mode is the platform's, applied in core; a plugin asks the same way (see Settlement calendars).

Types

All the dataclasses are frozen.

Identity

Field Type Default Meaning
instance_id str The instance's name.
roles tuple[str, ...] () The roles the instance was launched with. They decide its topic access. None is a plugin admitted with no topics.
deployment_id str "" The deployment it runs in.

Grants

Field Type Default Meaning
publish tuple[str, ...] () Topic patterns the plugin may publish to.
subscribe tuple[str, ...] () Topic patterns it may subscribe to.

Interface

The pages the plugin serves to people, on loopback. Only the plugin's sidecar reaches them, forwarding requests the dashboard vouched for.

Field Type Default Meaning
port int The loopback port the pages listen on.
title str The plugin's title.
pages Pages or sequence of Page () The plugin's pages, one list in the order shown, each with the levels it serves. A Pages registry declares each where its view is and refuses a session at a level it does not serve. Keyword only.
admin_pages sequence of Page () Retired: declare pages at admin in pages. meridian plugin migrate rewrites it into pages.

The dashboard shows a plugin's pages in its area, under the home's button for each level a page serves: Manage for admin, Open for write, View for read. A plugin that declares no page at write or read has one there, its /. See Manage, Open and View.

interface = meridian.Interface(port=8000, title="Statements", pages=pages)

Page

One of the plugin's pages, at a path on its own host, and the levels it serves. Pages makes one for each @pages.page; build one yourself only for a page served some other way.

Member Type Meaning
path str The path, beginning with /. Anything else raises ValueError at connect.
title str The tab's title.
levels sequence of str or AccessLevel The levels it serves: one or several of "admin", "write" and "read", an AccessLevel, or its name ("ACCESS_LEVEL_ADMIN"). A string alone is one level. Anything else raises ValueError, and a page with none raises it at connect.
roles sequence of str The roles it serves, from those the plugin was launched with. Default (): a plugin holding one role, or none, names none. See Roles.
serves(caller: Caller) -> bool method Whether the caller's session is at one of its levels; for a page naming roles whether the caller's level on one of them is. A session at no level is served nothing.
statements = meridian.Page("/statements", "Statements", levels=["write", "read"])
if not statements.serves(caller):
    ...  # answer 403

One path may serve several levels, and adapts by caller.level: the dashboard frames the same path under Open and under View.

AccessLevel

The generated protobuf enum meridian.v1.sidecar_pb2.AccessLevel: a person's level on a plugin, the same three for every plugin.

Value Number Button Spelling
ACCESS_LEVEL_UNSPECIFIED 0 A session at no level, which holds nothing.
ACCESS_LEVEL_READ 1 View "read"
ACCESS_LEVEL_WRITE 2 Open "write"
ACCESS_LEVEL_ADMIN 3 Manage "admin"

Setting

One setting the plugin needs, declared at connect.

Field Type Default Meaning
name str The setting's name.
kind type str str, int or bool, or list for a table. Anything else raises TypeError at connect.
required bool False Whether the plugin needs a value to be healthy.
secret bool False A secret is set through the dashboard and never read back, displayed, logged, reported or bundled. The plugin receives it in Settings and nowhere else.
description str "" What the setting is, sent with its declaration.
label str "" The field's name on the dashboard's form; for a table, the title of its tab.
default str, int, bool or None None Shown greyed in the empty field, and held in Settings.values while the setting is unset. Of the setting's kind, one of its choices if it has any, and never on a secret.
unit str "" Shown beside a number.
choices tuple[Choice, ...] () Makes the setting a choice, one of these, shown as radio buttons. Its kind must be str.
applies_when AppliesWhen or None None The setting applies only while another holds one of some values.
developer bool False Shown only on a development deployment.
columns tuple[Column, ...] () A table's columns, in the order its tab shows them. Only with kind=list, which needs at least one.
most_rows int 0 The most rows a table holds, at most 500; 0 is 500.
roles sequence of str () The roles it serves, from those the plugin was launched with: it is shown to an admin of any of them and set only by one holding admin on every one. Not who may read the value: the plugin reads every setting it declared. None on a plugin holding one role or none. See Roles.
per_person bool False Each person's own, saved for them and read from their requests, never on the Settings form and never in Settings.values. Never secret, never required. See A person's own settings.
DECLARATION = meridian.Declaration(
    settings=[
        meridian.Setting("api_token", secret=True, required=True, description="The rail's API token"),
        meridian.Setting("poll_seconds", kind=int, description="How often to poll"),
    ],
    reads_external_accounts=True,
)
plugin = await meridian.connect(declaration=DECLARATION, roles=[Custody()])  # its custody class

A table is rows of typed columns an admin enters in the dashboard on a tab of its own, after Settings and titled with its label: an entry grid checked cell by cell, saved through the same checks as the form. It has no default and is never secret:

PLAN_CODES = meridian.Setting(
    "plan_code_links",
    list,
    label="Plan-code links",
    columns=(
        meridian.Column("account", "external_account", label="Account", required=True),
        meridian.Column("code", label="Plan code", required=True),
        meridian.Column("instrument", "instrument", label="Instrument", required=True),
    ),
    most_rows=200,
)

Column

One column of a table setting.

Field Type Default Meaning
name str The key each row's cell is held under. Never changed_by or changed_at, which the deployment stamps on each row.
kind str "text" How the form takes a cell and the deployment checks it: "text", "integer", "decimal", "date", "choice", "external_account" or "instrument".
label str "" The column's heading on the table's tab.
required bool False Every row must fill it; a row with it empty is refused, naming the cell.
description str "" Words shown beside its heading.
choices tuple[Choice, ...] () A choice column's options, which it needs; a cell holds an option's value.

Every cell arrives as text. What each kind accepts:

Kind A cell is
"text" One line of plain text, at most 500 characters: no newline, tab, control character, or character that hides or reorders text (bidirectional, zero-width, tag or private-use).
"integer" A whole number, such as "42".
"decimal" An exact decimal, at most 18 places, such as "12.5". Read it with decimal.Decimal, never float.
"date" A date, "YYYY-MM-DD".
"choice" One of the column's choices, by its value.
"external_account" The identifier of an external account this plugin reported.
"instrument" A deployment instrument record's ID, picked by search and checked to exist, never a symbol.

See Set a plugin's settings for what a table's tab checks, and the data dictionary's SettingColumn.

Choice

One option of a setting that is a choice.

Field Type Default Meaning
value str The value the plugin receives.
label str "" What the form shows for it.
description str "" What it means: a line of the setting's hint on the form.

AppliesWhen

Field Type Meaning
setting str Another setting, declared before this one.
one_of tuple[str, ...] The values of it for which this setting applies. Only then does the form show it, and ask for it when it is required.
plugin = await meridian.connect(
    roles=[Lake()],  # its data class
    settings=[
        meridian.Setting(
            "environment",
            label="Environment",
            default="sandbox",
            choices=(meridian.Choice("sandbox", "Sandbox"), meridian.Choice("live", "Live")),
        ),
        meridian.Setting(
            "live_account", label="Live account", required=True,
            applies_when=meridian.AppliesWhen("environment", ("live",)),
        ),
    ],
)

Settings

Field Type Meaning
values dict of str to str, int, bool or list Each declared setting the deployment holds a value for, typed by its declaration, and the default of each that has one and no value. A table's is a list of rows, each a dict of text by column name with changed_by and changed_at.
missing_required tuple[str, ...] Required settings with no value yet, a refused one among them.
refused tuple[RefusedSetting, ...] The saved values this version cannot read, each delivered unset. Added in SDK 0.31.1.

A saved value this version cannot read is never raised and never mapped to another value. A value no longer among the setting's choices, as one a plugin's new release renamed, or one not of its kind, is delivered unset: a required setting holds nothing, not even its default, and is in missing_required, so the plugin's own not-ready path names it; an optional one holds its default. Each is in refused, a meridian.RefusedSetting: its name, its label, the value as saved (empty for a secret) and its reason, NOT_A_CHOICE or NOT_ITS_KIND. Its message is what a page shows: "Environment was saved as 'practice', which this version no longer offers; save it again". A typed settings class says the same in .refused, and a person's own values refused are in request.own_settings_refused. An environment so refused is empty: not live, so the plugin still reaches the demo host, and not demo, so the agents' guard counts it live until a person saves it again.

AccountScope

Field Type Default Meaning
read frozenset[str] empty Every account anybody may read through this plugin, and every account one of its external accounts is linked to.
write frozenset[str] empty Every open account anybody may write through this plugin, and every open account one of its external accounts is linked to.
links tuple[LinkedExternalAccount, ...] () This plugin's links, and nobody else's. An external account the plugin reported that none of them names is unlinked.
Method Returns Meaning
link_of(external_account_id: str) LinkedExternalAccount or None The link naming this external account, or None while it is unlinked.

A link puts its account in both read and write while it stands. A closed account stays in read alone, and its link is still listed.

LinkedExternalAccount

One of the plugin's external accounts, linked to one of the deployment's accounts by an admin of the plugin.

Field Type Default Meaning
external_account_id str The external account, as the plugin reported it with report_external_accounts.
account_id str The deployment's account it is linked to.
account_name str "" That account's name as the deployment holds it now, so a page can say which account a link points at without acting for anybody. A rename arrives as a new delivery.

Caller

Who a request for the plugin's page came from, as the dashboard vouched and the sidecar checked before forwarding it. It is read, not checked, by the SDK: only the sidecar can reach the page, and the sidecar removes every other claim the request arrived with.

A person opens a plugin at one level they hold, by a button on the dashboard's home: Manage at admin, Open at write, View at read. The session carries only that level, and the accounts are cut to it: under Open, read and write; under View, read alone; under Manage, neither, since admin configures a plugin and sees no account's data.

A person holds a level on each role of a plugin, and the session carries one entry for each role they hold something on within its button: under Open a role held at write is at write and one held at read at read, under View each at read, under Manage each role they administer at admin, with no account. roles, level_for, read_for and write_for read it. level, read and write stay the session's button and the union over its roles.

Member Type Meaning
subject str The person, as the deployment's directory names them. Deployment-local, never an address. A service account's SA- ID where subject_kind says so.
display_name str Their name, for display.
level int, an AccessLevel The level the session was opened at. ACCESS_LEVEL_UNSPECIFIED is a session that holds nothing.
admin bool property Whether the session was opened by Manage: level is ACCESS_LEVEL_ADMIN.
read frozenset[str] The accounts this plugin may show them in this session. Empty under Manage.
write frozenset[str] The accounts this plugin may act on for them in this session. Every one is also in read. Empty under View and Manage.
header str The Meridian-Caller header as received. Pass it as acting_for on a typed operation to send it for this person.
deployment_admin bool Whether the person is a deployment admin. It opens no page and reaches no account. It says only that, linking an external account under Manage, they may name a new account rather than an existing one.
delegation_id str The delegation the person acted through, when they came through a client, such as the CLI, their own agent or an MCP client, rather than a browser. Empty for a browser.
client_name str That client's registered name. Empty for a browser.
through_a_client bool property Whether the person came through a client on a delegation: delegation_id is not empty.
roles Mapping[str, int] Each role's level within the session's button, an AccessLevel by role. Empty where the claims carry no per-role entry.
level_for(role: str) -> int method Their level on role within the session's button; ACCESS_LEVEL_UNSPECIFIED for a role they hold nothing on here. Claims carrying no per-role entry read every role at level.
read_for(role: str) -> frozenset[str] method The accounts the plugin may show them for role: within read, empty under Manage and for a role they hold nothing on. Claims carrying no per-role entry read every role as read.
write_for(role: str) -> frozenset[str] method The accounts the plugin may act on for them in role, those the sidecar admits a command of that role for: within read_for(role), empty under View and Manage and for a role held at read. Claims carrying no per-role entry read every role as write.
Caller.from_header(header: str) -> Caller classmethod Decode a Meridian-Caller header: base64url, unpadded.
may_read(account_id: str) -> bool method Whether they may read the account through this plugin in this session: account_id in read.
may_write(account_id: str) -> bool method Whether they may write the account through this plugin in this session: account_id in write.
prepared_call_id str The prepared change this request was opened from or makes, PC-…; empty for none. Read it with await request.prepared().
own_settings Mapping[str, str] The person's own values of settings declared per_person, as the dashboard keeps them, under Open and View; empty under Manage. request.own_settings reads them typed.
second_pair_of_eyes frozenset[str] The plugin's acts whose second pair of eyes is on for this person now, by tool name: hide Save for them and offer Prepare. For showing only: the dashboard refuses a direct make whatever the page draws.
subject_kind int, a SubjectKind Whom the claims vouch for: SUBJECT_KIND_UNSPECIFIED, a person; SUBJECT_KIND_SERVICE_ACCOUNT, a service account, set by the dashboard only for an act's route reached for a call it admitted for the role's service account (see A service account runs the plugin's autonomous work).
owner_subject str A service account's owner when the call was admitted, the person answerable for it; empty for a person.
algorithm_id str The algorithm a service account's call named, one of the version's declared algorithms; empty for a person and where the call named none.
connectivity_instance_ids frozenset[str] For an ems, the connectivity plugin instances this user may route through for it, worked out by the dashboard from the links the ems's admin made: for showing which may be chosen. It names the instances, never the person's user groups; the sidecar checks a placement against it whatever the page draws. Empty for every other plugin.

The levels are the same for every plugin, granted in the deployment's access groups. A plugin names no parts of itself, so there is nothing finer to ask than the person's level on each of its roles, which are the deployment's, not the plugin's. The sidecar checks every command sent for a person again, whatever the plugin believes: it admits one only in a session at write, and only by their write on a role whose grants include the command, the account among that role's write accounts, refusing otherwise and naming the role. See Access.

The per-role accounts reach the plugin as positions in the claims' read accounts, which Caller resolves; a position past them is a header that does not read. Claims carrying no per-role entry, from a plugin holding no role, read every role as the session's level and accounts.

A person who came through a client on a delegation stays the actor: delegation_id and client_name say only through what, for the plugin to show and record as it chooses. The sidecar stamps the delegation beside the person on every command sent for them. See The delegation.

deployment_admin opens no page

A deployment admin holds on a plugin what their grants give, and a plugin's admin need not be one. Serve a page at admin by the session's level: declare it with levels=["admin"], or ask caller.admin.

Caller.access and TagAccess are gone

Access is not given tag by tag: Caller.access raises an AttributeError, and from meridian import TagAccess an ImportError, each saying to read Caller.read and Caller.write, or ask may_read and may_write.

Identifier

The generated protobuf message meridian.plugin.v1.operations_pb2.Identifier: one typed identifier for an instrument or a venue.

Field Type Meaning
scheme str A global scheme ("figi", "isin", "cusip", "sedol"; a currency's "iso4217", a token's "caip19"; a venue's MIC, "iso10383") or a source-scoped one ("symbol").
value str The identifier.
source str The namespace a source-scoped identifier belongs to, such as "snaptrade". Empty for a global scheme.
valid_from_ns, valid_until_ns int Its window on the record, as the instrument store answers it; valid_until_ns 0 while it is on the record. Filled by the store, never by a plugin: leave both 0 in what you send.
isin = meridian.Identifier(scheme="isin", value="US0378331005")
symbol = meridian.Identifier(scheme="symbol", value="AAPL", source="snaptrade")
mic = meridian.Identifier(scheme="iso10383", value="XNYS")              # a venue, for resolve_venue
letter = meridian.Identifier(scheme="symbol", value="V", source="alpaca")  # a vendor's code for one

MissReason

The generated protobuf enum meridian.plugin.v1.operations_pb2.MissReason: why a resolution did not produce exactly one instrument.

Value Number Meaning
MISS_REASON_UNSPECIFIED 0 Not set.
MISS_REASON_NOT_FOUND 1 No instrument matched.
MISS_REASON_AMBIGUOUS 2 More than one matched. It is reported as a miss rather than resolved by picking one.

Pages

A plugin's pages are declared and enforced in one place: each page is a view function and a template, declared where the view is with the levels it serves. meridian.Pages sends the list at registration and answers 403 to a session at any other level before the view runs, so the dashboard's tab row and the plugin agree by the same declaration.

from pathlib import Path

import meridian

pages = meridian.Pages("Statements", templates=Path(__file__).parent / "templates")


@pages.page("/connections", "Connections", levels="admin")
async def connections(request: meridian.Request) -> str:
    return pages.render("connections.html", linked=...)  # no account's data


@pages.page("/", "Statements", levels=["write", "read"])
async def statements(request: meridian.Request) -> str:
    rows = [...]  # cut to request.caller.read
    return pages.render("statements.html", rows=rows)


@pages.route("/sync", levels="write", methods=["POST"])  # an endpoint, not a tab
async def sync(request: meridian.Request) -> str: ...


DECLARATION = meridian.Declaration(
    interface=meridian.Interface(port=8000, title="Statements", pages=pages),
)

meridian.run(declaration=DECLARATION, roles=[Custody()], pages=pages, port=8000)

Pages

class Pages(title: str = "", *, templates: str | os.PathLike | None = None, kit: str = KIT, max_body: int = MAX_BODY)
Parameter Meaning
title The heading the base template draws, and the end of each page's <title>.
templates The directory render reads templates from. A directory that does not exist raises FileNotFoundError.
kit The kit version the base template links, at /.meridian/ui/<kit>/: pages.KIT, 0.16.0 in SDK 0.36.0, whose <om-live cells> draws live values. The dashboard answers any 0.x with the newest 0.x it carries.
max_body The largest request body, in bytes, the pages take: meridian.pages.MAX_BODY, 1 MiB, unless said. A larger one is answered 413 before any view runs, so a view need not measure what it is sent. A negative number, or anything but an int, raises ValueError.
Member Meaning
page(path, title, *, levels, roles=(), methods=("GET",)) Decorator. A tab, shown under the home's button for each of levels, in the order declared, and served only in a session at one of them; naming roles in a session where the person's level on one of them is.
route(path, *, levels, roles=(), methods=("GET",)) Decorator. An endpoint that is not a tab, such as a form's action or a page's data, served only in a session at one of levels; naming roles, as a page's. Its tool serves the same roles.
tool(*, replaces, ..., roles=None) Decorator. A tool replacing the one derived from the route at replaces; it serves the route's roles, or those roles= names. See Offer your plugin's pages to agents.
render(template, /, **context) -> str template, from templates, rendered with context. Called from a view, while it serves a request; anywhere else raises RuntimeError.
csrf_token(caller) -> str The token a request from this person, in a session at this level, carries back when it changes something.
declared The tabs, in the order declared: what registration sends.
dispatch(request) -> Response The view at the request's path and method: 404 for no such path, 405 for a method the path does not take, 403 for a level it does not serve, or for a session holding none of its levels on any of its roles. A HEAD runs the GET view, where no view is declared for HEAD, and answers its headers with no body.
app(plugin=None) The pages as an ASGI application, with the caller read as CallerMiddleware reads it. A body over max_body is answered 413 without being read; a view that raises is logged and answered 500.
serve(plugin, port, *, loop=None, max_body=None) Runs app(plugin) on 127.0.0.1:<port> with the standard library's threaded server, in a thread of its own; each view runs on loop, the running one by default, where the plugin's operations are. A request whose Content-Length is over max_body, the pages' own unless said, is answered 413 without its body being read. Returns the server, whose shutdown() stops it. A view taking more than 60 seconds is answered 500.

levels takes the same values as Page. A path or a route with no level raises ValueError when it is declared, and so does the same path and method declared twice. A view is sync or async, takes a Request, and answers a str, the page's HTML, or a Response.

HEAD is answered for every path that takes GET: the GET view runs, with request.method "HEAD", and its answer's headers are sent, with the length of the body, and no body. A path that takes GET lists HEAD in a 405's Allow too.

Request field Meaning
method, path As asked.
caller The Caller, with the session's level.
plugin The connection the pages serve: what is the whole plugin's. A view acts through the role object the plugin gives it, a closure or the state it shares, with acting_for=request.caller.header to act for the person.
query, form The query string's and a urlencoded form's fields, the last of each name.
headers By lower-case name.
body The body as sent.
csrf_token The token a request that changes something carries back, set before the view runs.
Response field Default Meaning
body "" str or bytes. text reads it as text.
status 200
content_type "text/html; charset=utf-8"
headers () More headers, as (name, value) pairs.

Roles

A person's level is granted on each role of a plugin, so a plugin holding several names the roles each page, route, tool and setting serves with roles=, from those it was launched with. A plugin holding one role, or none, names none, and nothing changes: the sidecar serves its declarations that role.

@pages.page("/statements", "Statements", roles=["custody"], levels=["write", "read"])
async def statements(request: meridian.Request) -> str:
    caller = request.caller
    rows = [...]  # cut to caller.read_for("custody")
    return pages.render("statements.html", rows=rows,
                        may_record=bool(caller.write_for("custody")))


@pages.page("/blotter", "Blotter", roles=["custody", "operations"], levels=["write", "read"])
async def blotter(request: meridian.Request) -> str: ...  # each role's rows by read_for(role)


settings = [meridian.Setting("api_key", secret=True, roles=["custody", "operations"])]
  • A page or route is served, and a page's tab shown, when the person's level on one of its roles within the session's button is one of its levels. Otherwise Pages answers 403 before the view, naming the roles and what the session holds on each, such as /statements is for custody under Open and View; under Open this session holds nothing on custody.
  • A tool derived from a route takes its route's roles; @pages.tool(..., roles=[...]) names others. It is listed at the deployment's /mcp to a person holding one of its levels on one of its roles.
  • A setting is shown to an admin of any of its roles and set only by one holding admin on every one.
  • On a plugin holding several roles, the sidecar refuses the registration of a page or setting naming no role, or a role the plugin was not launched with, naming it; a tool so declared is refused by name, without refusing the plugin. meridian plugin check says where first (roles-declared).
  • What is shown, never what is admitted. A declaration's roles decide what a person is shown. The sidecar decides every command sent for them, by their write on the role whose grants include it.

A page serving several roles adapts per role with caller.level_for(role), read_for(role) and write_for(role): offer a role's actions only where write_for(role) holds accounts.

Templates

render renders a Jinja2 template, autoescaped, so every value is escaped unless it is meridian.pages.Markup, text that is HTML already. Beside what the view passes, the context always holds:

Name What it is
caller The Caller asking.
level The session's level, as "admin", "write" or "read". A template adapts by it: {% if level == "write" %}.
csrf_input The hidden field carrying the CSRF token, for inside a form.
csrf_token The token itself, for a script's X-CSRF-Token header.

A page's template extends the kit's base template, meridian/base.html, which Pages carries. It links the kit's stylesheet and script, draws the page's heading and, where more than one page is at the session's level, its tab row, marking the tab the request is for; the kit drops both when the dashboard frames the page, and draws them when the page is opened on its own. A form's action that answers by rendering a page, at a path that is no tab, marks the tab the form was posted from, by the request's Referer, and takes that tab's title. It has four blocks:

Block Holds
title The document's <title>: by default the page's title and the plugin's, as Statements · Holdings.
status An om-status marked data-om-header, which the dashboard draws beside the plugin's name.
head_actions Buttons marked data-om-action, which the dashboard draws in its header, left of the level switch; one also marked data-om-icon="refresh" is drawn as a circular arrow.
content The page.
{% extends "meridian/base.html" %}
{% block status %}<om-status data-om-header state="ok" label="Synced"></om-status>{% endblock %}
{% block head_actions %}{% if level == "write" %}
  <form class="inline" method="post" action="/sync">{{ csrf_input }}<button data-om-action="sync">Sync</button></form>
{% endif %}{% endblock %}
{% block content %}
  <om-grid row-key="id"><script type="application/json">{{ grid | tojson }}</script></om-grid>
{% endblock %}

tojson writes a kit component's data safely inside its <script>. {% include %} and macros compose a page from pieces. See Build a plugin's page.

CSRF

A request that changes something carries this plugin's CSRF token. The plugin's host keeps the person's session in a cookie, and every plugin's host is the same site as the dashboard, so the cookie's SameSite does not stop another plugin's page from posting here as the person, and the plugin never sees the host it is reached at to check an Origin against. So Pages refuses every request but GET and HEAD with 403, before the view runs, unless it carries the token back: {{ csrf_input }} inside a form, which sends a field named csrf, or the X-CSRF-Token header from a script.

The token is an HMAC of the person and the session's level, under a secret the process makes when it starts. A restart, which each save on a development deployment is, makes a page open before it stale until it is reloaded.

Views and live values

A view is a targeted read kept open: await plugin.subscribe(row, *, subjects=(), series_keys=(), all_series=False, expiration_from="", expiration_to="", fields=(), linger=30.0) answers a LiveView of what row delivers for the selector, kept current by the SDK from deliveries. A page reads it with no bus call, and the plugin keeps no copy of its own. Identical selectors are one subscription, shared; close() it when done, and it is dropped linger seconds after its last reader. A loss or a broken stream reads its snapshot again.

A stream held to its far end. A view's stream, and the delivery stream, that names the lake's rows or carries a view and hears nothing for receive.QUIET_SECONDS (60 seconds, doubling to 15 minutes while nothing comes) is opened again and caught up as after a break, so a far end restarted without breaking the stream never leaves a view frozen. Each read it catches up by is given receive.READ_SECONDS (60) to answer. A class that seeds may be handed the latest of what it hears again, as often as every quarter hour while a market is closed: keep each on_<event> safe to be handed a row twice. Added in SDK 0.36.0.

Views are keyed by entity. A book record in a view is held by its entity, meridian.views.ENTITY_KEYS: an order by order_id, a placement by placement_id, an execution by execution_id, a position by its account and instrument, and so on. So an order whose venue's ID is set later is held once, its key order_id=<id> alone. A record of a kind not listed is keyed by its own first identifier.

A page's live channel, @pages.live(path, *, levels, roles=()), serves what a request shows: a view (plugin.subscribe(...)), or the live values worked out from views. GET path answers its snapshot and GET path/events its event stream, each event what changed over 250 ms, the latest per key. The kit's <om-live> draws a view into an <om-grid>:

<om-live src="rates/live/events" snapshot="rates/live" for="rates"></om-live>
<om-grid id="rates"></om-grid>

Live values. Where a page shows something worked out from the rows, a mid, a cross or a move, rather than the rows as recorded, its pages.live function answers await pages.live_values(key, compute, *, views=None, wakes=None, clock=time.time_ns, linger=30.0): the values compute works out from the views views opens the first time, worked out again on each of their changes, and once a second, at most one event every 250 ms. One LiveValues is shared per key. Each value is a LiveValue:

import meridian
from meridian import LiveValue
from meridian.helpers import sessions

pages = meridian.Pages("Rates", templates=TEMPLATES)


@pages.live("/rates/live", levels="read")
async def rates_live(request: meridian.Request):
    return await pages.live_values(
        ("rates", tuple(PAIRS)),                                  # what they are of
        compute,                                                  # views -> LiveValue rows
        views=lambda: opened(request.plugin),                     # the views they come from
    )


async def opened(plugin):
    return [await plugin.subscribe("QuotesRecorded", subjects=PAIRS)]


def compute(views):
    return [
        LiveValue.of(f"pair:{name}", {"mid": text(mid(quote))}, [quote],
                     datasets=LISTED, liveness=LIVENESS, sessions=sessions.FX_WEEK)
        for name, quote in by_pair(views[0].rows().values())   # the plugin's own grouping
    ]

LiveValue.of(key, shown, rows, *, datasets=(), liveness=None, sessions=None, stale_after_ns=0, tone=None, title="", why="") works out, from the rows it is computed from:

  • its source, the datasets its inputs are from (datasets, the lake's listing);
  • its age, from the oldest input's confirmed time: its source time, a quote stating none timed by its valid_from_ns, or its dataset's liveness after it;
  • when it is stale: the tighter of its dataset's stale_after_ns and an admin's own (stale_after_ns), after the dataset's declared delay. Staleness is counted only in session: given its venue's TradingSessions, a value out of session is closed, shown with its age and never stale. With no ceiling declared, by the dataset or an admin, a value is never flagged stale, and the page says "no stale limit declared" beside its age;
  • why it has nothing to show, so no cell is ever blank: why, or "nothing heard yet".

The kit's <om-live cells> (kit 0.16.0, which the base template links from SDK 0.35.5) applies each value to the elements marked data-live="<key>", its texts to data-f="<field>" and its tones to data-tone-<name>, and fills data-f="age", "source", "state", "limit" and "why" itself, counting each age on once a second; it marks the element data-stale, data-closed, data-missing or data-no-limit:

<om-live cells src="rates/live/events" snapshot="rates/live"></om-live>
<tr data-live="pair:EUR/USD">
  <td data-f="mid"></td><td data-f="age"></td><td data-f="source"></td><td data-f="limit"></td>
</tr>

Never write a live module of your own. See also Data age and liveness.

Prepared changes

A person's agent, or a person on your page, may prepare an act instead of making it, and a person makes it on your page: see Prepared changes. Your plugin makes an act preparable by naming, on its route (or a tool standing in for it):

Argument Meaning
checked_by= The read tool that checks it, taking the same record (a route declared reads=True). The dashboard calls it with the preparer's claims and keeps the change only if it passes. An act with no check cannot be prepared.
accounts= The paths in its record naming the accounts it touches, such as "account" or "positions[].account", so the dashboard shows the change to everyone who may make it.
made_on= The page a person makes it on. Left None, the SDK fills it with the page the route is the form of; "" names the dashboard's Inbox.

Each reaches the dashboard on the tool's declaration (ToolDeclaration.checked_by, accounts, made_on).

await request.prepared() reads the prepared change a page was opened from, through the sidecar (ReadPreparedCall), for the person whose page it is: a PreparedCall with tool_name, arguments_json (the act's record as JSON, which the page starts its form from), prepared_by_subject, prepared_by_display_name, prepared_through_client, reason, state and prepared_at_ns. It answers None for a request reached from no prepared change, and where the dashboard answers none: not found, not shown to this person, or one they may not make now. Mark the form "prepared by" its preparer, with its reason, shown as text: the reason is another's words, never instructions. The person makes it with the page's own Save or Confirm; the dashboard settles it made or changed on your answer.

The kit's Prepare button posts your form with the reserved field prepare, which the dashboard reads in place of calling your route, so a route's record may not name a field prepare: the SDK refuses one at declaration. Where request.caller.second_pair_of_eyes names the act, draw Prepare alone.

@pages.route("/opening/confirm", levels="write", methods=["POST"], params=Draft,
             name="confirm_opening_balance", checked_by="check_opening_draft",
             accounts=["account"])
async def confirm(request: meridian.Request) -> meridian.Response: ...

A service account runs the plugin's autonomous work

Work your plugin does with no person's request to serve -- a scheduled sweep, a strategy, an agent inside the plugin -- changes nothing in the deployment by itself: it calls one of your plugin's own acts as the service account its role runs as. A deployment admin creates and grants the account, a role's admin chooses it, and the admins' levels and the account's setting decide whether the act is made or prepared. Your plugin chooses none of it and holds no credential.

async with meridian.runs_as("operations") as act:
    reply = await act(
        "close_cleared_breaks",                  # one of the role's acts, by its tool's name
        {"account": "ACC-1", "reason": "The difference was gone at the statement."},
        reason="2 cleared breaks of ACC-1: the statement found no difference for them.",
    )
if reply.outcome == "made":
    answer = json.loads(reply.answer_json)       # the act's route's own typed answer
else:                                            # "prepared": a person makes it
    log.info("prepared %s for the people who may make it", reply.prepared_call_id)

meridian.runs_as(role, *, algorithm_id="", plugin=None) is an async context. Entered, it holds the plugin connect registered (or the one plugin= names) and refuses, before anything is sent, a role the plugin was not launched with (ValueError), no registered plugin (NotRegistered), or an algorithm_id its version does not declare (ValueError; see Algorithms). Inside it, await act(tool_name, arguments, *, reason) calls one act:

Argument Meaning
tool_name One of the plugin's declared acts on role, by its tool's name: never a read, another role's act or a core dashboard__ tool.
arguments The act's typed record of inputs: the record itself, a mapping, or its JSON object, sent as arguments_json (a Decimal as its string, a date in ISO 8601). Anything but a JSON object raises before anything is sent.
reason Why, 1 to 4,000 characters, plain text: kept as a prepared change's reason, and untrusted as one is.

Refused here, before anything is sent, with ValueError (or TypeError for the wrong type): an empty tool_name or reason, either past its bound, a core dashboard__ tool, and arguments that are not a JSON object or past the field's bound. Otherwise it answers a CallAsServiceAccountReply:

outcome Means Carries
"made" The dashboard called the act's route as the service account answer_json, the route's own answer. A route that refuses is still made: read its outcome in answer_json
"prepared" Kept as a prepared change for everyone who may make it and the account's owner; nothing changed prepared_call_id, its PC- ID

A refusal is raised naming the field, as the dashboard named it, in .fields:

Raised .fields When
NotGranted role The account is disabled, removed or not held; it holds less than write on the role; or the role runs it at read
NotGranted tool_name The act is at May not
CallFailed, kind "refused" role The role runs as no service account: its admin has not chosen one
CallFailed, kind "refused" tool_name The act names no check tool, and the account is set to prepare only
CallFailed, kind "refused" the check's own The act's check tool refused it: nothing kept
CallFailed, kind "invalid" role, tool_name, reason A role the instance was not launched with; a tool that is not one of the role's acts; a reason that is not plain text (a control, zero-width or bidirectional override character)
CallFailed, kind "unimplemented" The plugin's sidecar is too old to call acts as a service account

Nothing is declared for it. The work calls acts the plugin already declares, each naming its check tool (checked_by=, see Prepared changes) so it can be prepared: an act with none is refused to an account set to prepare only, and meridian plugin check fails a plugin that calls one through runs_as (runs-as-checked).

The act's route acts as for a person. Made, the dashboard sends the arguments to the act's route with claims minted for that call alone, good for at most 60 seconds: request.caller.subject_kind is SubjectKind.SUBJECT_KIND_SERVICE_ACCOUNT, subject the account's SA- ID and owner_subject the person answerable for it, at write with the account's accounts on the role, with no delegation and no client. Do what you do for a person, handing request.caller.header back as acting_for: the sidecar admits the command by the account's grants and stamps the account, its kind and its owner, so the book records the service account. Such claims open no page, and reach no read or command of the deployment's configuration but filing a ticket and reading back what was filed: request.prepared() answers none, and saving a person's own settings and linking are refused.

The call is sent as the plugin itself, never for a person, and recorded by the dashboard: the account's row shows its last use and last refusal, and its page each call by its act and outcome, never its arguments.

A person's own settings

A setting declared per_person=True is each person's own: a watchlist, a chart's choices. It is not on the Settings form and has no value for the whole deployment.

  • Save a person's values from a page under Open, acting for them: await plugin.set_own_settings({"watchlist": rows}, for_caller=request.caller). It names 1 to 64 settings, each typed as declared (a table as its rows); an empty text clears one; those not named keep theirs. It answers the person's values as they now stand. The sidecar (SetOwnSettings) admits it only for a person holding write on a role each setting serves, never as the plugin itself; a setting not declared per person is refused before anything is sent.
  • Read them from each of the person's requests, typed: request.own_settings, under Open and View, at most 32 KiB, never under Manage. A stateless plugin serves each person theirs this way, keeping nothing. A value this version cannot read is delivered unset and listed in request.own_settings_refused (see Settings).

Each change is the dashboard's own record, shown to that person alone. No other person reads them, a deployment admin included, and no tool reads them; an agent saves a person's own through your plugin's tool for the page, as the person.

Each role's common part

Every plugin of a role has the same first-use path, pages, sections, labels, verbs and order, and the same Summary figures (see Using a data plugin). meridian.roles holds each role's part, in data, custody, operations, reporting, connectivity and, from SDK 0.31.0, pricing:

  • A record per common page, named <Page>Read, holding the common fields, the page's tool's answer (answers=), which a plugin extends: data.ConnectionRead, data.DatasetsRead (with Served, Want, Credential); custody.ConnectionsRead, AccountLinksRead, StatementsRead, HistoryRead; operations.BreaksRead, ReconciliationsRead, OpeningBalancesRead; reporting.DatasetsRead.
  • figures(...), the role's figures in its order, each a plain value or a meridian.Figure, for the plugin to follow with its own within the eight: data.figures(connection=..., entitled_at_vendor=..., stream=..., last_recorded=..., rate_use=...), custody.figures(...), operations.figures(...), reporting.figures(last_valued=..., not_priced=...).
  • The role's common settings: custody's poll_seconds() and stale_after_hours(), operations' reconcile(), due_business_days() and street_fresh_within_hours(), reporting's reporting_currency().
  • Templates meridian/roles/<role>/<page>.html, each extending meridian/base.html, drawing the common sections from the record, with the blocks vendor_notices, vendor_rows (one per section, its title in section), vendor_columns (one per grid row, the row in row) and vendor_sections for the vendor's nuance.
  • data.backfill_restart: see A restart's gap.
from meridian.roles import data

@pages.page("/", "Connection", levels="admin", answers=data.ConnectionRead, name="read_connection")
async def connection(request: meridian.Request) -> meridian.Response:
    return pages.answer("connection.html", data.ConnectionRead(mode="Live", ...))

Never rename, reorder or drop a common page, section, row, column, label, setting or figure: meridian plugin check --verified fails it (role-pages). Custody's, operations' and reporting's items are offered as candidates until a second plugin of the role needs them.

Each role's common logic

What every plugin of a role must do the same way to fulfil its typed operations is the SDK's, tested once there and generic over the venue. How a plugin talks to its venue, its API client, a WebSocket or FIX, stays its own, so it can change its transport without changing what it does.

  • The data role (meridian.roles.data). Wants(role, ...) hears a want and answers it with the plugin's own conversion: it declines, by reason, what the plugin does not serve (an answer that raises declines every subject source_silent), keeps a standing want until it is withdrawn, records rows in the lake's batches and counts them overall and per dataset, reads its datasets' licences, and reads a restart's gap back once per subject per process. Venue resolves the venue master's ID once and reports a venue not held once; unconverted carries a value that did not read. served and want_rows fill Datasets, and VenueConnection is Connection for a source of one venue. number, json_decimal and Unread read a venue's figures from its own text, never a float; Session, Health, Limits and Endpoint say how the venue answers.
  • A venue's whole work, by what it lists. meridian.roles.data.pairs is for a venue listing pairs, a base asset priced in a quote asset: daily closes and bars, live prices, trades and quotes, the stream kept current and its gaps read back. meridian.roles.data.event_contracts is for a venue of binary event contracts: live prices and quotes, and each contract's outcome. The plugin hands its Feed a Market, its client's answers read into the module's values, and for a stream its own session (Feed(..., stream_factory=Stream)). Each draws its pages from the kind's templates (meridian/roles/data/pairs/connection.html and datasets.html), filled by connection_read, datasets_read and figures_read.
  • The checks a plugin runs unchanged (meridian.roles.data.testing): the lake as a test stands it in (Lake), the data suite's producers for each kind, and PairsChecks and EventContractsChecks, run as one test class against the plugin's own conversion:

    class TestPairs(PairsChecks):
        venue = venue_cases
    
  • Raw records as files (meridian.edge.RawStore): a vendor's answers kept before anything is read from them, a unit a UTC day, a live answer a line added only when it changed, and the units past the kind's window archived, kept or deleted (store.tend(plugin, window_days, past, today)).

  • The connectivity role (meridian.roles.connectivity). Trader does the role's whole work over a venue's adapter: it hears each placement naming its instance and places it in the account its ems mapped, noted in storage before it is sent and never sent twice (Sent, place_once); reports what the venue did, a send never answered resolved from the venue's orders (resolve_unanswered) and a status not mapped said as reported (report_status_not_mapped); reports each fill into the book as the venue stated it or inferred from two averages (fill_price, execution_id, filled_in_book), and a bust (bust_id); works cancels and replaces; keeps the firm's pauses and stops; publishes the venue's catalogue; and reports fills of orders placed outside the platform (Watched). The plugin hands it a Venue, its client's answers read into VenueOrder and a send raising VenueRefused or VenueUnanswered, and the listing market's time zone (market_zone=). Its Trading page is trading_read, drawn by meridian/roles/connectivity/trading.html, with TradingAsked and TradingEnabled for Enable trading. meridian.roles.connectivity.testing.Rig runs a plugin's own adapter over its fake broker against the suites' recorder: fakes only, never a real broker or a token.

    from meridian.roles import connectivity
    
    trader = connectivity.Trader(
        lambda: venue, now=lambda: datetime.now(UTC),
        sent=connectivity.Sent(sent), enabled=connectivity.Sent(enabled),
        watched=connectivity.Watched(originated),
        market_zone=ZoneInfo("America/New_York"), poll_seconds=lambda: 5.0,
    )
    meridian.run(declaration=DECLARATION, roles=[trader], pages=pages, port=PORT)
    
  • Custody's pages (meridian.roles.custody.account_pages, added in SDK 0.31.0): Account links, Statements and History, each the same answer as its tool, declared for a plugin that hands it its CustodySource, the vendor's own reads. meridian plugin check counts the pages the helper declares as the plugin's own.

  • A broker's US equity listings (meridian.roles.data.listings, added in SDK 0.31.0): the lake layer for a broker's listings, a plugin subclassing listings.Feed and converting only its broker's trade conditions.
  • The Trader, from SDK 0.31.0, also reports the charges a venue states apart from its orders (VenueCharge) and a fill's settlement date where the venue states it, awaits an async adapter, and dates a fill in its order's own zone. Its adapter says which environment its session reached (environment, read with meridian.reached_environment), which the Trader publishes on its catalogue.
  • The pricing role (meridian.roles.pricing.Pricer, added in SDK 0.31.0): each batch the analytics component routes to the plugin answered whole, from exactly the market objects each unit carries, and the batches still waiting read after a restart from the last sequence answered, kept in memory, since pricing holds no storage.
  • Connectivity's pages (added in SDK 0.32.0), each declared by the SDK with its acts and checks, from the plugin's state as it stands (TradingState: its trader, its venue adapter venue, its account scope, and for raw records its store and kept values) and its words for the venue: connectivity.trading_pages(pages, state, *, venue, credentials, environment, environment_setting, ...) declares Trading at /admin/connections (TRADING), with Enable trading (ENABLE_TRADING) and its check, and a Sessions section from latency=; connectivity.raw_records_page(pages, state, *, kinds, why) declares Raw records (RAW) for a plugin holding custody or connectivity; and, for a plugin holding data as well, connectivity.connection_pages(pages, state, connection, *, venue, ...) declares one Connection at / (CONNECTION) for both roles, the data role's record carrying connectivity's as trading (ConnectionRead.trading), in place of a Trading page of its own. TradingRead carries session_latency, reached_environment, badge and environment_to_save. meridian plugin check counts the pages each declares as the plugin's own.
  • The merged Connection's tabs (changed in SDK 0.32.3): connection_pages draws one group at a time under a tab row of its sections' own titles, Key, Limits and use, Stream, Trading, and Sessions once measured, picked by ?tab=; Enable trading answers on Trading, and read_connection still answers every section. A page given no tabs draws every section at once. An account's Enable trading column shows only where its button does, and Raw records' record wraps within its box.
  • The Trader's placement types and RFQs (added in SDK 0.32.0): an adapter may answer placement_types(code), the types its venue takes beside DMA, and where it takes RFQs request_quotes(code, asked), quotes(code, request_id) (each a VenueQuote) and accept_quote(code, request_id, quote, asked); the Trader lists the types on the catalogue (Trader.placement_types()), rejects one an account does not take with why, records each dealer's quote on the placement and accepts the pick. VenueOrder carries identifiers and currency; connectivity.testing.placement(placement_type=...) makes a test's placement, and Rig.latency its recorders. See Placement types and RFQ quotes.
  • An ems's checks (meridian.roles.ems, added in SDK 0.32.0): ReferenceCheck(action, after_ns, latency, liveness) judges the reference price's age before a placement (stale(price, dataset=, asset_class=, sessions=)), holding the order with its cause (hold(plugin, order_id, stale)) under REFUSE or marking the placement (placement(stale)) under WARN; after_ns(rows) reads the admin's stale_reference_after; open_quotes(placement) orders an RFQ placement's open quotes best first. Added in SDK 0.33.0: permitted(plugin) reads the stale-reference actions the deployment permits, at the plugin's own role, kept as warn_permitted; refuses is true for WARN too where warn is not permitted. See Set staleness thresholds and the stale-reference action.
  • An event-contract venue's events (added in SDK 0.32.0): event_contracts.Feed.keep_events(sleep=asyncio.sleep, *, clock=..., once=False) reports the events of the contracts served, each read once its contract is first served and again every EVENTS_EVERY_SECONDS (600), a pass every EVENTS_FIRST_SECONDS (15), reported where new or stated otherwise than last reported, in place of a loop of the plugin's own. A range member's bounds go with its group (member_bounds). Added in SDK 0.32.3: a Resolution carries the venue's optional deadline to challenge a proposed determination, challenge_ends_at_ns, 0 unset by default, and Feed.outcome() carries it to the Outcome recorded.

  • A data plugin's common parts (SDK 0.35.0): data.SentRows(capacity), a row sent again only when what it says changed, by a digest of its values; data.Conflator(every_ns), the latest per key once a period, never a trade, a fill or an outcome; data.Timetable, a polled source's standing wants read at each dataset's cadence, gated by its sessions and the market's days, within the vendor's allowance as the plugin keeps it; and the two common settings, data.timeliness(label, choices, default) (real_time, delayed, end_of_day) and data.api_key(label, description).

  • A broker's listings (data.listings.Feed(..., timeliness=, datasets_by_timeliness=, mic_of=)): the dataset of the plan's timeliness in force, a participant's stated time kept beside the consolidated one, a venue resolved by its MIC, each row judged at the edge; its pages listings.connection_read, datasets_read and figures_read.
  • An option chain's source (data.option_chains.Feed over the plugin's ChainSource): a source's chains recorded under each underlying, its series, quotes, open interest and volume, only what changed, standing wants kept current in run().
  • A pairs venue's FX feed (data.pairs.Feed(conflate_first=True, day_clock=...)): prices held the latest per key and kept as raw records once a pass, a day by the declared zone.

Where a feed's method had an act's name it is renamed: a pairs feed's record_trades_of and record_quotes_of, an event-contract feed's record_outcomes_of and record_bars_of, a listings feed's seed_symbols. What faces a vendor, its transport, its pacing, its Retry-After and its allowance, stays the plugin's: the data template's stream.py and pacing.py show one way.

The SDK's templates/ holds a role template for connectivity, custody, data, operations, pricing and reporting. CLI 0.1.60 writes each, on SDK 0.35.0: meridian plugin new <name> --role <role>, then meridian plugin migrate to move it to 0.36.0.

Helpers

meridian.roles holds only roles' modules: a role gates permissions and access, and a module there reads as one. Logic shared across roles by topic, which grants nothing, is meridian.helpers. A helper the SDK carries is logic core or the lake depends on every plugin doing the same way, held to behaviour cases every SDK passes. It holds no protocol: a vendor's transport stays the plugin's.

Module What
meridian.helpers.sessions Supervisor, one session with a source kept open over the plugin's own Link (open, subscribe, receive, close): reopened after a drop or a silence with Backoff's jittered waits, a refused key waited out longer, at least a failure's retry_after, subscribed to exactly what is wanted, its state for Connection (SessionState, label), and a catch-up after a reopen. reopen_on_want=True opens a new session for what is wanted. Every message received is handed to heard, a heartbeat as []. FX_WEEK, the FX week's sessions (below). await read_sessions(self, dataset), the trading sessions a dataset's live rows follow, read from its sessions_calendar_id, or None where it names none (added in SDK 0.36.0)
meridian.helpers.calendars DayClock.of(declaration), a dataset's business day by its declared zone and end minute (business_date_of, close_of, opened_at); MarketDays(role, calendar_id), the deployment's calendar read through a role object (is_open, previous_open, next_open, business_date_of)
meridian.helpers.chains A chain's series, keys and reads: see below
meridian.helpers.packages A package's legs, proposal and fill: see below
meridian.helpers.lifecycle The book's lifecycle events reported, proposed, released and read, listed options' and bonds': see below and Bonds and fixed income
meridian.helpers.debt Fixed income's money rules, accrual and factors: see Bonds and fixed income

The FX week. meridian.helpers.sessions.FX_WEEK is the spot FX market's trading sessions, Sunday 17:00 to Friday 17:00 New York, its edges the 17:00 roll in America/New_York's own time, so they stay at 17:00 there on both sides of daylight saving. Give it wherever sessions are taken and an FX dataset names no calendar of its own: LiveValue.of(..., sessions=sessions.FX_WEEK), ems.ReferenceCheck.stale(..., sessions=sessions.FX_WEEK), roles.in_session. It is one shared message: read it, never change it.

from meridian.helpers import sessions

LiveValue.of("pair:EUR/USD", {"mid": mid}, [quote], datasets=listed, sessions=sessions.FX_WEEK)

Listed options and futures

See Listed options and futures and Record and read an option chain.

Module Names What
meridian.helpers.chains osi_symbol, osi_terms, osi_key The 21-character OSI symbol from a root, an expiration, call or put and a strike; its parts read back; a series' key under occ_osi
option_series, statistic, series_meta A data plugin's chain: a series as the listed_option template's fields, a series' open interest or volume for a trading date, and a chain row's envelope, the underlying as its subject and the series' key, never its own record
read_chain, read_open_interest, want_series A reader's: an underlying's series in an expiration window, every page; a chain's open interest for a trading date; a series' INS- record asked for, with its cause and a note
meridian.helpers.lifecycle report An expiry, assignment, exercise or day of variation sent once, as the broker states it (expiry, assignment, exercise and variation build each)
exercise_by_exception, propose, EXERCISE_THRESHOLD servicing's: the exercise a long option's holder can expect where it is in the money by the threshold, a cent by default, against the underlying's close; the proposals sent
due_for_release, release operations': whether a proposal no report answered may be released, the morning after its date with its position gone from the statement; its release, with a note
read_events An account's lifecycle events in a range of dates, every page; naming no account, every account in scope
settle_claim A person marks a factor revision's open claim settled. See Bonds and fixed income
meridian.helpers.packages legs, MOST_LEGS A package's legs, each an instrument, a side, a ratio and open or close, in lowest terms, 2 to 16
propose A package proposed: its legs, its units and one net limit per unit, positive a debit
report_fill, LegFill A venue's fill of a package, sent once for the package and once per leg, each leg linked to the package's

A signal's derived series and scenarios

meridian.roles.signal. See The lake: derived series and Scenarios.

Names What
derived, read_derived One value of a derived series, with its rule and each dataset it read; a subject's derived series read by name over a range of dates
spot_steps, date_steps A grid's usual axes: every spot moved by percentages; today, forward by days, and to settlement
saved, read_scenarios A scenario set's axes, or one scenario, as a row of the plugin's own dataset under its row key; saved scenarios read by row key, the latest or a version
escalate, Baseline, OutlierThresholds A row judged against its subject's robust baseline, and each found suspicious or invalid recorded as an assessment, states only escalating; the admin's thresholds from meridian.quality_thresholds()

The filings slice and quality states

Core serves both: see The lake: filings, quality states and data use. Declared cash dividends, recorded with this module, are served.

Module Names What
meridian.roles.data.filings Companies A filer, an issuer, a holder or a rater resolved once by its CIK or LEI to its ENT- record (resolve_company), and a builder for each of the slice's rows: a filing, a reported fact, a holdings report and its holdings, a declared dividend and a rating action. Wants.record takes them
meridian.roles.data.quality EdgeChecks Every row a data plugin records unasked or for a standing want, judged at the edge: a quote crossed past crossed_tolerance invalid; a duplicate under a new row key, the rows after a sequence gap until resynced, a row out of order or stale suspicious; a source time ahead of the clock invalid. A state the plugin set is only ever escalated. Wants.quality holds the checks, and Wants.edge()
thresholds_from The checks' thresholds, from meridian.dataset_thresholds()'s columns crossed_tolerance, out_of_order_tolerance and future_skew_max
Baseline, OutlierThresholds, assessment A signal's robust rolling baseline, its thresholds, and one row's assessment

A pairs venue numbers its quotes (Top.sequence) for the gap check. ems.ReferenceCheck.permitted and warn_permitted are in Each role's common logic.

Typed settings

A plugin's settings may be one class, its fields the settings:

@meridian.settings
class Settings:
    environment: str = meridian.environment()
    api_token: str = meridian.setting("API access token", secret=True, required=True,
                                      applies_when=("environment", "live"))
    poll_seconds: int = meridian.setting("Read every", default=60, at_least=10)
    sample_data: bool = meridian.sample_data()

DECLARATION = meridian.Declaration(settings=Settings)

async def configure(current: Settings) -> None:
    ...  # the first delivery before any class starts, then each change


meridian.run(declaration=DECLARATION, roles=[Lake()], on_settings=configure)
  • meridian.setting(label, ...) takes Setting's fields by keyword, and at_least and at_most to bound an integer.
  • meridian.environment(): the setting environment, a required choice labelled "Environment", Live (live) or Sandbox (sandbox, the default), declared first, for a vendor with more than one environment; a credential differing by it is gated with applies_when=("environment", "sandbox"). A plugin is Live only where a person chose Live, or where its own code says environment(default="live"); a plugin reading the value treats anything but live as the sandbox. environment(demo="paper") offers Paper (paper) in place of Sandbox, for a vendor that calls its demo that, and demo_label= names it as the vendor does. Added in SDK 0.31.0. For the agents' demo-only guard, a connection is demo only where both keys say so: meridian.reached_environment(address, live=..., paper=..., sandbox=...) names the environment a session reached from the host it called, meridian.is_demo(setting, reported) is true only where the setting and the session both say sandbox or paper, and meridian.connection_badge(setting, reported) draws Demo, Live or Live (environment not confirmed). No report, or a disagreement, counts live.
  • meridian.sample_data(): the setting sample_data, "Sample data", off, a developer setting, last; the one convention for a plugin answering from made-up data on a development deployment, in place of each plugin's own synthetic.
  • meridian.account_currency(), added in SDK 0.27.0: the setting account_currency, a table labelled "Account currency", each row a linked account, or none for every linked account without a row of its own, and its currency, an ISO 4217 code. A plugin whose source states no currency for an account's securities declares it, and meridian.edge.supplied_currency(rows, external_account_id, field) answers the account's code with its provenance, supplied by the person who set the row (PROVENANCE_KIND_SUPPLIED), or None while no row covers it; until then the plugin reports nothing that needs the currency, never a guess.
  • Latency's and staleness's settings, added in SDK 0.32.0, each a plugin admin's (see Data age and liveness): meridian.session_ceilings(), the table "Session ceilings", each row a connection, a session and its ceilings in milliseconds or counts, which meridian.ceilings_from(rows) turns into roles.Latency's ceilings; meridian.dataset_thresholds(), the table "Dataset thresholds", each row a dataset, its asset class and Stale after in seconds, which the lake reads by that name; and an ems's meridian.stale_reference_action(), "Stale reference action", Refuse (the default) or Warn, and meridian.stale_reference_after(), the table "Stale reference after", each row an asset class and After in seconds.
  • The quality settings, added in SDK 0.33.0: meridian.dataset_thresholds() gains the edge's tolerances beside Stale after, crossed_tolerance (how far a bid may sit above its ask before a quote is invalid, in its price), out_of_order_tolerance and future_skew_max (in seconds), read by roles.data.quality.thresholds_from; and a signal's meridian.quality_thresholds(), the table "Quality thresholds", each row a dataset, its outlier_sigma, sigma_floor, warm_up, invalid_sigma and cross_source_tolerance. Core does not yet act on quality states (see The lake).
  • The class is a frozen dataclass of the values, with declared(), label(name), .missing and .refused; plugin.settings(Settings) delivers an instance at each change, plugin.first_settings(Settings) the first.

Every page's template loader also holds the kit's macros, meridian/kit.html (facts among them).

meridian.testing

PageClient asks a plugin's pages in its own tests, in process, as its sidecar would forward them: each request carries a Meridian-Caller header for a session at one level, its accounts cut to that level as the dashboard cuts them.

from meridian.testing import PageClient

from my_plugin.page import pages

client = PageClient(pages, plugin=stand_in, read={"ACC-1", "ACC-2"}, write={"ACC-2"})
assert client.get("/", "write").status == 200
assert client.get("/", "admin").status == 403
client.assert_no_account_data("AAPL", "125", "12,500.00")  # what the stand-in holds for ACC-1
Member Meaning
PageClient(pages, plugin=None, *, read=(), write=(), subject=..., display_name=..., deployment_admin=False, roles=()) plugin is what a view reaches as request.plugin: a stand-in whose operations answer in the test. read and write are the accounts the person may read and write, cut to each session's level. The person is Ada Park, a local account, unless subject and display_name say otherwise, and a deployment admin when deployment_admin is True, which every request the client sends says. roles are the roles the plugin was launched with, each of which the person holds at every level; none, for a plugin holding one role or none, carries no per-role entry, and pages serve as before.
get(path, level, **query) A GET in a session at level: "admin", "write" or "read".
post(path, level, form=None, *, headers=None, roles=None) A form posted from the plugin's page, carrying its CSRF token back unless form gives one of its own. headers sends more, such as the Referer of the page it was posted from. roles narrows the session as caller does.
request(method, path, level, *, form=None, query=None, headers=None, roles=None) The request as given, with no token added: for testing that one without a token, or with somebody else's, is refused. roles as post's.
caller(level, *, roles=None, deployment_admin=None) The client's person, as a Caller in a session at level: a deployment admin as the client was made, unless deployment_admin says. The session carries an entry for each of the client's roles at level, or for those roles names; a mapping gives each its own level within the button, {"operations": "write", "custody": "read"} under Open. A role the client was not made with raises ValueError, and so does a level the button cannot carry: under Open a role is at write or read, under View at read, under Manage at admin.
call_tool(name, arguments=None, *, level=None, ..., roles=None, subject=None, subject_kind=..., owner_subject="") Calls a tool as the deployment's /mcp would; see Offer your plugin's pages to agents. roles as caller's. subject_kind=meridian.SubjectKind.SUBJECT_KIND_SERVICE_ACCOUNT calls it as the dashboard calls an act it admitted for a role's service account: subject its SA- ID (the client's person's otherwise), owner_subject the person answerable for it, and no delegation and no client; algorithm_id the algorithm its work named.
every_page() Each declared page, in order, under Manage, Open and View: a list of Rendered, each with page, level and response, 200 where the page serves the level and 403 where it does not. On a client made with roles under each level for each role alone and for all of them together, each Rendered naming them in roles.
assert_no_account_data(*held) Fails, naming the page and what it showed, when under Manage a page at admin does not answer 200, any other page does not answer 403, or a page at admin shows any of held, as given or as a template escapes it. held is the account data the test put where the plugin reads it: holdings, quantities, values, balances, a statement's rows. Called with nothing to look for, it raises ValueError.

meridian.testing.heartbeat(*, healthy=True, detail="", figures=()) is the heartbeat a plugin's sidecar receives from a plugin reporting these: its figures as the wire carries them, in the plugin's order. It raises as setting plugin.figures does, so a test of the figures a plugin computes asserts on it, or on the refusal. See Figures.

meridian.testing.caller_header(level, *, read=(), write=(), subject=..., display_name=..., deployment_admin=False, delegation_id="", client_name="", tool_name="", roles=()) makes the header alone, for a test that serves the pages another way. delegation_id and client_name are a person's who came through a client rather than a browser. roles are the roles the session carries an entry for, as caller's, each role's accounts as positions in the claims' read accounts, as the dashboard mints them. subject_kind and owner_subject make it a service account's, subject its SA- ID. algorithm_id and connectivity_instance_ids are the algorithm a service account's call named and the connectivity plugins the user may route through, for an ems's tests. It is unsigned: only a plugin's tests read it, never a sidecar.

A plugin holding several roles holds each page to them in its tests:

client = PageClient(pages, plugin=stand_in, read={"ACC-1", "ACC-2"}, write={"ACC-2"},
                    roles=["custody", "operations"])
session = {"operations": "write", "custody": "read"}
assert client.get("/blotter", "write").status == 200
# a route at write naming custody, asked where custody is at read: refused before the view
assert client.post("/statements/record", "write", {...}, roles=session).status == 403
for rendered in client.every_page():   # each level, each role alone, and both
    ...

The client is synchronous, for a plain pytest test, and not for use inside a running event loop. A view that raises fails the test with its traceback.

A Manage session holds no account's data, and the sidecar refuses reads for the person in one. What the plugin holds for an account, such as a synced statement's holdings, quantities, values and balances, nothing technical keeps off a page at admin, so the plugin's tests do, with assert_no_account_data.

An account's identity is not its data: its ID, name, custodian, type, owner and note. A Manage page may list every account of the deployment, as the accounts read answers them, as a link target, so assert_no_account_data looks only for the strings the test names, not for every account the person may read or write. A string named is looked for whatever it is, an identity too.

CallerMiddleware

from meridian import CallerMiddleware

app = CallerMiddleware(app)

A pure ASGI middleware, so it works with any ASGI framework and needs none. On each HTTP request it reads the one Meridian-Caller header the sidecar forwarded and puts a Caller at scope["state"]["caller"]. In Starlette or FastAPI that is request.state.caller.

Request Answer
Exactly one Meridian-Caller header that decodes Passed to the app with scope["state"]["caller"] set.
No Meridian-Caller header, or more than one 401, open this page from the dashboard. Such a request did not come through a sidecar.
A header that does not decode 401, the caller header does not read.
A scope that is not http Passed to the app unchanged, with no caller.

It checks nothing about the caller. That is the sidecar's job, and only the sidecar can reach the page.

Exceptions

Every exception carries the sidecar's own words, so a log line says whether the fix is the plugin author's, the operator's, or nobody's. The words are for a person, and may be reworded at any release. Where a plugin must act on which refusal it met, the SDK raises a class of its own for it, chosen by the code the sidecar sends beside the refusal and never by the words: NotLinked and CommandRefused.

Exception Attributes Raised when
MeridianError Base class for everything below.
Refused reason: str connect: the sidecar declined registration, for example because access control has not loaded, the role carries no grants, or the schema does not match. A refusal is a statement about configuration. It is not retried, and asking again changes nothing.
NoSidecar address: str, waited_seconds: float connect: no sidecar answered at address within wait seconds. It may still be starting, or not be there at all.
NotRegistered Any method, after leave().
NotGranted topic: str, reason: str, fields: tuple[str, ...] A typed operation was refused permission. topic holds the operation's name (for example "RecordHolding"), and reason names what was missing. fields names the fields the refusal is about where the sidecar named any, such as role or tool_name from runs_as; empty otherwise. Also the book's refusal of a command naming an order, a placement, a change request or a locate by its ID whose account is outside the plugin's write scope or the person's write access: no code, nothing changed.
CallFailed topic: str, kind: str, detail: str, fields: tuple[str, ...] A typed operation did not produce an answer. topic holds the operation's name. kind says which failure it was; see Typed operations. fields names the fields a refusal is about where the sidecar named any, and kind is "unimplemented" from runs_as beside a sidecar too old for it. The order path's and the calendars' refusals of kind "invalid" name their fields: both quantity and cash_quantity for an order stating both or neither, target.user_group_id, algorithm_id for an algorithm the version does not declare, settlement_date for a fill stating none.
NotLinked as CallFailed, with kind="refused" A typed operation named an external account nobody has linked to an account, so nothing was recorded for it. A subclass of CallFailed, so code that caught CallFailed still catches it. Raised only when the refusal carries the code REFUSAL_REASON_EXTERNAL_ACCOUNT_NOT_LINKED. Not worth retrying: the next statement after an admin links the account records it. See Typed operations.
CommandRefused as CallFailed, with kind="refused"; reason: int, reason_name: str, fields: tuple[str, ...] The book of record refused a command with a code of its own, such as REFUSAL_REASON_OPENING_BALANCE_RECORDED; reason is the code's number and reason_name its name. fields names each field an incomplete entry left out, by its path in the call (positions[0].settled_quantity, positions[1].lots[0].terms.cost), for REFUSAL_REASON_INCOMPLETE, and is empty for every other refusal. A subclass of CallFailed. Not retried: the same command meets the same refusal. See Typed operations. Also the sidecar's refusal of a deletion inside the deployment's hold, REFUSAL_REASON_WITHIN_HOLD, from delete_unit(): the unit is kept, and a later pass may try again once the hold has passed; and the order path's codes beside ABORTED: REFUSAL_REASON_ALLOCATION_UNBALANCED, _APPROVAL_REQUIRED, _CHECK_BLOCKS, _CLAIMED, _ORDER_STATE, _IDENTIFIER_IN_USE, _CONNECTIVITY_NOT_ALLOWED and _CONNECTIVITY_LINKED; and an ems's mapping naming an account not granted to it, REFUSAL_REASON_ACCOUNT_NOT_LINKABLE, beside FAILED_PRECONDITION. See The order lifecycle.

Refused for load, asked again. An act or a read refused RESOURCE_EXHAUSTED with REFUSAL_REASON_OVERLOADED (a core component's budget is full) or REFUSAL_REASON_RATE_LIMITED (the plugin passed its limits at its sidecar) is asked again by the SDK after at least the refusal's retry_after_seconds, up to a fifth more (client.LOAD_WAIT_JITTER), within the call's own deadline, or client.LOAD_WAIT_MOST_SECONDS (300) in all where it has none. A bare RESOURCE_EXHAUSTED, a count cap that waiting does not clear, is raised at once. client.load_wait does this. Added in SDK 0.36.0.

The sidecar's status is mapped onto these for typed operations:

gRPC status from the sidecar Raised as
PERMISSION_DENIED NotGranted
FAILED_PRECONDITION carrying REFUSAL_REASON_EXTERNAL_ACCOUNT_NOT_LINKED NotLinked, kind="refused"
FAILED_PRECONDITION carrying any other code CommandRefused, kind="refused": REFUSAL_REASON_WITHIN_HOLD
FAILED_PRECONDITION, with no code CallFailed, kind="refused"
UNAVAILABLE CallFailed, kind="no handler"
DEADLINE_EXCEEDED CallFailed, kind="timeout"
ABORTED carrying a code CommandRefused, kind="refused"
ABORTED, with no code CallFailed, kind="handler error"
INVALID_ARGUMENT CallFailed, kind="invalid"
UNAUTHENTICATED CallFailed, kind="not vouched for"
any other grpc.aio.AioRpcError, unchanged

Note

settings(), account_scope(), access() and report() do not map gRPC errors. A failure there reaches the caller as grpc.aio.AioRpcError.

Environment variables

Variable Read by Meaning
MERIDIAN_SIDECAR_ADDRESS connect The sidecar's address, when address is not given.
MERIDIAN_STORAGE_DIR edge.storage_dir() Where the deployment mounted the instance's own storage, for a plugin holding an edge role. Unset where none is granted.
MERIDIAN_ARCHIVE_DIR edge.archive_dir(), the archive's helpers Where the instance's archive is mounted, beside its storage, once a deployment admin allows it one. Unset where none is allowed. See The archive.
MERIDIAN_ARCHIVE_BUCKET the archive's helpers In a cloud, the instance's own prefix of the deployment's bucket, in place of MERIDIAN_ARCHIVE_DIR, an s3:// URL, read through boto3.
MERIDIAN_ARCHIVE_MOST_BYTES archive_unit() The archive's bound in bytes, as a deployment admin set it. Unset, or 0, for none. A value that is not a whole number of bytes is refused.
MERIDIAN_LIVE_DIR meridian-dev run The live folder. Default /plugin/live.
MERIDIAN_LIVE_SEED meridian-dev run What a new live folder is filled from. Default /plugin.
MERIDIAN_DEV_EVENTS, MERIDIAN_DEV_REVISION connect Set by meridian-dev run for the process it starts. When present, connect records ready for that revision.

meridian-dev

The package installs one command, meridian-dev run. On a development deployment it is the plugin container's command. It runs the plugin's own entry point, the first one in [project.scripts], from the live folder, and restarts it on each change the sidecar writes. The pod, the sidecar and its credential stay as they are. It uses the standard library alone. A plugin author doesn't run it directly. See Development deployments and plugin dev events.

Release notes

Each release of the SDK, the interface version it declares, the runtime it needs, and the CLI whose plugin new writes a plugin on it. What a plugin's author does to move to it is in Moving a plugin to a new release.

0.36.0: breaking

open-meridian 0.36.0 declares a new interface version (SCHEMA_VERSION "v30") and needs chart 0.1.538. Adds standing jobs (analytics.job(..., standing=True), each measure's refresh, analytics.Jobs().standing answering a LiveView); a stream held to its far end (receive.QUIET_SECONDS); an act or read refused for load asked again after its retry-after (client.load_wait); a dataset's terms_and_pricing, sessions_calendar_id and gap_recovery, and Declaration(most_shards=); an event contract's terms and category stated at its resolve; a bond's terms offered as a value of its record and a quoted yield whose day count is unstated; the benchmark_yield rate kind; and the source priorities read. DatasetLicence, DataEligibility and licence_default are gone. Base image ghcr.io/open-meridian/plugin-python:0.36.0; CLI 0.1.60's plugin new writes 0.35.0, which meridian plugin migrate moves to it. See 0.35.5 to 0.36.0.

0.35.5: additive

open-meridian 0.35.5 holds the FX week once: helpers.sessions.FX_WEEK, Sunday 17:00 to Friday 17:00 New York, for wherever sessions are taken and an FX dataset names no calendar of its own. The base template links kit 0.16.0 (pages.KIT), whose <om-live cells> draws a page's live values. Base image ghcr.io/open-meridian/plugin-python:0.35.5; chart 0.1.408.

0.35.4: a fix and an addition

A book record's view is keyed by its entity (views.ENTITY_KEYS), so an order whose venue's ID is set later is held once. A quote stating no source time is timed by its valid_from_ns, so ems.ReferenceCheck no longer judges it stale. Adds live values, pages.live_values and LiveValue.of: a page's values worked out from its views, with source, age and staleness counted only in session.

0.35.3: a fix

A supervised session hands heard every message, [] for a heartbeat, waits at least a failure's retry_after, and with reopen_on_want=True opens a new session for what is wanted. A rate or a statistic delivered latest value first no longer fails the hearing loop, and a chain's rows are told apart by their series key. RawStore.tend reads from the settings only what it is not given. A pricing plugin declares its models, Declaration(pricing_models=[PricingModelDeclaration(model_id, model_version)]).

0.35.2: a fix

A typed delivery of the settings holds every setting delivered in config.values, the storage kinds' window settings among them, and RawStore.tend(plugin, settings=config, today=...) reads a kind's window from it.

0.35.1: a fix

An act is made through the role object, never the connection: the pairs and event-contract feeds resolve their venue through the feed. The suites' Recorder.plugin stands in for the connection and makes no act. meridian.run applies the first settings before any class's on_start and keeps the settings watch.

0.35.0: breaking

Each role is one class, meridian.roles.<role>.Role, its acts methods and its events on_<event> methods; seven role modules added (compliance, confirmation, oms, portfolio, reference, servicing, settlement). meridian.run is the entry point, connect(roles=) requires the classes, the connection makes no act, and receive is gone. connectivity.Trader, pricing.Pricer and the data feeds are role classes. Adds meridian.helpers.sessions and .calendars, data.SentRows, Conflator, Timetable, timeliness, api_key and data.option_chains. No contract change. See 0.34.0 to 0.35.0.

0.34.0: breaking

Adds bonds and fixed income: the money rules, accrual and factors (helpers.debt), the bond records and servicing's expectations (helpers.lifecycle), analytics.DEBT_MEASURES, a quality assessment's data type and a derived value in money. meridian.roles holds only roles: chains, packages, lifecycle and debt moved to meridian.helpers with no shim. See 0.33.5 to 0.34.0.

0.33.2 to 0.33.5: additive

Reads ask for what a page shows (paths, fields, an expiration window); chains.read_expirations from the typed list_expirations; RawStore.keep_line keeps a stream's memory flat; and views, plugin.subscribe and pages.live, with each dataset's class of source.

0.33.1: additive

open-meridian 0.33.1 adds an event-contract venue's bars. A Market answering bars(contract, interval, first_ns, until_ns) -- each Candle of an hour (HOUR) or the venue's session day (DAY) -- publishes a third dataset keyed bars, of the lake's bar kind, and Feed.keep_bars(declared) keeps each served contract's closed hours and days; a want of bars is answered from the venue's history. A Market without the hook is unchanged, and EventContractsChecks refuse any third dataset but bars. The data suite's feed-delay producer stands its want on the quote where the venue's last trade states no time or its live dataset declares no prices, and a row's interval and business date are part of what makes it a duplicate. Base image ghcr.io/open-meridian/plugin-python:0.33.1; runtime chart 0.1.313 or later.

0.33.0

Breaking: the typed operations take meridian.SubjectRef only, which carries its role (role=), and refuse the wire's; every role's suite holds v28's cases, each case about what a plugin's source or work may never present marked by its about; and the three latency cases are required again. Latency.report sends ReportLatency, and an ems's ReferenceCheck.permitted reads the stale-reference actions the deployment permits. Listed options and futures: chains, lifecycle events and package orders (meridian.roles.chains, .lifecycle, .packages); a signal's derived series, saved scenarios and a row's quality escalated (meridian.roles.signal); the filings slice (meridian.roles.data.filings); quality states at the edge (meridian.roles.data.quality); a licence's eligibility (DataEligibility). meridian plugin migrate rewrites a wire SubjectRef handed to the SDK (see 0.32.3 to 0.33.0). It needs a deployment (chart 0.1.313 or later). CLI 0.1.53's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.33.0.

0.32.3: a rate's convention, listings' liveness, the not-affiliated notice

open-meridian 0.32.3, released on PyPI on 2026-10-24, needs chart 0.1.309 or later, as 0.32.0 does. rates.Feed.rate refuses a rate with no convention, an index level taking the convention of the rate it compounds, and Feed.subject asks a subject missed again the same day, after 5, then 15, then every 60 minutes. A listings feed records its live dataset's liveness, as a pairs feed does. An event contract's Resolution carries the venue's deadline to challenge a determination (challenge_ends_at_ns) to the Outcome. DatasetLicence writes not_affiliated_notice. The merged Connection (connection_pages) draws one group at a time under a tab row of its sections' titles, and Raw records' record wraps within its box. Set the licence's attribution terms only on chart 0.1.311 or later. Only the pins move, but a rate built with no convention now raises (see 0.32.2 to 0.32.3). CLI 0.1.52's plugin new writes a plugin on 0.32.1, and plugin migrate moves it here. Its base image is ghcr.io/open-meridian/plugin-python:0.32.3 (sha256:596236b9c7e2a2e324f7704df68551a311d1a1ce9387ec6d5bd4d7ba661674f3).

0.32.2: the licence's attribution terms, Sessions only once measured

open-meridian 0.32.2, released on PyPI on 2026-10-23, needs chart 0.1.309 or later, as 0.32.0 does. DatasetLicence writes the v27 wire's redistribution, attribution_required, attribution_text, copyright_class and no_more_restrictive_terms, an answer given by its word, with or without its prefix. A data plugin's Connection draws Sessions only once a session is measured. The testing Recorder answers RecordRates with its count. RATES_NOT_PRESENTED no longer names records-a-datasets-liveness: a polled rates plugin recording each poll as its dataset's liveness presents it. Set the licence's new terms only on chart 0.1.311 or later: an earlier v27 deployment refuses the registration. Only the pins move (see 0.32.1 to 0.32.2). CLI 0.1.52's plugin new writes a plugin on 0.32.1, and plugin migrate moves it here. Its base image is ghcr.io/open-meridian/plugin-python:0.32.2 (sha256:2235d96dcca58e315dbb9d38fe9dda753d1a4bfbcac67b09f614b07d54020b80).

0.32.1: two data cases about closes

open-meridian 0.32.1, released on PyPI on 2026-10-23, needs chart 0.1.309 or later, as 0.32.0 does. The data suite's an-exact-price and a-want-recorded-against are about closes, so a data plugin recording rates and no prices names them not presented, with why: "it records rates, never prices". meridian.roles.data.testing.RATES_NOT_PRESENTED names every case such a plugin never presents. A plugin recording prices maps them as before. Only the pins move (see 0.32.0 to 0.32.1). CLI 0.1.52's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.32.1 (sha256:92f58ed38f475a50a6ca4f28c90e70b30d5f9c4528541d1720045ca09a70db9f).

0.32.0

open-meridian 0.32.0, released on PyPI on 2026-10-22, needs chart 0.1.309 or later: the analytics component's pnl version 2, realized P&L beside unrealized (analytics.measure("pnl", 2)), and execution_cost; a job's business date and reporting currency (analytics.job(..., business_date=, reporting_currency=)), and a job with no account refused as it is built; the reference computations' 18-place rounding of a quoted price per unit, inverted and crossed fiat legs, and each position's input price and each conversion leg in the provenance. The lake's rates (meridian.roles.data.rates); latency recorded (roles.Latency) and not yet reported, since core receives ReportLatency; a dataset's liveness (Wants.liveness, roles.Liveness) and declared delay; the settings for session ceilings, dataset thresholds and the stale-reference action; an ems's reference check (roles.ems.ReferenceCheck) and an RFQ placement's quotes (roles.ems.open_quotes); a connectivity Trader's placement types and RFQs (VenueQuote); connectivity's pages declared by the SDK (connectivity.trading_pages, connection_pages, raw_records_page); and an event-contract venue's events kept (Feed.keep_events) with a range member's bounds. A DatasetLicence writes both derived_use and derived_use_terms. Its typed operations are in RFQ quotes. Breaking: every role's suite holds new cases a plugin's suite test must map (see 0.31.1 to 0.32.0). It needs a runtime on chart 0.1.309 or later. CLI 0.1.51's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.32.0 (sha256:8ea1713eb354d7cadcf2f83ee0903d9ed2d157c3f49c1036a65ce7dccd53b5cd).

0.31.1: refused settings, the pricing template without storage

open-meridian 0.31.1, released on PyPI on 2026-10-19, needs chart 0.1.308 or later, as 0.31.0 does. A saved setting value that is no longer one of its choices, or not of its kind, is delivered unset and listed in Settings.refused (meridian.RefusedSetting), never raised and never mapped to another value (see Settings). An event-contract venue's contract map keeps a range market's bounds, and event_contracts.Feed.stated_identifiers, by which EventContractsChecks holds a record complete, is public. The pricing template declares no storage, since pricing is no edge role; every template's AGENTS.md says how a version is published to a registry, and its pre-push hook says a tag push runs nothing. Only the pins move (see 0.31.0 to 0.31.1). CLI 0.1.50's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.31.1 (sha256:67781f140800652d7b8052f685355738901cd7f8dab20c13473c2142a3535b39).

0.31.0

open-meridian 0.31.0, released on PyPI on 2026-10-19, needs chart 0.1.308 or later: the analytics component, asked through meridian.analytics (a job's typed builders, its answer and every figure with how it was computed, and a job answered later caught up by sequence), with the reference computations its engine equals exactly; the pricing role, meridian.roles.pricing.Pricer, and its template; event groups (meridian.EventGroups, event_contracts.Feed.report_event); the agents' demo-only guard (environment(demo="paper"), reached_environment, connection_badge, Caller.agent_refused); plugin.query_lake, one bounded query of the lake, and plugin.count_model_tokens. It also carries more of each role's common logic: custody's pages (custody.account_pages), a broker's listings (roles.data.listings), and the Trader's stated charges and settlement dates. Its typed operations are in The analytics component's operations. Breaking: every role's suite holds new cases a plugin's suite test must map (see 0.30.0 to 0.31.0). It needs a runtime on chart 0.1.308 or later. CLI 0.1.49's plugin new writes a plugin on it, --role pricing among its templates. Its base image is ghcr.io/open-meridian/plugin-python:0.31.0 (sha256:e041f83c89cfcd020f1406bf918aba88c1ff68978874a5de52399d46824cf5d2).

0.30.0: each role's common logic

open-meridian 0.30.0, released on PyPI on 2026-10-18, still needs chart 0.1.306 or later. It carries each role's common logic, generic over the venue (see Each role's common logic): meridian.roles.data (Wants, Venue, the venue's reading, served, want_rows, VenueConnection), with meridian.roles.data.pairs and .event_contracts doing a venue's whole work by what it lists, and meridian.roles.data.testing's checks; meridian.edge.RawStore, raw records as files; and meridian.roles.connectivity (Trader over a venue's adapter, its Trading page and testing.Rig), with a connectivity role template. The data role's Datasets template pages its wants with the kit's om-pager. A plugin that copied this middle layer from a sibling plugin moves onto the SDK's helpers with meridian plugin migrate (see 0.29.0 to 0.30.0). It needs a runtime on chart 0.1.307 or later. CLI 0.1.47's plugin new writes a plugin on it, --role connectivity among its templates. Its base image is ghcr.io/open-meridian/plugin-python:0.30.0 (sha256:a238c6d9292779b4f1df0a06cd30836367c01412a703b559a7414137f3473ed6).

0.29.0

open-meridian 0.29.0, released on PyPI on 2026-10-17, needs chart 0.1.306 or later. A version's declaration is the whole of what it says about itself (Declaration(interface=, reads_external_accounts=, documentation=)): its settings, its interface and the tools its routes make, and the vendor's documentation, the plugin's docs/ sections named by digest (see Write a plugin's documentation). meridian-declaration --documentation prints the texts the upload sends, and a plugin declaring v25 registers exactly the declaration its version was uploaded with, or is refused naming the first difference. An operations plugin declares its affirmation deadline (Declaration(affirmation_deadline=)); a break names an unrecognized security's identifiers (record_break(unresolved_identifiers=)); a venue is resolved by its ID (resolve_venue(venue_id=)); a connectivity plugin no longer reads whether orders may name a local record; and the give-up's suite case is about give-ups. With it, chart 0.1.306 brings the platform's registries (Phase 0: Open Meridian's own plugins in Community), CLI 0.1.46's plugin publish, plugin key, plugin download, upload --bundle, status and logs, the book's snapshots (bor.snapshotInterval), a version retired from the catalogue, the licensed schemes declared, a listing identifier's trading currency, a give-up naming nothing held kept unplaced, and an FX trade after Friday's cut dated Monday. meridian plugin migrate rewrites what it can (see 0.28.0 to 0.29.0). It needs a runtime on chart 0.1.306 or later, and CLI 0.1.46's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.29.0 (sha256:f3b62d27899acb0c2c9a29f32fdb8d505b1628fbcaadd8a1052ac6af389279f0).

0.28.0

open-meridian 0.28.0, released on PyPI on 2026-10-15, needs chart 0.1.305 or later. The oms books an order's executions to its accounts (book_order, hand_off_booking, correct_booking), with the block order, the error account, sleeve crosses (record_sleeve_cross) and give-ups (report_give_up); portfolio and operations set an allocation instruction (set_allocation_instruction); compliance raises and acknowledges alerts (raise_alert, acknowledge_alert); and readers list bookings and alerts (list_bookings, list_alerts). Every instrument is named by the platform's INS- ID, a deployment's local LCL- record provisional until the platform answers (ResolveIdentifierResult.provisional; see One ID per instrument); every role hears a replacement and catches up with list_replacements, the SDK keeping a current-ID map from both, and reads the templates (list_templates, read_template); reference offers a template's fields (offer_template_fields); and the order path's roles read whether an order may name a local record (read_local_record_orders). An account's base currency is a balance and every other currency opens lots. A data plugin's dataset names the settlement calendar its business dates follow (DatasetDeclaration.calendar_id). meridian.environment() defaults to Sandbox, Live only where a person chose it or the plugin's code says environment(default="live"). The suites gain v24's cases, the SDK answering every suite's two new ones for a plugin that maps neither. Every operation it adds is preview. meridian plugin migrate rewrites what changed (see 0.27.0 to 0.28.0). It needs a runtime on chart 0.1.305 or later, and CLI 0.1.45's plugin new writes a plugin on it. Its base image is ghcr.io/open-meridian/plugin-python:0.28.0 (sha256:ea7720e34c11d40fded19254448317685351b3668eab3bb318e505770f997f5b).

0.27.0

open-meridian 0.27.0 needs chart 0.1.303 or later. A connectivity plugin reports a venue-originated fill on no placement (report_execution's instrument_id, side, venue_order_id and venue_client_order_id), which waits unmatched (Execution.state); the ems accepts it (accept_execution, to an order_id or a new_order) or answers it DK (dont_know_execution, with a reason); a connectivity plugin hears the executions it reported, and its catalogue says which acknowledgement its venue takes (VenueCatalogue.execution_acknowledgement); list_orders takes execution_state. A connectivity plugin reports a venue's value-date roll (report_value_date_roll), a fee may be financing or commission, and an FX order names its quote_currency. A data plugin records an event contract's outcomes (record_outcomes, Outcome), which prices' readers and operations hear and read (list_outcomes); a daily value on a closed day carries ObservationMeta.closed_day. A plugin whose source states no currency declares meridian.account_currency(). The suites gain v23's cases, their about markers and any_of. Every operation it adds is preview. Nothing a plugin calls changed shape: meridian plugin migrate moves the pins (see 0.26.0 to 0.27.0). It needs a runtime on chart 0.1.303 or later, and CLI 0.1.43's plugin new writes a plugin on it. Released 2026-10-14.

Notice: SDK 0.28.0 changes three stable reads

The one-ID revision changes ResolveIdentifier, ResolveInstrument and ReportMissingInstrument. Every instrument comes to be keyed by its platform INS- ID: a deployment's provisional records stay LCL-, resolved to their INS- record, and a resolved LCL- ID is accepted forever. The open_meridian identifier stops being added to records. Plugins treat IDs as opaque and need no change; a plugin that reads the open_meridian identifier should stop relying on it. A stable row changes only after a version whose notice says it will, and SDK 0.27.0 is that version.

0.26.0

open-meridian 0.26.0 needs chart 0.1.301 or later: the order path and settlement calendars. Each role on the order path does its part through typed operations (propose_order, release_order, create_placement, report_execution and the rest) and hears the book's records (receive(order_updated=..., placement_updated=..., execution_recorded=..., trading_instruction_changed=...)); a refusal by the order's state, a claim or a link is a CommandRefused with its code, and the order's transition table ships as meridian/transitions.json. Every role works out a date in core (read_calendar, compute_date). A version declares the algorithms its autonomous work may name (Declaration(algorithms=[AlgorithmDeclaration(...)])), and runs_as(role, algorithm_id=...) names one; a Caller carries algorithm_id and, for an ems, the connectivity plugins the user may route through (connectivity_instance_ids). A placement carries its own change requests (change_requests). A connectivity plugin reports fills on the ems's placements and records no order and creates no placement. The order path's and the calendars' operations are preview. At the upgrade to chart 0.1.301 the book records that every account it holds waits for compliance, each its own entry, and no connectivity plugin is available or linked, each recorded so. Nothing a plugin calls changed shape: meridian plugin migrate moves the pins (see 0.25.0 to 0.26.0). It needs a runtime on chart 0.1.301 or later, and CLI 0.1.42's plugin new writes a plugin on it. Released 2026-10-13.

0.25.0

A plugin's autonomous work -- a scheduled sweep, a strategy, an agent inside the plugin -- calls its own acts as the service account its role runs as, meridian.runs_as(role), answered made or prepared; a refusal names its field (NotGranted.fields, CallFailed.fields), and a sidecar older than v21 is said to be (CallFailed, kind "unimplemented"). An act's route reached for such a call reads a Caller whose subject_kind is a service account, naming its owner_subject (meridian.SubjectKind), and acts as for a person; meridian.testing calls a tool so (subject_kind=, owner_subject=). Nothing a plugin calls changed shape: meridian plugin migrate moves the pins (see 0.24.0 to 0.25.0). It needs a runtime runtime on chart 0.1.299 or later, and CLI 0.1.41's plugin new writes a plugin on it. Released 2026-10-12.

0.24.0

An act can be prepared rather than made (checked_by=, accounts=, made_on=, request.prepared()); a setting may be each person's own (per_person, plugin.set_own_settings, request.own_settings); a plugin links only to the accounts a deployment admin granted it; a data plugin reads its own datasets' licences and backfills a restart's gap (meridian.roles.data.backfill_restart); the roles dgm, ccm and match are named data, connectivity and confirmation; and each role's common part is meridian.roles, with typed settings (@meridian.settings, meridian.environment(), meridian.sample_data()) and the kit's macros (meridian/kit.html). From 0.23.0 meridian plugin migrate rewrites the old role names; see 0.23.0 to 0.24.0. It needs a runtime on chart 0.1.298 or later, and CLI 0.1.39's plugin new writes a plugin on it. Released 2026-10-11. At the upgrade to chart 0.1.298 the instrument store opens a pulled identifier's window from the start, each record so corrected a version of its own naming each window and the date it replaced; a duplicate local record minted before it, beside the platform's, by an earlier-dated report or a data plugin naming a currency the deployment did not hold, is left for a person to merge on the Instruments page. Each account a plugin had linked is granted to it at the upgrade, recorded as made then, so nothing that worked stops. A deployment's grants and access entries naming an old role name are rewritten once by core at the upgrade. At the upgrade every act is "may make", with the second pair of eyes off, which is what every delegation could do before, each recorded as set then. A secret of 16 characters or more shows its last four characters in its placeholder once it is set again after the upgrade.

0.23.0

open-meridian 0.23.0 adds the lake's trades and quotes: a data plugin records them in batches, signal and ems read and hear them, every trade in full and caught up after a watermark; reporting resolves an identifier; the suites' 1b cases and a case about a kind of data a plugin never publishes marked not presented. It needs a runtime on chart 0.1.294 or later, which refuses nothing a plugin on 0.22.0 sends. Released 2026-10-10. See Trades and quotes.

0.22.0

open-meridian 0.22.0 adds the lake: a data's catalogue, prices and bars recorded in batches, wants, the readers, a Money naming its instrument and real dates. It needs a runtime on chart 0.1.292 or later, which refuses nothing a plugin on 0.21.0 sends. See Plugin manifest. A plugin built before it keeps sending the currency code it always sent, and core resolves it to the currency's cash instrument. At the upgrade to chart 0.1.292 the street and the book resolve every currency code they hold to its cash instrument, once, each resolution its own record saying it was filled in then, not when the amount was first recorded; no entry or record is rewritten, and a code the instrument store does not answer for yet is resolved by a later sweep.

0.21.0

open-meridian 0.21.0 adds the archive: a plugin at the edge declares the kinds of raw record it keeps, and past each kind's window archives, keeps or deletes them as its admin chose, each move reported. It needs a runtime on chart 0.1.277 or later, and CLI 0.1.36's plugin new writes a plugin on it; CLI 0.1.36's meridian up asks where older records go (--archive, --no-archive). A plugin declaring no kinds keeps one retention, as before. SnapTrade 0.13.0, the first plugin on it, drops 0.12.0's Keep raw responses for (raw_retention_days) and Keep activity records for (activity_retention_days) and carries neither value over: its windows, Raw responses: window (responses_window_days, 30 days by default) and Reported activity: window (activity_window_days, 2,555 days), take their place, set once at upgrade. Upgrade the deployment first, note what the two old settings hold, launch 0.13.0 in place, then set the two windows and what is done past each. What 0.12.0 deleted is not brought back; on its first pass 0.13.0 moves 0.12.0's records into units, keeping their times.

0.20.0

open-meridian 0.20.0 adds a person's level is granted per role of a plugin (see Access per role), and a page, route, tool and setting names the roles it serves; a custody plugin re-resolves an activity once its instrument resolves. It needs a runtime on chart 0.1.268 or later, and CLI 0.1.35's plugin new writes a plugin on it. A runtime serving v14 or earlier grants each plugin as a whole. The upgrade to chart 0.1.268 rewrites every grant once, changing what nobody can do: every access entry on a plugin holding exactly one role is rewritten to name it, at the level it had, in the group it was in (a plugin's roles being what its sidecar last reported, or its latest launch); an entry on a plugin holding no role is left naming none; user groups, account groups, permissions and links are untouched, and All plugins (admin) grants admin on every role. A delegation covering everything is untouched; each row of a narrowed one is rewritten to name the plugin's one role, and a row the rewrite cannot name is kept, covers nothing, and is flagged on Connected clients. Each group the upgrade rewrote is recorded, made by no person. A runtime at chart 0.1.268 keeps no session for a terminal and serves CLI 0.1.25 or later, which holds a delegation in place of a session.

0.19.0

open-meridian 0.19.0 needs a newer runtime: the custodian's activity, reported by a custody plugin, and read, heard and linked by operations, with each sync status the street keeps; and a setting may be a table of typed columns. It needs a runtime on chart 0.1.262 or later, and CLI 0.1.34's plugin new writes a plugin on it. Released 2026-10-05. Every settings change is its own record. At the upgrade the deployment redacts the records of every setting already secret; a change made before it, recorded with who and when and without the value, is filled in from the value the setting still holds where its latest change set it, and says so; and each setting changed before the upgrade gets one more record saying the earlier values are not known before then.

The plugin UI kit, by version

The kit pages link, at /.meridian/ui/<version>/, served by the deployment. A 0.x release only adds, and the dashboard serves the newest 0.x it carries to any 0.x request (see Build a plugin's page).

Version What it added
0.1.0 The brand's tokens and colour schemes, the platform's CSS classes, and om-grid (with its high-rate mode), om-chart, om-asof, om-instrument-picker, om-live and om-panels
0.2.0 The seamless frame: a page the dashboard frames drops its own heading and tab row, and the frame is sized to the page. See Inside the dashboard's frame.
0.3.0 om-account-map; om-moment; the grid's declared JSON, rich cells and narrow layouts; list rows that wrap; option and field-row styles, and a select as tall as an input
0.4.0 Header actions: a framed page's head buttons, drawn by the dashboard in its own header. See Header actions.
0.5.0 om-account-map at scale: a dense table with search, filters, grouping, pages, a chooser found by typing, suggestions, and several links in one form (link-several). See At thousands of accounts.
0.6.0 om-status, a status dot with its note on hover, focus or a tap; and in om-account-map, each account's optional status and values, a Status column and a filter by state
0.7.0 Header status: a framed page's head om-status marked data-om-header, drawn by the dashboard beside the plugin's name. See Header status.
0.8.0 Icon actions: a button marked data-om-icon="refresh" is drawn as a circular arrow, in the page and as a header action in the dashboard's header. See Icon actions.
0.9.0 om-entry-grid: a table of typed inputs a person enters rows in, posted with the page's own form, the server's messages placed on their cells, a spreadsheet's paste and a CSV in a dialog
0.10.0 Every page fits one screen: a height budget the dashboard holds to the screen, compact chrome, one-line rows (table.one-line, om-grid's one-line) with the whole row a click away, om-pager, as many rows a page as fit, and the overflow check
0.11.0 A search beside the pager, search on om-grid, om-pager and om-entry-grid, search-param on om-pager, and the om-search event; and fields gated on a choice, data-om-applies-when and data-om-one-of. See To search a list, and show a field only when it applies. Served from chart 0.1.293.
0.12.0 An instrument column on om-entry-grid with its src, found as you type; the patterns Facts, Next step and Prepare; and meridian:open with data-om-opens, a link from a framed page to one of the dashboard's own tabs. See Role pages, instruments and Prepare. Served from chart 0.1.298.
0.13.0 om-sidebar, a grouped left sidebar of plain links, its groups given as data, collapsing to icons on a desktop and a drawer on a phone, with .om-shell and .om-shell-main and the kit's stroked icons (lib/icons.js). The dashboard's settings are drawn with it. See A site's sections: om-sidebar. Served from chart 0.1.309.

Moving a plugin to a new release

Move a plugin one release at a time or several at once: meridian plugin migrate runs every step between the plugin's pin and the target, in order, and its table of what each step does lists every step from 0.5.0. The recent ones:

From To Contract Runtime plugin new writes it from plugin migrate rewrites Then by hand
0.35.5 0.36.0 30 chart 0.1.538 none: CLI 0.1.60 writes 0.35.0 a dataset's licence_default=DatasetLicence(...) into terms_and_pricing="..."; a base pinned by digest, moved with its digest dropped see 0.35.5 to 0.36.0
0.35.4 0.35.5 29 chart 0.1.408 none: CLI 0.1.59 writes 0.35.0 nothing: only the pins move an FX week of the plugin's own replaced by helpers.sessions.FX_WEEK
0.35.3 0.35.4 29 chart 0.1.408 none: CLI 0.1.59 writes 0.35.0 nothing: only the pins move a live module of the plugin's own moved onto pages.live_values and LiveValue.of, its page script onto <om-live cells>; a book view's row is keyed order_id=<id> alone
0.35.2 0.35.3 29 chart 0.1.408 none: CLI 0.1.59 writes 0.35.0 nothing: only the pins move what meridian plugin migrate --to 0.35.3 names in a plugin already on 0.35
0.35.1 0.35.2 29 chart 0.1.408 none: CLI 0.1.59 writes 0.35.0 nothing: only the pins move a Declaration(settings=Config.declared()) names the class again, settings=Config
0.35.0 0.35.1 29 chart 0.1.408 none: CLI 0.1.59 writes 0.35.0 nothing: only the pins move an act on the connection made on the role object holding it
0.34.0 0.35.0 29 chart 0.1.408 CLI 0.1.55 to 0.1.59 receive calls into role classes' on_<event> methods, the SDK's renamed methods, self.plugin.<act> onto self see 0.34.0 to 0.35.0
0.33.5 0.34.0 29 chart 0.1.408 none every import of meridian.roles.chains, .packages, .lifecycle or .debt onto meridian.helpers see 0.33.5 to 0.34.0
0.33.1 0.33.5 28 chart 0.1.334 or later for subscriptions CLI 0.1.54 writes 0.33.3 nothing: only the pins move nothing
0.33.0 0.33.1 28 chart 0.1.313 or later none: CLI 0.1.53 writes 0.33.0 nothing: only the pins move a venue keeping bars declares bars, names BARS_DECLARED in its venue_cases and drops a-daily-bar from NOT_PRESENTED; a plugin's own feed-delay producer standing on the quote can be removed
0.32.3 0.33.0 28 chart 0.1.313 or later CLI 0.1.53 a wire SubjectRef handed to the SDK, into meridian.SubjectRef; a base pinned by digest, moved with its digest dropped where it cannot be read see 0.32.3 to 0.33.0
0.32.2 0.32.3 27 chart 0.1.309 or later; 0.1.311 for the licence's attribution terms none: CLI 0.1.52 writes 0.32.1 nothing: only the pins move give every rate a convention: see 0.32.2 to 0.32.3
0.32.1 0.32.2 27 chart 0.1.309 or later; 0.1.311 for the licence's attribution terms none: CLI 0.1.52 writes 0.32.1 nothing: only the pins move a rates plugin recording no liveness names one more case not presented: see 0.32.1 to 0.32.2
0.32.0 0.32.1 27 chart 0.1.309 or later CLI 0.1.52 nothing: only the pins move a rates-only data plugin names two more cases not presented: see 0.32.0 to 0.32.1
0.31.1 0.32.0 27 chart 0.1.309 or later CLI 0.1.51 a base pinned by digest, moved with its digest dropped where it cannot be read see 0.31.1 to 0.32.0
0.31.0 0.31.1 26 chart 0.1.308 or later CLI 0.1.50 nothing: only the pins move nothing
0.30.0 0.31.0 26 chart 0.1.308 or later CLI 0.1.49 a base pinned by digest, moved with its digest dropped where it cannot be read see 0.30.0 to 0.31.0
0.29.0 0.30.0 25 chart 0.1.307 or later CLI 0.1.47 a module copied from a sibling plugin, and a trading layer in SnapTrade 0.16.0's shape, into re-exports of the SDK's helpers see 0.29.0 to 0.30.0
0.28.0 0.29.0 25 chart 0.1.306 or later CLI 0.1.46 a connectivity plugin's read of the escape hatch, and its test's mapping of the dropped case see 0.28.0 to 0.29.0
0.27.0 0.28.0 24 chart 0.1.305 or later CLI 0.1.45 a test of the LCL- prefix; a read of the open_meridian identifier; the suites' LCL-suite-n fixtures see 0.27.0 to 0.28.0
0.26.0 0.27.0 23 chart 0.1.303 or later CLI 0.1.43 nothing: only the pins move see 0.26.0 to 0.27.0
0.25.0 0.26.0 22 chart 0.1.301 or later CLI 0.1.42 nothing: only the pins move see 0.25.0 to 0.26.0
0.24.0 0.25.0 21 chart 0.1.299 or later CLI 0.1.41 nothing: only the pins move see 0.24.0 to 0.25.0
0.23.0 0.24.0 20 chart 0.1.298 or later CLI 0.1.39 the renamed roles: dgm to data, ccm to connectivity, match to confirmation see 0.23.0 to 0.24.0
0.22.0 0.23.0 19 chart 0.1.294 or later CLI 0.1.38 nothing: only the pins move a data plugin's suite test meets the data suite's trades and quotes cases
0.21.0 0.22.0 18 chart 0.1.292 or later CLI 0.1.37 nothing: only the pins move see 0.21.0 to 0.22.0
0.20.0 0.21.0 16 CLI 0.1.36 nothing: only the pins move see 0.20.0 to 0.21.0
0.19.0 0.20.0 15 CLI 0.1.35 nothing: only the pins move see 0.19.0 to 0.20.0
0.18.0 0.19.0 14 CLI 0.1.34 nothing: only the pins move see 0.18.0 to 0.19.0

Before 0.19.0, plugin new pinned 0.18.0 from CLI 0.1.33, 0.17.0 from CLI 0.1.29 to 0.1.32, 0.16.0 from CLI 0.1.28, 0.15.0 from CLI 0.1.27, 0.14.0 from CLI 0.1.26, 0.12.0 from CLI 0.1.24 and 0.1.25, 0.11.0 from CLI 0.1.23, 0.10.1 from CLI 0.1.22, 0.10.0 from CLI 0.1.21, 0.9.0 from CLI 0.1.20, 0.7.1 from CLI 0.1.18 and 0.1.19, and 0.6.1 from CLI 0.1.16 and 0.1.17.

0.35.5 to 0.36.0

The to_0_36_0 step. Breaking: a new interface version, and a dataset's catalogue entry declares no licence. meridian plugin migrate --to 0.36.0 moves the pins and rewrites:

  • A dataset's licence into words (licence-default-described): each licence_default=DatasetLicence(...) whose arguments are literals becomes terms_and_pricing="...", the terms it declared in words, part by part. A licence shared through a module-level name keeps its name, which then holds the words. DatasetLicence and DataEligibility are dropped from a from meridian import ... where nothing else names them.

    # before
    licence_default=DatasetLicence(kept=True, display=True, personal_use=True)
    # after
    terms_and_pricing="Kept by the lake; display allowed; one person's terms."
    
  • The base image (base-digest-dropped): a base pinned by digest at an older release is moved to 0.36.0 with its digest dropped, since the migration runs with no network.

Then, by hand where they apply:

  • a licence the rule could not read (an argument that is not a literal, a dataset built from a mapping, terms past 2,000 characters in words), or DatasetLicence, DataEligibility or .licence_default still named, such as in a test (licence-default-left): write the vendor's terms in terms_and_pricing, read them back as .terms_and_pricing;
  • a DerivedSeries or CorporateActionAnnounced of the SDK's given the wire's MeasureRef as its rule (measure-ref-sdk-form): build it as meridian.MeasureRef(definition=..., version=...);
  • a suite test of a role that gained cases (suite-cases-v30): holds-a-standing-figure in the reporting, portfolio, compliance and signal suites, and reads-the-source-priorities in those of the eight roles that read the lake. Map each, or name it not presented, with why;
  • the plugin's e2e images, pinned to a core of this release by digest (core-images), and the base's digest pinned again (base-digest).

Then, where it helps: give each dataset its gap_recovery, an FX or other session-bound dataset its sessions_calendar_id, and a vendor allowing more than one connection per key its most_shards.

0.34.0 to 0.35.0

The to_0_35_0 step. Breaking, with no contract change: each role is one class. meridian plugin migrate --to 0.35.0 moves the pins and rewrites:

  • A receive call whose handlers are one class's methods on one declared role's events: that class's on_<event> methods, the class given the role's base, seed=False its seed = False, the connection it took dropped; the SDK's own handlers dropped from a receive.
  • The SDK's role logic: a Trader's, a feed's and a Pricer's renamed methods and arguments, the connection given them dropped (a recorder's call recorder.bind(...)).
  • An act on the connection in a role class, self.plugin.<act>, onto self; a helper given self.plugin given self; an unchanged template entry point replaced by meridian.run.
  • A listings feed's own edge glue: its at_the_edge and the _record override calling it deleted, and its change copies made data.SentRows.

Then, by hand where they apply:

  • an entry point connecting by hand, moved to meridian.run;
  • an act on the connection, made on the role object holding it;
  • a receive no rewrite reached;
  • a role class lacking a required event;
  • a suite test hearing through recorder.receive, or giving a class a stand-in connection: recorder.hear(recorder.bind(...));
  • the SDK's helpers adopted where the plugin keeps its own: a stream supervisor onto helpers.sessions.Supervisor, a chains machine onto data.option_chains.Feed, a calendar reader onto helpers.calendars, a polling loop onto data.Timetable, a raw store onto meridian.edge.RawStore.

0.33.5 to 0.34.0

The to_0_34_0 step. Breaking: a new interface version; and four modules moved. meridian plugin migrate --to 0.34.0 moves the pins and rewrites every import and name of meridian.roles.chains, .packages, .lifecycle or .debt onto meridian.helpers: import meridian.roles.chains, from meridian.roles import chains, from meridian.roles.chains import X, from meridian.roles import Latency, chains split in two, meridian.roles.debt.factor(...), and a patch target's string. A base pinned by digest is moved with its digest dropped.

Then, by hand where they apply:

  • map each role suite's v29 cases, the data suite's records-a-pools-factor and every reader's reads-a-pools-factors and hears-a-factor-recorded, or name those about what the plugin never presents in not_presented, with why;
  • name a QualityAssessment's data_type;
  • an old path still named in a comment, a dynamic import or read through from meridian.roles import *;
  • pin the base's digest again.

0.32.3 to 0.33.0

The to_0_33_0 step. Breaking: a new interface version; the typed operations take meridian.SubjectRef only, and every role's suite holds new cases, so a plugin running its role's suite is not verified until its test maps them. meridian plugin migrate --to 0.33.0 moves the pins and rewrites:

  • A wire SubjectRef handed to the SDK (subject-ref-from-the-sdk): ops.SubjectRef(...), operations_pb2.SubjectRef(...), or one imported from meridian.plugin.v1.operations_pb2, into meridian.SubjectRef(...). One built inside another wire message, or added to a wire message's field, stays the wire's.
  • A base pinned by digest (base-digest-dropped): moved to 0.33.0 with its digest dropped, since the migration runs with no network.

Then, by hand where they apply:

  • Subjects from the wire (subjects-from-the-wire): a typed operation, or one of the SDK's rows, given a heard message's subjects (subjects=want.subjects). Convert each: [meridian.SubjectRef(entity_id=s.entity_id) for s in want.subjects], keeping role=s.role where the row states one.
  • The suites' v28 cases (suite-cases-v28): map each to the plugin's own producer through the SDK's v28 helpers (see Helpers), or, for a case about what its source or work never presents, name it in not_presented with why (see A case not presented).
  • The latency cases (latency-cases-required): the three a v27 plugin named not presented, "core receives ReportLatency", are required: map each to the plugin's roles.Latency, which now reports. meridian.roles.data.testing.feed_delay, and the connectivity and ems producers in the SDK's testing modules, do it for the SDK's kinds.
  • The base's digest (base-digest): pin it again, ghcr.io/open-meridian/plugin-python:0.33.0@sha256:<digest>.

0.32.2 to 0.32.3

Released 2026-10-24 (the to_0_32_3 step). meridian plugin migrate --to 0.32.3 moves the pins and changes no code. One change can raise: a rate built with rates.Feed.rate and no convention is refused at the plugin, with a clear error, as the lake refuses it; give it the publisher's convention, and an index level the convention of the rate it compounds (the SOFR Index rates.MONEY_MARKET). Nothing else a plugin calls changed shape: a missed rates subject is asked again the same day, a listings feed records its live dataset's liveness, a Resolution may carry challenge_ends_at_ns, DatasetLicence takes not_affiliated_notice, and the merged Connection is tabbed. Set the licence's attribution terms only for a deployment on chart 0.1.311 or later.

0.32.1 to 0.32.2

Released 2026-10-23 (the to_0_32_2 step). Not breaking: meridian plugin migrate --to 0.32.2 moves the pins and changes no code. DatasetLicence takes redistribution, attribution_required, attribution_text, copyright_class and no_more_restrictive_terms; a deployment before chart 0.1.311 refuses the registration of a declaration setting any of them, so set them only for one on 0.1.311 or later. RATES_NOT_PRESENTED no longer names records-a-datasets-liveness: a rates-only plugin recording no liveness adds it to not_presented itself, with why. A data plugin's Connection drops its empty Sessions until a session is measured, so a page check expecting it on a polled plugin no longer finds it.

0.32.0 to 0.32.1

Released 2026-10-23 (the to_0_32_1 step). Not breaking: nothing a plugin calls changed shape, and meridian plugin migrate --to 0.32.1 moves the pins and changes no code. The data suite's an-exact-price and a-want-recorded-against are now about closes: a data plugin recording rates and no prices names them in not_presented, "it records rates, never prices", most simply by passing meridian.roles.data.testing.RATES_NOT_PRESENTED. A plugin recording prices maps them as before.

0.31.1 to 0.32.0

Released 2026-10-22 (the to_0_32_0 step). Breaking: the SDK needs chart 0.1.309 or later, and every role's suite holds new cases, so a plugin running its role's suite is not verified until its test maps them. Nothing a plugin called is removed. meridian plugin migrate --to 0.32.0 moves the pins; a base pinned by digest moves with its tag, the new digest read from its registry, or with its digest dropped where it cannot be read (base-digest-dropped).

Then, by hand where they apply:

  • The base's digest (base-digest): pin it again, ghcr.io/open-meridian/plugin-python:0.32.0@sha256:<digest>.
  • The suites' v27 cases (suite-cases-v27): map each to the plugin's own producer. The reading roles (reporting, portfolio, compliance, signal) read a curve for a date (rates.read_curve) and hear a rate and a dataset's liveness (receive(rates_recorded=..., liveness_recorded=roles.Liveness().heard)); they, the ems, the oms and operations read an account's execution costs (list_execution_costs); the ems picks a quote on its RFQ placement; the oms sets a block member's allocation instruction; operations reads a trade date's FX close. A case about something the plugin's source never presents (about: rates, streams, rfqs, trades) is named in not_presented with why; meridian.roles.data.testing's PAIRS_NOT_PRESENTED and EVENT_CONTRACTS_NOT_PRESENTED name a kind of venue's.
  • The latency cases: reports-its-feed-delay (data), reports-a-sessions-round-trip (connectivity) and reports-its-pre-trade-checks-data-age (ems) are about: latency. Every plugin holding the role names them in not_presented with the reason "core receives ReportLatency, ruling 515": the SDK records latency and sends nothing until chart 0.1.313, when they are required in full.
  • A data plugin querying the lake without trades (lake-query-without-trades): the data suite's queries-the-lake-within-bounds is about trades. A plugin whose catalogue declares no trades names it not presented, "it records no trades", rather than fake it.
  • A connectivity plugin's own Trading page (trading-page-onto-sdk): call connectivity.trading_pages(...) and raw_records_page(...) in its place, or, holding the data role too, connection_pages(...) (see Each role's common logic).
  • An event-contract plugin's own events loop (events-onto-sdk): run feed.keep_events() in its place, the Market answering event(ref).
  • An e2e workflow's logs (e2e-hidden-files): a workflow uploading the harness's logs from .e2e/ with actions/upload-artifact adds include-hidden-files: true, or a failure keeps no logs.

0.31.0 to 0.31.1

Released 2026-10-19. Not breaking: nothing a plugin calls changed shape, and meridian plugin migrate --to 0.31.1 moves the pins and changes no code. What changes is how a saved setting value this version cannot read arrives: unset, never raised, and listed in Settings.refused, a typed class's .refused and request.own_settings_refused (see Settings). A plugin that renamed a choice can show each refused value's message on its page, asking the person to save the setting again.

0.30.0 to 0.31.0

Released 2026-10-19. Breaking: a new interface version; and every role's suite holds new cases, so a plugin running its role's suite is not verified until its test maps them. Nothing a plugin called is removed. meridian plugin migrate --to 0.31.0 moves the pins; a base pinned by digest moves with its tag, the new digest read from its registry, or with its digest dropped where it cannot be read (base-digest-dropped).

Then, by hand where they apply:

  • The base's digest (base-digest): pin it again, ghcr.io/open-meridian/plugin-python:0.31.0@sha256:<digest>.
  • The suites' v26 cases (suite-cases-v26): map each to the plugin's own producer, or name one about something its source never presents in not_presented, with why. A reading role (reporting, portfolio, compliance, signal) runs a job and reads its answer, catches up finished jobs and queries the lake within bounds; oms, portfolio, compliance and ems read the agents' guard, and the ems meets the guard's five cases; data, ems and operations query the lake within bounds; and the data suite reports an event group and a flag the venue does not state. Every role's hears-and-reads-an-event-group is answered by the SDK unless the plugin maps it.
  • A connectivity adapter's environment (venue-reports-no-environment): give the adapter an environment, read from the host its session called with meridian.reached_environment(...). The catalogue carries it, and a connection with no report counts live, so an agent's placement on it is refused while the agents' guard is on.
  • Custody pages, a listings layer or a trader of the plugin's own (custody-pages-onto-sdk, lake-onto-sdk, trading-onto-sdk): keep only what is the vendor's, and call custody.account_pages, subclass listings.Feed, or construct connectivity.Trader over the adapter (see Each role's common logic).

0.29.0 to 0.30.0

Released 2026-10-18. Not breaking: the interface is unchanged, and a plugin left on 0.29.0 keeps working. meridian plugin migrate --to 0.30.0 moves the pins and moves a plugin's copied middle layer onto the SDK's helpers (see Each role's common logic):

  • A module copied from a sibling plugin (market.py, products.py, contracts.py, raw.py or dgm.py, saying "The same module in ... and ...") becomes a re-export of the SDK's names that replace it, so every import of it still resolves (copied-module-onto-sdk).
  • A trading layer in SnapTrade 0.16.0's shape (trading.py defining Trader, Noted, Venue, Watched and trading_files) becomes a re-export of meridian.roles.connectivity, its Trader the SDK's in the module's market zone and Noted the SDK's Sent (copied-trader-onto-sdk).

Then, by hand where they apply:

  • The re-exports (re-export-left, trader-re-export-left): import the names from the SDK where the plugin uses them and delete the module; construct connectivity.Trader with market_zone=, and use Sent for Noted.
  • Copied pages (copied-page, trading-page-onto-sdk): build the data role's Connection and Datasets with the kind's builders and templates, and the Trading page with trading_read and the SDK's template, extending a template only for the venue's own nuance.
  • Copied tests (copied-tests, trading-rig-onto-sdk): one class of the SDK's checks (PairsChecks or EventContractsChecks), and a subclass of meridian.roles.connectivity.testing.Rig for the trading rig.
  • A copied stream.py (stream-is-the-plugins): keep it as the plugin's own transport and hand it to the feed as its stream factory; a stream given another connection by assigning its connect (stream-connect-assigned): retarget it instead.
  • The Makefile's suite-same (suite-same-retired): remove it, since nothing is copied between plugins any more.
  • A trading layer the migration could not rewrite (trader-by-hand): move it onto meridian.roles.connectivity by hand, keeping only the venue's adapter and its client in the plugin.

0.28.0 to 0.29.0

Released 2026-10-17. Breaking: a plugin registers exactly the declaration its version was uploaded with, so a plugin whose declaration does not carry its interface is refused at registration until it does. meridian plugin migrate --to 0.29.0 moves the pins, and the CI's MERIDIAN_VERSION in .github/workflows/check.yaml, and rewrites, in a plugin holding connectivity and no role that keeps the read (compliance, ems, oms, portfolio):

  • A read of the escape hatch, await plugin.read_local_record_orders() as a statement of its own, is removed (escape-hatch-read-dropped): connectivity's grant of ReadLocalRecordOrders is dropped, since a connectivity plugin proposes and accepts nothing on a record.
  • A test mapping the dropped suite case reads-whether-orders-may-name-local-records is removed (escape-hatch-case-dropped): the connectivity suite no longer has it.

Then, by hand where they apply:

  • connect given an interface, settings or reads_external_accounts (connect-names-the-declaration): name them in the version's declaration, Declaration(settings=Config, interface=meridian.Interface(port=8000, title=..., pages=pages), reads_external_accounts=True, documentation="docs"), with the interface's port fixed rather than read from the environment, in a module importing every module that declares a page or a route, and pass connect(declaration=DECLARATION). See The whole declaration.
  • A [tool.meridian] naming no declaration (declaration-not-named): add declaration = "<module>:DECLARATION", and write docs/overview.md and docs/setup.md (troubleshooting.md and terms.md prompted, pages.md optional); meridian plugin check checks them. See Write a plugin's documentation.
  • A Dockerfile whose last stage copies no documentation into the image, such as one installing only the package from a mount or a stage of its own (docs-not-in-image): add COPY docs/ docs/ under WORKDIR /plugin to its last stage, so the image holds /plugin/docs, which Declaration(documentation="docs") reads where the plugin runs; without it meridian-declaration, and the upload with it, fail. The templates' Dockerfile copies the whole build context (COPY . .), docs/ with it.
  • A connectivity plugin's read of the escape hatch the migration cannot remove alone, its answer used or the read inside another expression (escape-hatch-read-kept): remove the read and what depends on its answer; an order on a provisional record is the oms's and the ems's to weigh.
  • An operations plugin may declare its affirmation deadline, Declaration(affirmation_deadline=21 * 60), and record an unrecognized security's identifiers on its break (unresolved_identifiers), which the book requires on that category.

A plugin left on 0.28.0's pins keeps working against a runtime on chart 0.1.306 or later: it is admitted as before and not held to a declaration it never made, and its Documentation tab shows the Reference from its registration.

0.27.0 to 0.28.0

Released 2026-10-15. meridian plugin migrate --to 0.28.0 moves the pins and rewrites what the one-ID revision changed (see One ID per instrument):

  • A test of the LCL- prefix on a record's instrument ID, such as record.instrument_id.startswith("LCL-"), is rewritten to read the record's provisional (lcl-prefix-provisional). The prefix only says the deployment minted the ID: a resolved LCL- ID stays LCL-.
  • A read of the open_meridian identifier for the platform's ID is rewritten to the record's instrument_id, which is the platform's ID , or the read's default where the record is provisional (open-meridian-instrument-id). The open_meridian identifier is no longer added to records.
  • The suites' LCL-suite-n fixtures become INS-suite-n (suite-fixture-ins); a provisional record's stays LCL-suite-n, answered by a Recorder(provisional=True).

Then, by hand where they apply:

  • An ID tested for the prefix LCL- with no record at hand (lcl-prefix-tested), or the open_meridian identifier read or written another way (open-meridian-read): read the record's provisional and its instrument_id instead.
  • A plugin that keeps instrument IDs in its own storage or state follows each replacement to the record it became: meridian.CurrentIdMap does it from list_replacements and the deliveries. One that keeps none needs nothing. meridian plugin check warns (replaced-ids-followed), failing nothing, on an INS- or LCL- ID kept as a string in the plugin's code outside its tests.
  • A suite test meets v24's cases. Every suite's hears-and-reads-a-replacement and reads-the-template-catalogue the SDK answers for a plugin that maps neither. The rest are booking's and the one-ID model's, mapped by a plugin holding the role: oms's eleven booking cases and reads-whether-orders-may-name-local-records; operations' corrects-a-handed-off-booking, names-an-unsettled-allocation-as-a-breaks-cause, sets-a-clients-allocation-instruction, hears-a-booking-handed-off and hears-and-reads-a-held-bookings-alert; compliance's alert, booking and local-record cases; portfolio's amends-an-allocation-instruction, hears-and-reads-bookings and reads-whether-orders-may-name-local-records; connectivity's and confirmation's reports-a-give-up-rejected; ems's and connectivity's reads-whether-orders-may-name-local-records; custody's records-a-thinly-described-holding-unresolved; and reporting's hears-and-reads-bookings and hears-and-reads-alerts. None carries an about marker, so none may be named in not_presented.
  • A plugin declaring meridian.environment() that was Live by default says environment(default="live") only where Live is meant; otherwise its admin chooses.

A plugin that does none of these keeps working on 0.27.0's pins against a runtime on chart 0.1.305 or later: IDs are opaque, and a resolved LCL- ID is accepted on every write, forever.

0.26.0 to 0.27.0

Released 2026-10-14. meridian plugin migrate --to 0.27.0 moves the pins and rewrites nothing: chart 0.1.303 adds, and changes the shape of nothing a plugin calls. Then, by hand where they apply:

  • A suite test meets v23's cases. connectivity's four (reports-a-fill-on-no-placement, reports-a-later-fill-on-the-accepted-placement, re-reports-a-charge-on-the-accepted-placement, about venue-originated fills, and reports-a-value-date-roll, about value-date rolls), the data suite's three outcome cases (about outcomes and disputed outcomes), and each reader's hears-and-reads-an-outcome (about outcomes) may be named in not_presented, with why, where the plugin's source or work never has them; ems's four and operations' and compliance's new cases are required. Cases that were required may now be marked too: custody's plan-code, sweep-fund, split and currency-not-stated cases, and connectivity's reports-a-bust and reports-a-charge. See A case not presented.
  • A test that skips or expects a suite test to fail fails CLI 0.1.43's role-suite: run the suite, mark what is not presented, and assert report.passed, report.failures in the next statement (see meridian plugin check).
  • A client of dashboard__list_datasets or dashboard__list_source_priorities accepts outcome: "unchanged", which they answer, as well as read.
  • A custody plugin whose source states no currency declares meridian.account_currency() and gives the currency it supplies with its provenance (see Typed settings).

0.25.0 to 0.26.0

Released 2026-10-13. meridian plugin migrate --to 0.26.0 moves the pins and rewrites nothing: chart 0.1.301 adds, and changes the shape of nothing a plugin calls. Then, by hand where they apply:

  • A suite test meets the settlement calendars' cases: every role's suite now holds reads-a-calendar, and every role's but connectivity's and operations' asks-for-a-settlement-date. Map each to a call of read_calendar and compute_date (the role templates' test_suite.py show both), or, where your plugin's work never uses calendars or settlement dates, name it in not_presented, with why: the two are about calendars and settlement dates (see A case not presented). A plugin holding connectivity or oms meets its role's suite, and one holding portfolio, ems, compliance, operations or reporting that role's order path cases.
  • A settlement or value date your plugin works out itself is asked of core instead, with compute_date, and recorded with its basis (see Settlement calendars).
  • Work the plugin does on its own that runs an algorithm declares it, Declaration(algorithms=[AlgorithmDeclaration("twap-v1", "...")]), and names it, meridian.runs_as(role, algorithm_id="twap-v1"), keeping each identifier stable across versions.
  • A plugin that reads positions, lots or movement lines finds a sleeve on each, empty for an account's unsleeved part.

0.24.0 to 0.25.0

meridian plugin migrate --to 0.25.0 moves the pins and rewrites nothing: chart 0.1.299 adds, and changes the shape of nothing a plugin calls. Then, by hand where they apply:

  • Work the plugin does on its own that changes something calls the plugin's own act as its role's service account, meridian.runs_as(role), and reads whether it was made or prepared. Each act called so names its check tool (checked_by=): from CLI 0.1.41 meridian plugin check fails one that names none (runs-as-checked).
  • An act's route may be reached for a service account: it acts as for a person, acting_for=request.caller.header. Show request.caller.subject_kind and owner_subject where the page says who acted.
  • A test that compares a refusal's attributes finds fields on NotGranted and CallFailed too, empty where the sidecar named none.

0.23.0 to 0.24.0

meridian plugin migrate --to 0.24.0 moves the pins and rewrites one thing, the renamed roles, wherever a plugin names a role: its pyproject.toml's [tool.meridian] roles, a roles= argument, the role meridian.suites.run, run_async, suite or names is given, and the role Caller.level_for, read_for or write_for asks; dgm becomes data, ccm connectivity and match confirmation. Nothing else a plugin calls changed shape. The release is marked breaking for the rename alone. Then, by hand where they apply:

  • An act you want preparable names its check tool, accounts and page (checked_by=, accounts=; made_on= is filled for you): see Prepared changes. A verified plugin's act without a check fails meridian plugin check --verified from CLI 0.1.39.
  • A form field named prepare is refused: rename it.
  • A data plugin meets the data suite's three new cases: map a-restarts-gap-backfilled (or, where it records no trades, it is about trades and may be marked not presented) and reads-its-datasets-licences; map the-takers-side-stated or, where the source never states the aggressor, mark it with why. See Write a data plugin.
  • A plugin linking external accounts is answered only the accounts a deployment admin granted it, nothing at first: say so on its page, and expect REFUSAL_REASON_ACCOUNT_NOT_LINKABLE for any other.
  • A plugin of a role with a common part (data, custody, operations, reporting) may move its pages onto meridian.roles, and is held to the role's pages by role-pages under --verified from CLI 0.1.39.
  • synthetic, each plugin's own until now, may become meridian.sample_data() (sample_data), the ruled convention: a plugin chooses when, at its next versioned push.

0.21.0 to 0.22.0

meridian plugin migrate moves only the pins: open-meridian==0.22.0 and plugin-python:0.22.0. A Money naming its currency by its ISO 4217 code keeps its shape, and core resolves the code to the currency's cash instrument; a date sent as valid ISO text is sent as before. Left for you: a test comparing a Money read back with one it made, which now carries instrument_id (compare amount and currency_code), and a date sent as text that is no date, now refused. A plugin built on 0.22.0 needs a runtime of its release or later, and an older runtime refuses it at registration, naming both versions. See Money and instruments.

0.20.0 to 0.21.0

meridian plugin migrate moves only the pins: open-meridian==0.21.0 and plugin-python:0.21.0. Nothing a plugin calls changed, and a plugin keeping one retention, Storage(retention_days=...), keeps its records under it as before. A plugin adopting record kinds drops any setting of its own that held a window, and its release notes name that setting and the window setting it maps to, for the admin to set once at upgrade. A plugin built on 0.21.0 needs a runtime of its release or later, and an older runtime refuses it at registration, naming both versions.

0.19.0 to 0.20.0

meridian plugin migrate moves only the pins: open-meridian==0.20.0 and plugin-python:0.20.0. Nothing a plugin calls changed. A custody plugin that resolves activities by anything a person can change later, such as a plan code's link, adds re_resolve_activity; an operations plugin that reads activities reads re_resolutions beside them. One that does neither needs nothing more (see Report the custodian's activity). A plugin built on 0.20.0 needs a runtime of its release or later, and an older runtime refuses it at registration, naming both versions. 0.20.0 also brings access per role, which changes nothing for a plugin holding one role.

0.18.0 to 0.19.0

meridian plugin migrate moves only the pins: open-meridian==0.19.0 in pyproject.toml, and plugin-python:0.19.0 in the Dockerfile. Nothing a plugin calls changed. What 0.19.0 adds, a plugin adds by hand:

  • A custody plugin sends history_from on its sync status and reports each activity with record_activity, its raw record kept, and maps the custody suite's new cases.
  • An operations plugin may read and hear activity and sync statuses, and link a cause to an activity. One that does none of this needs nothing more.

A plugin built on 0.19.0 needs a runtime of its release or later, and an older runtime refuses it at registration, naming both versions: upgrade the deployment first (Upgrade a deployment), then relaunch the plugin on the new version. A test that stands in for the SDK's private Operations._receive is now handed only the rows given a handler, not every row with None for the rest.