Typed operations¶
A plugin reaches the Open Meridian bus through its typed operations and nothing else. Each operation is one step of a workflow that a plugin role may take. The plugin calls a typed method, and its sidecar does the rest:
- It builds the domain message.
- It stamps the fields only the sidecar knows truthfully.
- It checks the grant.
- It sends the message.
A plugin never names a topic.
The operations are generated, not written by hand. One generator reads the contract's matrix and domain protos and emits three things that must agree:
- the plugin-facing gRPC service,
meridian.plugin.v1.PluginOperations; - the sidecar's side of it;
- the Python methods on
Plugin.
This page lists every operation in SDK 0.6.1. There is no order-routing or execution operation.
Summary¶
| Python method | gRPC rpc | Workflow step | Kind | Role | Returns |
|---|---|---|---|---|---|
report_external_accounts |
ReportExternalAccounts |
W2.8 Report the accounts a connection reaches | event | custody |
Published |
report_sync_status |
ReportSyncStatus |
W2.1 Observe the brokerage sync state | event | custody |
Published |
record_holdings_statement |
RecordHoldingsStatement |
W2.2 Open a holdings statement | command | custody |
RecordHoldingsStatementResult |
record_holding |
RecordHolding |
W2.3 Publish each holding | command | custody |
RecordHoldingResult |
resolve_identifier |
ResolveIdentifier |
W3.1 Resolve an identifier set | query | custody |
ResolveIdentifierResult |
report_missing_instrument |
ReportMissingInstrument |
W3.2 Report that a resolution missed | event | custody |
Published |
read_accounts_for_linking |
ReadAccountsForLinking |
W6.4 Link a plugin's external account | query | custody |
ReadAccountsForLinkingResult |
link_external_account |
LinkExternalAccount |
W6.4 Link a plugin's external account | command | custody |
LinkExternalAccountResult |
Role is the plugin role that publishes the step, from the contract's matrix. A plugin can call an operation only if it holds that role, approved when it was launched. Otherwise the call raises NotGranted. See Plugins, roles and grants and Plugin manifest.
Kind says what comes back:
- An event returns
Published, the message's identifier on the bus. - A command or a query returns the answer of whatever serves it.
W2 is holdings ingestion from a brokerage. It is read-only throughout: nothing in it places an order. W3 is instrument resolution. W6.4 is linking the accounts a source reaches to the firm's own, which a plugin does on its own admin page, for the deployment admin viewing it. The tutorial Record a holdings statement walks through W2 and W6.4.
Conventions¶
Calling¶
Every operation is an async method on the Plugin that meridian.connect() returns, and takes keyword arguments only. The return types are the generated protobuf messages in meridian.plugin.v1.operations_pb2. Each parameter crosses the wire under its own name.
import meridian
async with await meridian.connect() as plugin:
published = await plugin.report_sync_status(
source="snaptrade", external_account_id="acct-1", connection_healthy=True,
)
Numbers and amounts¶
Numbers are Decimal in Python, and exact integers that carry their own scale on the wire
A quantity takes a decimal.Decimal or an int. An amount of currency takes a meridian.Money: an amount and its ISO 4217 currency code, together, so an amount is never separated from its currency.
On the wire a number is its integer, in two 64-bit halves, and the scale it was stated with. Decimal("1.50") crosses as 150 at scale 2 and reads back as 1.50. Nothing on either side is a float, and nothing normalises the scale.
A number is refused rather than rounded, in the plugin's process, before anything is sent:
| Given | Raised |
|---|---|
A float, a bool, or anything but a Decimal or an int |
TypeError: quantity is a Decimal or an int, not float |
NaN or infinity |
ValueError: quantity is not a finite number |
| More than 18 decimal places | ValueError: quantity has 19 decimal places; at most 18 cross the wire, and it is refused rather than rounded |
| More than 38 digits | ValueError: quantity has more than 38 digits; it is refused rather than rounded |
For an amount, anything but a meridian.Money |
TypeError: market_value is a meridian.Money, not Decimal |
A Money whose currency_code is not a str |
TypeError: market_value's currency_code is a str, not NoneType |
The sidecar holds a plugin that builds its params by hand to the same bounds, and refuses one outside them as invalid, naming the field.
Convert what the venue sent to the platform's convention before calling: amounts in the currency's major unit, and quantities in the instrument's own units. A plugin that read cents converts them. Keep what the venue sent, as sent, in the plugin's own logs. A float the venue sent becomes Decimal(repr(value)), its shortest round-trip form, never Decimal(value), its binary expansion.
meridian.as_decimal and meridian.as_money read a number, or an amount, back off the wire exactly as it was stated.
Times and dates¶
A parameter ending _ns is a time in nanoseconds, as an int. time.time_ns() gives one. as_of_date is an ISO 8601 date string, such as "2026-09-25".
Note
The contract names the unit of _ns fields but not their epoch. The examples on this page assume the Unix epoch, which is what time.time_ns() returns.
Defaults¶
Every parameter except quantity has a default in Python: the empty string, 0, False, an empty sequence, or None. A default is what the wire carries when the field is unset. It does not mean the operation succeeds without the parameter. The Required column below says which parameters are needed, and who refuses a call without them.
What the sidecar sets¶
Some fields describe the publisher rather than the event, and only the sidecar knows them truthfully. They are not parameters:
| Field | On | Set by the sidecar from |
|---|---|---|
account_id |
RecordHolding |
The link a deployment admin made from the plugin's external_account_id to an account. Refused when there is none. |
account_id |
ReportSyncStatus |
The same link, or empty when there is none. Never refused. |
publisher_instance_id |
ReportMissingInstrument |
The instance the plugin was launched as. |
placeholder_instrument_id |
ReportMissingInstrument |
Nothing: always empty from a plugin. Only the instrument store sets it. |
plugin_instance_id |
LinkExternalAccount |
The instance the plugin was launched as. |
The sidecar also puts the plugin's own instance in place of {instance} in a topic, so a plugin can speak only as itself.
Acting for a person¶
Four operations take acting_for: the two commands that record holdings, and the two that link accounts. Pass the Meridian-Caller header of the page request you are serving; with CallerMiddleware that is request.state.caller.header.
For record_holdings_statement and record_holding, it is optional:
- Unset: the plugin acts as itself.
- Set: the sidecar checks the assertion, admits the command only when that person may write the account it names, and stamps the person on it. For a command that names no account, the person must be able to write something through the plugin.
A person narrows what a plugin may do and never widens it.
For read_accounts_for_linking and link_external_account, it is required. They touch the deployment's configuration, which a plugin reaches only acting for a deployment admin, and never as itself. Without an assertion, or with one for somebody who is not a deployment admin, the sidecar refuses the call with NotGranted.
Errors¶
A refusal is the call's gRPC status, chosen by what the caller should do about it. The SDK raises it as one of its own exceptions:
| Raised | Sidecar status | Means | Your next move |
|---|---|---|---|
NotGranted |
PERMISSION_DENIED |
None of the plugin's roles grants this operation; or the account is outside the plugin's write scope; or the person in acting_for may not write it; or an operation on the deployment's configuration without a deployment admin's assertion; or a link for an external account this plugin did not report. |
Stop. It is configuration: a role, a permission, a link or an admin, which a person changes. |
CallFailed, kind="refused" |
FAILED_PRECONDITION |
An external_account_id nobody has linked to an account; or the sidecar considers the plugin not registered, or already left. |
Report it. A deployment admin links the account, and the next statement records it. |
CallFailed, kind="invalid" |
INVALID_ARGUMENT |
A required field is empty, such as external_account_id; or a number outside what the wire carries, in params built by hand. |
Fix the call. |
CallFailed, kind="no handler" |
UNAVAILABLE |
Nothing serves the topic right now. | Retry later. |
CallFailed, kind="timeout" |
DEADLINE_EXCEEDED |
What serves it did not answer in time. | Retry. |
CallFailed, kind="handler error" |
ABORTED |
What serves it answered with a refusal. detail is its reason. |
Report it. |
CallFailed, kind="not vouched for" |
UNAUTHENTICATED |
The acting_for assertion was not accepted: expired, replayed, for another instance, or the sidecar holds none of the dashboard's keys. |
Ask the person to reload the page. |
NotRegistered |
none (raised by the SDK) | The plugin has called leave(). |
Don't use it after leaving. |
TypeError, ValueError |
none (raised by the SDK) | A number that is not a Decimal or an int, an amount that is not a Money, or a number that would have to be rounded. |
Fix the call. |
grpc.aio.AioRpcError |
any other, such as INTERNAL |
Raised unchanged. |
On NotGranted and CallFailed, topic holds the operation's name, such as "RecordHolding". The exception's text is the operation's name, then the kind, then the sidecar's own words. For a row naming an external account nobody has linked:
RecordHolding: refused: external account acct-1 is not linked to an account; a deployment admin links it on the plugin's admin page (W6.4), and the next statement records it
On a development deployment, a refusal by the sidecar itself, for a grant, the write scope, the deployment's configuration, an external account the plugin did not report, or a number out of range, also appears as a refused event.
Note
NotGranted and CallFailed use an attribute called topic, but for typed operations it holds the operation's name, not a bus topic.
report_external_accounts¶
Reports every external account the plugin's connection reaches, as the source presents them. A custody plugin sends it before it records anything, and again whenever the list changes. One connection can reach several accounts, and a deployment admin can link only an account the plugin reported.
It is the whole list each time. An account missing from it is one the connection no longer reaches.
async def report_external_accounts(
self, *, accounts: Sequence[ExternalAccount] = (),
) -> Published
| gRPC | rpc ReportExternalAccounts(ReportExternalAccountsParams) returns (Published) |
| Workflow step | W2.8, Report the accounts a connection reaches |
| Kind | event, on platform.custody.{instance}.event.external-accounts |
| Role | custody |
| Heard by | the dashboard, which counts the unlinked ones on the plugin's health |
| Name | Type | Required | Meaning |
|---|---|---|---|
accounts |
sequence of ExternalAccount |
no | Every account the connection reaches. |
The sidecar keeps the list as the accounts a link from this plugin may name.
Returns Published, with message_id, the message's identifier on the bus.
Errors: NotGranted without the custody role.
import meridian
await plugin.report_external_accounts(
accounts=[
meridian.ExternalAccount(
external_account_id="acct-1", name="Individual brokerage", venue_account_type="Individual"
),
meridian.ExternalAccount(
external_account_id="acct-2", name="Rollover IRA", venue_account_type="IRA"
),
],
)
report_sync_status¶
Reports how fresh a connected account's data is, as the rail (the brokerage aggregator) reports it, and why when it is not current. It is published so an operator can tell stale data from absent data, and knows whose fix it is. Those look identical on a holdings screen and mean different things.
async def report_sync_status(
self, *, source: str = "", last_synced_at_ns: int = 0, connection_healthy: bool = False,
status_detail: str = "", observed_at_ns: int = 0, external_account_id: str = "",
state: SyncState | None = None, holdings_as_of_ns: int = 0, history_as_of_ns: int = 0,
) -> Published
| gRPC | rpc ReportSyncStatus(ReportSyncStatusParams) returns (Published) |
| Workflow step | W2.1, Observe the brokerage sync state |
| Kind | event, on platform.custody.{instance}.event.sync-status |
| Role | custody |
| Heard by | the dashboard, which shows it on the plugin's overview in its admin view |
| Name | Type | Required | Meaning |
|---|---|---|---|
source |
str |
no | The rail's namespace, such as "snaptrade". |
last_synced_at_ns |
int |
no | When the rail last synced this account from the institution. Not when the data is as of: see the two _as_of_ns fields. |
connection_healthy |
bool |
no | Whether the rail considers the connection healthy now. False with a recent last_synced_at_ns means the data is good but the connection has since broken. state says why. |
status_detail |
str |
no | The rail's text, for whatever state does not say. Diagnostic only; nothing branches on it. |
observed_at_ns |
int |
no | When the plugin observed this. |
external_account_id |
str |
yes, by the sidecar | The account as the rail knows it. The sidecar refuses it when empty (invalid). It is not refused when unlinked: the dashboard shows the status beside the unlinked account, so an admin can tell whether it is worth linking. |
state |
SyncState or None |
no | Whether the data is current, and if not, why. None leaves it unset, which reads as SYNC_STATE_UNSPECIFIED, shown as what connection_healthy says. |
holdings_as_of_ns |
int |
no | When the holdings the rail serves are as of. 0 where the rail does not say. |
history_as_of_ns |
int |
no | When the history (transactions) is as of. Apart from holdings, because a connection can have one current and the other not. |
Returns Published, with message_id, the message's identifier on the bus.
Errors: invalid for an empty external_account_id; NotGranted without the custody role.
import time
import meridian
await plugin.report_sync_status(
source="snaptrade",
external_account_id="acct-1",
connection_healthy=True,
state=meridian.SyncState.SYNC_STATE_CURRENT,
last_synced_at_ns=last_sync_ns,
holdings_as_of_ns=last_sync_ns,
observed_at_ns=time.time_ns(),
)
record_holdings_statement¶
Opens one statement: the plugin's snapshot of one account, at one moment. Every holding row then attaches to it.
It carries two dates, and they are not the same thing:
as_of_dateis the date the positions reflect.read_at_nsis when the plugin fetched them.
A statement read this morning may be as of yesterday's close.
async def record_holdings_statement(
self, *, source: str = "", external_statement_id: str = "", as_of_date: str = "",
read_at_ns: int = 0, expected_rows: int = 0, buying_power: Money | None = None,
margin_requirement: Money | None = None, maintenance_excess: Money | None = None,
currency_assumed: bool = False, acting_for: str | None = None,
) -> RecordHoldingsStatementResult
| gRPC | rpc RecordHoldingsStatement(RecordHoldingsStatementParams) returns (RecordHoldingsStatementResult) |
| Workflow step | W2.2, Open a holdings statement |
| Kind | command, on platform.street.command.record-statement |
| Role | custody |
| Served by | the street store: what custodians say is held |
| Name | Type | Required | Meaning |
|---|---|---|---|
source |
str |
no | The rail's namespace, such as "snaptrade". |
external_statement_id |
str |
no | The plugin's identifier for this snapshot, made from the account it read and when it read it, so the same read sent twice has the same one. Used to recognise a redelivery of the same statement. |
as_of_date |
str |
no | The ISO 8601 date the positions are as of. |
read_at_ns |
int |
no | When the plugin read them. |
expected_rows |
int |
yes, by the workflow | How many holding rows will follow. It is the only thing that marks the end of a statement. The street store closes the statement when this many rows have landed. A statement whose rows never all arrive stays open rather than publishing counts that are wrong. Hold the whole list before recording any of it. |
buying_power |
Money or None |
no | The account's buying power, as the venue reported it. None where it reported none. Never derived from the holdings. |
margin_requirement |
Money or None |
no | The margin requirement, as the venue reported it. |
maintenance_excess |
Money or None |
no | The maintenance excess, as the venue reported it. |
currency_assumed |
bool |
no | True when the venue stated no currency for these figures, and the one given is the plugin's own assumption. |
acting_for |
str or None |
no | The Meridian-Caller header of the person this is sent for. See Acting for a person. |
Returns RecordHoldingsStatementResult:
| Field | Type | Meaning |
|---|---|---|
statement_id |
str |
Assigned by the deployment. Every row references it. |
already_recorded |
bool |
True when this statement had already been recorded and the existing one is returned. Redelivery is a no-op, not a duplicate. |
Errors: TypeError or ValueError for an amount. no handler, timeout or handler error from the street store. With acting_for: not vouched for, or NotGranted when the person may write nothing through the plugin. NotGranted without the custody role.
import time
statement = await plugin.record_holdings_statement(
source="snaptrade",
external_statement_id="acct-1@2026-09-25T13:30:00Z",
as_of_date="2026-09-25",
read_at_ns=time.time_ns(),
expected_rows=len(rows),
)
record_holding¶
Records one holding, for one account, at one instrument, on one side, against an open statement.
Set exactly one of instrument_id and unresolved_identifiers:
instrument_idwhenresolve_identifierfound it: an instrument, or the deployment's placeholder for an identifier set nothing matched;unresolved_identifierswhen it did not, because more than one instrument matched.
A row that could not be resolved is still recorded. A dropped holding would be invisible.
Cash is a holding like any other: of the currency's cash instrument, which the identifier scheme iso4217 names (scheme="iso4217", value="USD"), with the cash the venue reports in that currency as its quantity.
async def record_holding(
self, *, statement_id: str = "", instrument_id: str = "",
unresolved_identifiers: Sequence[Identifier] = (), quantity: Decimal | int,
market_value: Money | None = None, external_account_id: str = "",
side: HoldingSide | None = None, settle_date_quantity: Decimal | int | None = None,
currency_assumed: bool = False, also_counted_in_cash: bool = False,
acting_for: str | None = None,
) -> RecordHoldingResult
| gRPC | rpc RecordHolding(RecordHoldingParams) returns (RecordHoldingResult) |
| Workflow step | W2.3, Publish each holding |
| Kind | command, on platform.street.command.record-holding |
| Role | custody |
| Served by | the street store |
| Name | Type | Required | Meaning |
|---|---|---|---|
statement_id |
str |
yes, by the street store | The statement_id that record_holdings_statement returned. |
instrument_id |
str |
one of these two, by the street store | The instrument, or the deployment's placeholder, when resolution found one. |
unresolved_identifiers |
sequence of Identifier |
one of these two, by the street store | Everything the plugin held, when resolution was ambiguous, so an operator can see exactly what could not be accounted for. |
quantity |
Decimal or int |
yes, by Python | The trade-date quantity: what is held counting every trade executed, settled or not. Signed to match side: negative is short. At most 18 decimal places. |
market_value |
Money or None |
no | The rail's valuation of the holding, in its currency, recorded as reported and not recomputed. None where the venue reported none, which is not a value of zero. |
external_account_id |
str |
yes, by the sidecar | The account as the rail knows it. The sidecar translates it through its link, and refuses it when empty or not linked. |
side |
HoldingSide or None |
yes, by the street store | Long or short, stated rather than read off the sign. None leaves it unset, and the street store refuses the row. A venue that reports an account's long and short of one instrument apart sends two rows, one on each side. |
settle_date_quantity |
Decimal, int or None |
no | The settle-date quantity: what is held counting only settled trades, where the venue reports it. For cash, the settled cash. |
currency_assumed |
bool |
no | True when the venue stated no currency, and the one given is the plugin's own assumption: the market value's currency, and for cash the currency whose cash instrument the row names. |
also_counted_in_cash |
bool |
no | True when this position's value is also included in the account's cash holding as the venue reports it, as SnapTrade does with a money-market fund. The street store keeps both as reported. |
acting_for |
str or None |
no | The Meridian-Caller header of the person this is sent for. |
Returns RecordHoldingResult:
| Field | Type | Meaning |
|---|---|---|
holding_id |
str |
The recorded row. |
resolved |
bool |
True when the row carried an instrument and updated a custodial position. False for an unresolved row, which updates nothing until the deployment knows what it holds. |
Errors:
TypeErrororValueErrorfor a number or an amount.invalidfor an emptyexternal_account_id, andrefusedfor one not linked. The sidecar counts the unlinked account in its report of the plugin, and the next statement after it is linked records it.NotGrantedwhen the linked account is outside the plugin's write scope, as a closed account is, or when the person inacting_formay not write it.not vouched forfor anacting_forthat is not accepted.handler errorfrom the street store for a row it refuses, with its reason. It refuses a row that states no side, a quantity whose sign contradicts its side, both or neither ofinstrument_idandunresolved_identifiers, or a statement it has not opened.no handlerortimeoutfrom the street store.
from decimal import Decimal
import meridian
result = await plugin.record_holding(
statement_id=statement.statement_id,
instrument_id=instrument_id,
side=meridian.HoldingSide.HOLDING_SIDE_LONG,
quantity=Decimal("150"),
market_value=meridian.Money(Decimal("34218.75"), "USD"),
external_account_id="acct-1",
)
# An instrument that resolved ambiguously is still recorded. A short row's
# quantity is negative, and a venue that reported no value sends none:
await plugin.record_holding(
statement_id=statement.statement_id,
unresolved_identifiers=[meridian.Identifier(scheme="symbol", value="XYZQ", source="snaptrade")],
side=meridian.HoldingSide.HOLDING_SIDE_SHORT,
quantity=Decimal("-10"),
external_account_id="acct-1",
)
resolve_identifier¶
Reverse resolution: asks which instrument a set of identifiers maps to, as of a date. The strongest identifier is matched first: a global identifier, then a symbol qualified by venue and currency.
There are two outcomes other than a match:
- Nothing matched. The instrument store answers the deployment's placeholder for the set, an
LCL-identifier, minting it the first time the set is asked about.foundisTrueandplaceholderisTrue. Record the holding against it: the platform'sINS-identifier replaces it later, and the instrument store reports the miss itself. - More than one matched. The result is a miss rather than a pick:
foundisFalseandmiss_reasonisMISS_REASON_AMBIGUOUS. Record the holding withunresolved_identifiers, and report the miss.
async def resolve_identifier(
self, *, identifiers: Sequence[Identifier] = (), as_of_ns: int = 0,
exchange_mic: str = "", currency: str = "",
) -> ResolveIdentifierResult
| gRPC | rpc ResolveIdentifier(ResolveIdentifierParams) returns (ResolveIdentifierResult) |
| Workflow step | W3.1, Resolve an identifier set |
| Kind | query, on platform.reference.query.resolve-identifier |
| Role | custody |
| Served by | the instrument store, the deployment's replica of the security master |
| Name | Type | Required | Meaning |
|---|---|---|---|
identifiers |
sequence of Identifier |
yes, by the contract | At least one. Matched strongest first. An empty set is a miss, MISS_REASON_NOT_FOUND, with no placeholder. |
as_of_ns |
int |
no | The reference time: the date the mapping is being asked about. An identifier maps to different instruments over time. |
exchange_mic |
str |
no | Narrows a symbol match. Empty means unconstrained. |
currency |
str |
no | Narrows a symbol match. |
Returns ResolveIdentifierResult:
| Field | Type | Meaning |
|---|---|---|
found |
bool |
Whether instrument_id holds an answer: exactly one instrument matched, or the placeholder stands in. |
instrument_id |
str |
The instrument, or the placeholder, when found. |
placeholder |
bool |
True when nothing matched and instrument_id is the deployment's LCL- placeholder for the set. |
miss_reason |
MissReason |
Set only when found is False: MISS_REASON_AMBIGUOUS, or MISS_REASON_NOT_FOUND for an empty set. |
Errors: no handler, timeout or handler error from the instrument store. NotGranted without the custody role.
import time
import meridian
held = [
meridian.Identifier(scheme="isin", value="US0378331005"),
meridian.Identifier(scheme="symbol", value="AAPL", source="snaptrade"),
]
answer = await plugin.resolve_identifier(identifiers=held, as_of_ns=time.time_ns())
if answer.found:
instrument_id = answer.instrument_id # an instrument, or the deployment's placeholder
else:
reason = answer.miss_reason # ambiguous: record the row unresolved, report the miss, carry on
report_missing_instrument¶
Reports that a resolution missed. A fact, not a request. The plugin reports what it held and carries on with the next holding. It does not ask for an instrument to be created, does not wait for one, and could not create one. The deployment and the platform decide what the instrument is, and an administrator completes it.
A plugin reports only an ambiguous miss. When nothing matched, resolve_identifier answered a placeholder, and the instrument store has already reported that miss, carrying it.
async def report_missing_instrument(
self, *, source: str = "", asset_class: str = "", identifiers: Sequence[Identifier] = (),
as_of_ns: int = 0, reason: MissReason | None = None, observed_at_ns: int = 0,
) -> Published
| gRPC | rpc ReportMissingInstrument(ReportMissingInstrumentParams) returns (Published) |
| Workflow step | W3.2, Report that a resolution missed |
| Kind | event, on platform.reference.event.instrument-missing |
| Role | custody |
| Heard by | the conductor, which asks the platform whether it already knows the instrument (W3.3) |
| Name | Type | Required | Meaning |
|---|---|---|---|
source |
str |
no | The namespace the miss occurred in, such as "snaptrade". |
asset_class |
str |
no | The instrument's asset class. |
identifiers |
sequence of Identifier |
no | Everything the plugin held at the miss. Enough to look it up against a global scheme, or to create a stub carrying them. |
as_of_ns |
int |
no | The reference time of the missed resolution. The reaction targets the mapping effective then, not now. |
reason |
MissReason or None |
no | The miss_reason that resolve_identifier returned. None leaves it unset, which reads as MISS_REASON_UNSPECIFIED. |
observed_at_ns |
int |
no | When the plugin observed the miss. |
The sidecar sets publisher_instance_id, and leaves placeholder_instrument_id empty.
Returns Published, with message_id, the message's identifier on the bus.
Errors: NotGranted without the custody role.
import time
await plugin.report_missing_instrument(
source="snaptrade",
asset_class="equity",
identifiers=held,
as_of_ns=as_of_ns,
reason=answer.miss_reason,
observed_at_ns=time.time_ns(),
)
read_accounts_for_linking¶
Reads the deployment's accounts, so the plugin's own admin page can offer the ones an external account may be linked to. It is read only for a deployment admin, the one viewing the page.
async def read_accounts_for_linking(
self, *, acting_for: str | None = None,
) -> ReadAccountsForLinkingResult
| gRPC | rpc ReadAccountsForLinking(ReadAccountsForLinkingParams) returns (ReadAccountsForLinkingResult) |
| Workflow step | W6.4, Link a plugin's external account |
| Kind | query, on platform.config.query.accounts |
| Role | custody |
| Served by | the conductor |
| Name | Type | Required | Meaning |
|---|---|---|---|
acting_for |
str or None |
yes, by the sidecar | The Meridian-Caller header of the deployment admin viewing the page. See Acting for a person. |
Returns ReadAccountsForLinkingResult:
| Field | Type | Meaning |
|---|---|---|
accounts |
sequence of AccountRecord |
Every account in the deployment, open and closed: each one's identifier, name, state, custodian, type, owner and note. No holdings. |
Offer only open accounts. A link to a closed account is refused.
Errors: NotGranted without an acting_for, or when the person is not a deployment admin. not vouched for for an acting_for that is not accepted. no handler, timeout or handler error from the conductor. NotGranted without the custody role.
from meridian.plugin.v1 import operations_pb2 as ops
read = await plugin.read_accounts_for_linking(acting_for=request.state.caller.header)
offered = [a for a in read.accounts if a.state != ops.ACCOUNT_STATE_CLOSED]
link_external_account¶
Links an external account the plugin reported to one of the firm's accounts, or removes its link. The plugin sends it from its own admin page, for the deployment admin viewing it.
Name one of these, never both:
account_id, to link to an existing account;new_account_name, to create an account and link to it in one step, so nothing is left half-done.
Name neither to remove the link.
The link is also the plugin's right to that account. The custody role grants the street store; the link grants the one account, which is in the plugin's read and write scope while the link stands, with no permission needed. A closed account stays readable through the link, and is not writable. Removing the link removes both.
async def link_external_account(
self, *, external_account_id: str = "", account_id: str = "", new_account_name: str = "",
new_account_custodian: str = "", new_account_type: str = "", new_account_owner: str = "",
new_account_note: str = "", acting_for: str | None = None,
) -> LinkExternalAccountResult
| gRPC | rpc LinkExternalAccount(LinkExternalAccountParams) returns (LinkExternalAccountResult) |
| Workflow step | W6.4, Link a plugin's external account |
| Kind | command, on platform.config.command.link-external-account |
| Role | custody |
| Served by | the conductor, which records the deployment admin as the one who made the link |
| Name | Type | Required | Meaning |
|---|---|---|---|
external_account_id |
str |
yes, by the sidecar | The account as the source knows it. It must be one this plugin reported with report_external_accounts, or, to remove a link, one it already links. |
account_id |
str |
one of these two, or neither | An existing, open account to link to. |
new_account_name |
str |
one of these two, or neither | A new account's name, for the conductor to create and link in one step. |
new_account_custodian |
str |
no | The new account's custodian, such as "Fidelity". At most 200 characters. |
new_account_type |
str |
no | The new account's type, such as "Roth IRA". At most 200 characters. |
new_account_owner |
str |
no | One ownership or grouping label for the new account. At most 200 characters. |
new_account_note |
str |
no | Anything else worth knowing about the new account. At most 2,000 characters. |
acting_for |
str or None |
yes, by the sidecar | The Meridian-Caller header of the deployment admin viewing the page. |
The four new_account_* attributes are free text, which a page may pre-fill from what the source reported, for the admin to change. They are ignored unless new_account_name is given: an existing account is edited only on the dashboard's Accounts tab.
Returns LinkExternalAccountResult:
| Field | Type | Meaning |
|---|---|---|
plugin_instance_id |
str |
The plugin the link belongs to. |
external_account_id |
str |
The external account. |
account_id |
str |
The account it is linked to: the new account's identifier when one was created, and empty when the link was removed. |
Errors:
NotGrantedwithout anacting_for, or when the person is not a deployment admin; or for an external account this plugin did not report, an empty one included.not vouched forfor anacting_forthat is not accepted.handler errorfrom the conductor for a link it refuses, with its reason: bothaccount_idandnew_account_name, an account that does not exist or is closed, or an attribute that is too long.no handlerortimeoutfrom the conductor.NotGrantedwithout thecustodyrole.
caller = request.state.caller
# Link to an existing account:
await plugin.link_external_account(
external_account_id="acct-1", account_id="ACC-…", acting_for=caller.header,
)
# Or create one and link it, pre-filled from what the source reported:
await plugin.link_external_account(
external_account_id="acct-2",
new_account_name="Rollover IRA",
new_account_custodian="Fidelity",
new_account_type="IRA",
acting_for=caller.header,
)
# Remove a link:
await plugin.link_external_account(external_account_id="acct-2", acting_for=caller.header)
Types¶
The plugin-facing types these operations take and return. Identifier and MissReason are described with the Python SDK. Every type below except Money is a generated protobuf message or enum in meridian.plugin.v1.operations_pb2; Money, ExternalAccount, HoldingSide and SyncState are also exported from meridian.
Money¶
An amount of currency, meridian.Money(amount, currency_code): a frozen dataclass.
| Field | Type | Meaning |
|---|---|---|
amount |
Decimal or int |
In the currency's major unit. |
currency_code |
str |
ISO 4217, such as "USD". |
ExternalAccount¶
One account a connection reaches, as the custodian presents it.
| Field | Type | Meaning |
|---|---|---|
external_account_id |
str |
Stable: the plugin makes it so where the venue does not. It is the external_account_id the account's rows name. A handle the venue wants on each call stays inside the plugin. |
name |
str |
The custodian's own name for it, as a person there would recognise it. |
venue_account_type |
str |
The venue's own word for the kind of account, verbatim and for display only. |
HoldingSide¶
Which side of an instrument a holding is on.
| Value | Number | Meaning |
|---|---|---|
HOLDING_SIDE_UNSPECIFIED |
0 | Not said. Refused by the street store. |
HOLDING_SIDE_LONG |
1 | Long. The quantity is zero or more. |
HOLDING_SIDE_SHORT |
2 | Short. The quantity is zero or less. |
SyncState¶
Why a connection's data is, or is not, current. Each asks something different of a person.
| Value | Number | Meaning |
|---|---|---|
SYNC_STATE_UNSPECIFIED |
0 | Not said. Shown as what connection_healthy says. |
SYNC_STATE_CURRENT |
1 | Current. Nothing to do. |
SYNC_STATE_STALE |
2 | Still serving, but older than it should be; holdings_as_of_ns says since when. Usually the rail's to recover. |
SYNC_STATE_NEEDS_SIGN_IN |
3 | A person must sign in again at the venue before it serves anything new. |
SYNC_STATE_DISABLED |
4 | The connection is disabled and serves only what it last read. Somebody re-enables it. |
SYNC_STATE_DELAYED_BY_DESIGN |
5 | Late on purpose, such as by a business day. Expected; nothing to do. |
SYNC_STATE_HOLDINGS_UNAVAILABLE |
6 | The venue does not provide holdings through this connection. Waiting changes nothing. |
AccountRecord¶
One of the firm's accounts. See Accounts.
| Field | Type | Meaning |
|---|---|---|
account_id |
str |
ACC- followed by 26 letters and digits. |
name |
str |
The account's name. |
state |
AccountState |
ACCOUNT_STATE_OPEN or ACCOUNT_STATE_CLOSED. Closed, not deleted. |
created_at_ns |
int |
When it was created. |
custodian |
str |
Where it is held. Free text; may be empty. |
account_type |
str |
What it is. Free text; may be empty. |
owner |
str |
One ownership or grouping label. Free text; may be empty. |
note |
str |
Anything else. Free text; may be empty. |
The gRPC service¶
For reference, the plugin-facing service in meridian/plugin/v1/operations.proto, which the sidecar serves on loopback:
service PluginOperations {
rpc ReportExternalAccounts(ReportExternalAccountsParams) returns (Published);
rpc ReportSyncStatus(ReportSyncStatusParams) returns (Published);
rpc RecordHoldingsStatement(RecordHoldingsStatementParams) returns (RecordHoldingsStatementResult);
rpc RecordHolding(RecordHoldingParams) returns (RecordHoldingResult);
rpc ResolveIdentifier(ResolveIdentifierParams) returns (ResolveIdentifierResult);
rpc ReportMissingInstrument(ReportMissingInstrumentParams) returns (Published);
rpc LinkExternalAccount(LinkExternalAccountParams) returns (LinkExternalAccountResult);
rpc ReadAccountsForLinking(ReadAccountsForLinkingParams) returns (ReadAccountsForLinkingResult);
}
Each …Params message keeps the field numbers of the domain message it stands for and drops the fields the sidecar stamps, so its encoding is the domain message's. acting_for is field 1000 on the four operations that take it, a meridian.v1.CallerAssertion. The SDK decodes it from the base64url Meridian-Caller header you pass. A number is a Decimal message, high and low halves of a 128-bit integer and a scale of 0 to 18, and an amount a Money message, a Decimal and a currency_code. Identifier, MissReason and the other types are plugin-facing mirrors of the domain types.