DRE plugin protocol, version 0
Every source, format and destination in DRE is a plugin, served by a separate executable that
DRE core starts and talks to over stdin and stdout. Plugins ship in packages: one executable can
serve several plugins (the databricks package is a source and a destination; object_store
is three destinations), and core names the one it wants in the handshake. Plugins can be written
in any language. This document
is the contract between core and a plugin. Any change to it means a new protocol version.
The reference implementation is the dre-protocol crate
(cargo add dre-protocol). It has its own version, apart from both DRE’s and this protocol’s.
It has three parts:
- the core side,
host; - a plugin SDK,
plugin, which gives Rust authors framing, the handshake and error handling; - a conformance suite,
conformance, that any plugin binary can be checked against.
The plugin interface
Section titled “The plugin interface”Every plugin, whatever its kind, follows one interface. Core finds a plugin by its kind and name alone and knows nothing else about it, so a new source, format or destination needs no change to core.
Every plugin:
- Answers the handshake (
hello) with its kind, name, version and capabilities. - Describes itself (
describe): the connection fields aprofiles.ymltarget of its type takes, and the options a report’s config block for it takes (option_fields). - Validates a config block (
validate): checks one block of options and replies with every problem found, without connecting or writing anything.dre validateanddre runsend every block the project gives the plugin before anything runs. - Does its one job: a source runs statements (
open,execute, and optionallycheckandload); a format writes files (write); a destination delivers them (deliver). - Closes cleanly on
closeor at the end of its input.
The config block a plugin owns:
| Kind | Block | Keys core owns (never sent) |
|---|---|---|
| format | a report’s output: map, plus the project’s format_options.<format> |
format, destination, template, extension |
| destination | one entry of output.destination |
profile, path |
| source | none; its settings are the profile’s connection fields |
Rules for option messages. Each problem is one sentence that names the key in backticks
(`quoting` must be one of `minimal`, `all`, `strings`, `none`). Core adds where it was found
(the file, the report and Set, the destination profile). A plugin rejects keys it doesn’t
declare, so a misspelt key is an error rather than silently ignored.
In Rust, the dre_protocol::plugin SDK does all of this. A plugin implements one trait
(Source, Format or Destination) and is served with serve_source, serve_format or
serve_destination; a package of several passes them all to serve_package. A plugin declares its options as OptionFields (name, type,
allowed values, bounds, default, description), and adds any rule a declaration can’t express in
validate(). The SDK answers describe and validate from those declarations, advertises
validate, and checks the options again before every write and deliver, so plugin code only
sees options that passed. The conformance suite checks every part of the interface.
Naming and location
Section titled “Naming and location”A plugin is identified by its kind and name, written <kind>/<name>:
kindissource,formatordestination.namematches[a-z0-9_]+. It is the value used inprofiles.yml(type: duckdb) and inoutput.format(format: xlsx).
A package’s executable is named dre-plugin-<package> (plus .exe on Windows), e.g.
dre-plugin-object_store. An executable serving one plugin may instead be named
dre-<kind>-<name>, e.g. dre-source-duckdb; it is then a package of that one plugin, called
<name>.
Core looks in the project’s dre_deps/plugins (or DRE_PLUGINS_DIR), in two layouts:
<dir>/<package>/<version>/: versioned installs, side by side, as the plugin manager lays them out.plugin.jsonthere names the executable and the plugins it provides:{"executable": "dre-plugin-object_store", "provides": ["destination/s3", ...]}.<dir>/dre-plugin-<package>or<dir>/dre-<kind>-<name>: placed by hand, for development. Core asks adre-plugin-<package>executable what it provides with a handshake.
Streams
Section titled “Streams”| Stream | Direction | Content |
|---|---|---|
| stdin | core → plugin | frames |
| stdout | plugin → core | frames, and nothing else |
| stderr | plugin → core | free-form UTF-8 log lines, shown in core’s log and quoted in errors; a line starting info: is shown to the person without -v (e.g. while waiting for a warehouse to start) |
A plugin must never write anything but frames to stdout.
Core sets DRE_INTERACTIVE in every plugin’s environment: 1 when a person is at core’s
terminal, 0 otherwise (a scheduler, CI). A plugin that needs a person, such as for a browser
sign-in, must fail with an explanation instead of waiting when it isn’t 1. A value already in
core’s environment is passed through unchanged.
Frames
Section titled “Frames”A frame is a 4-byte big-endian unsigned length N, then N bytes of body. N is between 1
and 2^30. The first body byte is the frame type:
| Byte | Type | Rest of the body |
|---|---|---|
0x4A (J) |
control message | one UTF-8 JSON object with a type field |
0x41 (A) |
data | one Arrow IPC stream (schema message, record batches, end-of-stream marker) |
Each data frame is self-contained: it carries its own schema, so it can be decoded alone. Large result sets are sent as many data frames, one or more batches each.
A frame with an unknown type byte, a bad length or invalid JSON is malformed. The receiver reports it and stops.
Handshake
Section titled “Handshake”The first message core sends is hello, naming the plugin it wants served:
{"type": "hello", "min_version": 0, "max_version": 0, "core_version": "…", "plugin": "destination/s3"}The plugin picks the highest protocol version both sides support and replies as that plugin:
{"type": "hello", "protocol_version": 0, "kind": "destination", "name": "s3", "version": "1.2.0", "capabilities": ["validate"], "provides": ["destination/s3", "destination/gcs", "destination/azure_blob"]}pluginis optional. Without it, an executable serves its first plugin (core leaves it out only to ask a hand-placed package what it provides).- An executable that doesn’t provide the plugin asked for replies
errorand exits non-zero. Core also refuses a reply whosekindandnamearen’t the plugin it asked for. provideslists every plugin the executable serves. It may be left out by an executable serving one plugin; that plugin is then itskindandname.- An executable serving one plugin may ignore
plugin, so plugins written before packages keep working unchanged.
If the ranges don’t overlap, it replies with its own range and exits non-zero:
{"type": "version_mismatch", "min_version": 1, "max_version": 2}Core reports both ranges to the user. It waits 30 seconds for the hello reply
(DRE_PLUGIN_HANDSHAKE_TIMEOUT_MS overrides this). A plugin that hasn’t answered by then is
reported as not responding.
Capabilities:
| Capability | Meaning |
|---|---|
sessions |
Source: one session (connection) is held across every request until close, so temp tables and session settings persist. Core refuses to run a Binding with more than one statement on a source without it. |
read_only |
Source: honours read_only: true on open. |
check |
Source: supports check (verify a statement without executing it). |
load |
Source: supports load (rows into a temporary table on the session). |
multi_file |
Destination: takes every file of one output in a single deliver (files), e.g. one email carrying every attachment. |
validate |
Answers validate. Required: every first-party plugin advertises it (the Rust SDK does so itself). Core warns that a plugin without it predates option checks and should be updated. |
Requests and replies
Section titled “Requests and replies”Every request gets exactly one reply. Any request may be answered with an error:
{"type": "error", "message": "human-readable explanation"}After an error reply the plugin keeps serving. Unknown request types, and requests meant for
another plugin kind, are answered with error.
All kinds
Section titled “All kinds”| Request | Reply |
|---|---|
{"type":"describe"} |
{"type":"describe","connection_fields":[{"name","description","required","secret","default","same_as_source"}],"option_fields":[{"name","type","description","required","default","choices","min","max"}]} |
{"type":"validate","options":{…}} |
{"type":"validated","errors":["…"]} |
{"type":"close"} |
{"type":"ok"}, then the plugin exits 0 |
describe lists the fields a profiles.yml target of this plugin’s type accepts. dre init
uses it to prompt for connection details. By default it offers fields marked secret as
env_var() references. A destination field with "same_as_source": "<source type>" defaults
to the value entered for a source profile of that type (for example one Databricks host for
both). Format plugins return an empty list.
option_fields lists the options the plugin takes (see The plugin interface).
type is one of string, char (exactly one character), boolean, integer, number,
strings (a string or a list of strings), list, map or any. choices limits a string to
those values; min and max bound a number, inclusive. It may be omitted when the plugin
takes no options.
validate checks one config block of options and replies validated with every problem found,
each a sentence naming the key; errors is empty when the block is fine. The plugin doesn’t
connect, read or write anything. A destination’s string values may still hold Jinja
({{ … }}, {% … %}), which core renders only before deliver: such a value is checked for
presence only.
When stdin closes, the plugin exits.
Source
Section titled “Source”{"type": "open", "connection": {…profile target fields…}, "read_only": false}open starts the session. connection holds every field of the selected profiles.yml target
except type, with env_var() already rendered by core. The reply is ok.
{"type": "execute", "sql": "select …", "row_limit": 100}execute runs exactly one statement. Core splits files into statements itself. row_limit is
optional; when present, the plugin returns at most that many rows. The reply is one of:
{"type":"no_result","rows_affected":3}: the statement returned no result set (DDL, DML,SET, …).rows_affectedis optional.{"type":"result","columns":["a","b"]}, followed by one or more data frames (the first one carries the schema, even for zero rows), followed by{"type":"result_end","rows":42}. Anerrormay replaceresult_endif the stream fails part-way through.
The report’s YAML, not the reply, decides what goes in the output: each query entry makes one
tab (a sheet, or a file for single-table formats) from its file’s last statement unless it has
tab: false. Core only checks the reply against that: a tab statement must reply result (zero
rows still gives a tab with its column names), and results of other statements are discarded.
run_query() in templates, --preview (with row_limit) and dre validate --live all use this
same request.
{"type": "check", "sql": "select …"}check verifies a statement without executing it, in the dialect’s own way (for example
EXPLAIN). The reply is ok, or an error explaining what’s wrong. Only sent to plugins that
advertise check.
{"type": "load", "name": "countries"}load puts rows into a temporary table (or view) on the session, for a lookup too large to
inline. Core then streams the rows as one or more data frames (the first carries the schema) and
a {"type":"result_set_end"}. Column types are Utf8, Int64, Float64, Boolean and
Date32. The plugin replies:
{"type": "loaded", "relation": "dre_lookup_countries", "rows": 5000, "warning": "…"}relation is what core puts in the SQL wherever the lookup is referenced. Set warning when the
database has no bulk path and the load went through ordinary SQL; core shows it to the user.
First-party plugins: DuckDB uses its appender, Postgres COPY, and Databricks a temporary view
built from one VALUES statement, with a warning. Only sent to plugins advertising load.
Format
Section titled “Format”{"type": "write", "path": "/…/target/run/monthly/client_a/monthly.xlsx", "format": "xlsx", "options": {…format options…}, "result_sets": [{"name": "Summary", "query": "query_01", "result_index": 1, "anchor": "A1", "header": true, "columns": {"amount": {"format": "#,##0.00"}}}], "template": {…}}result_index is always 1: each query contributes at most one result set. anchor, header
and columns (the query entry’s per-column options: format, formula, total) are left out
when not set.
After write, core streams each result set in the order listed. Each one is sent as one or more
data frames (the first carries the schema), then {"type":"result_set_end"}. After the last
result set, core sends {"type":"finish"}. The plugin writes the file(s) and replies:
{"type": "written", "files": ["/…/monthly.xlsx"], "warnings": ["…"]}warnings (optional) are shown to the person, e.g. a value the format had to write as text.
If the write fails part-way, the plugin replies {"type": "error", ...} at once, without waiting
for finish, and then reads (and discards) the rest of the stream up to finish, sending
nothing more. Core stops sending data when it sees the error, so a large result isn’t streamed
into a plugin that gave up. That error is the request’s only reply.
options are the report’s resolved format options (see the YAML schema). template is present
only for an xlsx template output: {"file": "<absolute path>", "bindings": [...], "values": {"<sheet>!<cell>": "<rendered value>"}}, with single-cell values already rendered
by core.
Destination
Section titled “Destination”{"type": "deliver", "local_path": "/…/target/run/…/monthly.xlsx", "remote_path": "s3://bucket/monthly-20260125.xlsx", "connection": {…}, "options": {…}}deliver copies the local file to remote_path. The path is already rendered, and may be absent
when the destination profile alone says where. The reply is
{"type":"delivered","location":"<where it landed>"}. The local file is never removed. It stays
in target/ whatever the outcome.
options holds the destination entry’s plugin options: every key of the entry in
output.destination other than profile and path (for example to and subject for email,
channel and message for Slack). Core renders their Jinja before sending, so string values
arrive final. It is {} when the entry has none. Core has already had them checked with
validate, and the Rust SDK checks them again before calling the plugin.
A destination that advertises the multi_file capability receives every file of one output in a
single request, in place of local_path/remote_path:
{"type": "deliver", "files": [{"local_path": "/…/daily_orders.csv", "remote_path": "…/daily_orders.csv"}, {"local_path": "/…/daily_refunds.csv", "remote_path": "…/daily_refunds.csv"}], "connection": {…}, "options": {…}}It replies with one delivered for the whole set. Exactly one of local_path or files is
present. Core only sends files to a plugin that advertised multi_file; any other plugin gets
one deliver per file. A single-file output is always sent in the local_path form.
Errors and failure
Section titled “Errors and failure”- A plugin that exits, crashes or closes stdout unexpectedly is reported with its exit status and its last stderr lines.
- A malformed frame from the plugin is reported as a protocol error.
- A plugin that panics or hits an internal error should reply
errorbefore exiting. The SDK does this.
Versioning
Section titled “Versioning”The protocol version is a single integer. Core and plugins release independently, so each side states the range it supports and the handshake picks the highest common version. Adding a new optional field to a message does not change the version; anything else does.
Conformance
Section titled “Conformance”dre_protocol::conformance::run(path) checks a plugin binary. It covers the handshake and
identity, refusal of an unsupported version, describe, error replies for unknown and wrong-kind
requests, validate (advertised, answered, and refusing an option the plugin doesn’t declare),
behaviour on a malformed frame, and a clean exit on close and on end of input. For a
destination it also sends a deliver carrying options and, when multi_file is advertised, a
files delivery, and expects a reply to each (delivered or error) with the plugin still
serving. conformance::run_with_env runs the suite with extra environment variables. Every
first-party plugin runs the suite in its tests.