Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plugins Reference

The manifest a plugin is uploaded with, the ABI its component implements, how an invocation is bounded, and what a failure looks like. The concept page is Plugins; the ceilings are under Configuration › Plugins; the endpoints are under Admin API › Plugins.

Manifest

Host-owned metadata submitted with the component, as TOML. Its vocabulary is the same field table every built-in function declares, and nothing more.

abi = "orion:plugin@1.0.0"
name = "acme.iso8583"
version = "1.2.0"            # informational; Orion assigns the entity version
component = "component.wasm" # relative to this file; read by tooling and the CLI only

[[functions]]
name = "acme.iso8583.parse"
description = "Parse an ISO 8583 message into field-numbered JSON"
category = "transform"
output_default_root = "data"

[[functions.input_fields]]
name = "message"
kind = "string"
required = true
resolvable = true

[[functions.input_fields]]
name = "spec"
kind = "string"
required = true
KeyRule
abiMust be orion:plugin@1.0.0, the WIT package version this server speaks.
nameThe plugin id: lowercase reverse-domain with at least two labels ([a-z][a-z0-9-]*). orion.* and unqualified names are reserved.
versionThe author’s own version string. Informational: Orion assigns the entity version.
componentPath of the component relative to the manifest, with no way out of its directory. Read only by offline tooling and the CLI upload; the server receives bytes and identifies them by digest.
functions[].nameMust be <name>.<label> — a plugin’s functions live in its own namespace, which is what keeps them from colliding with a built-in or another plugin.
functions[].categoryFree text, defaulting to transform.
functions[].output_default_rootdata, temp_data or metadata: where the result lands when a task names no output. Absent means a task must name one.
functions[].input_fields[].kindstring, number, bool, object, array or any.
functions[].input_fields[].requiredRefused at create time when absent.
functions[].input_fields[].template_attrue makes the field’s value a JSONLogic expression: compiled once when the workflow loads, evaluated per message, and the guest receives the result — the same template_at: [""] a built-in’s field declares in the catalogue.
functions[].input_fields[].resolvable{"var": …} nodes in the field are folded against the message before the guest sees it. Not combinable with template_at, which already evaluates var.

Unknown keys, an unsupported abi, an invalid kind, a reserved name and a declared output all reject the upload, with a path into the document. output is implicit on every function: a task may always name where its result goes. A field is evaluated (template_at), folded (resolvable) or literal; secret_at does not exist, and a {"secret": …} node is refused at create time anywhere in a plugin task’s input — a plugin never sees key material.

Activating a version checks every active workflow that calls its functions against the schema it declares: a field renamed or newly required between versions is refused with a 409 naming the workflow, while the previous version keeps serving. (A workflow that reaches the engine with an input its plugin’s table refuses — through an import, say — is quarantined when it loads, with the mismatch in the reason.)

ABI

package orion:plugin@1.0.0;

interface functions {
  enum error-class { caller-input, internal }
  record plugin-error { code: string, class: error-class, message: string }

  /// `function` is the registered name; `input` is the evaluated
  /// `function.input` as JSON. Returns the JSON value written at `output`.
  invoke: func(function: string, input: string) -> result<string, plugin-error>;
}

world plugin {
  export functions;
}

The world imports nothing. The guest receives the whole evaluated input object — every declared field the task set, output excluded — and returns one JSON value. One component may export many functions and dispatch on the name. A guest code must match ^[A-Z][A-Z0-9_]{0,63}$; its message is capped and prefixed with the function name before a client sees it.

Limits

Every invocation runs in a fresh instance under the node’s ceilings, each narrowed by the plugin’s [[plugins.overrides]] block if one exists:

CeilingWhat it bounds
max_memory_bytesLinear memory. A growth past it is refused; a guest that then aborts fails as a limit.
max_timeout_msWall clock. The epoch deadline traps the guest; a wall-clock timeout around the call is the belt to that brace. The task’s own deadline applies too and the shorter wins.
max_request_bytesThe serialised input, checked before the guest runs.
max_response_bytesThe returned JSON, checked before it is parsed.
max_concurrency_per_functionInvocations of one function at once; beyond it a task waits for a permit until its deadline.
fuel_backstopAn instruction budget, sized well above what the clock admits; it catches a runaway the clock somehow missed.

Compiling happens once per digest per process, on a blocking thread, never on a request. Instantiation is microseconds through a pooling allocator whose size is max_live_instances.

Errors

SourceConditionClassRetried
guestcaller-inputCallerInputno
guestinternalBackendno
hostfuel, memory, request or response size, no permit, instance pool fullLimitno
hostepoch or wall-clock deadlineTimeoutyes — a pure function retries for free
hosttrap, panic, result that is not JSONBackendno

A failure writes nothing. Wasmtime’s internals and trap text go to the operator log with the plugin id, version, digest, function and trace id; a client sees the category and, for a guest error, the code and capped message. orion_plugin_failures_total{category} counts them by the same categories — see Metrics › Plugins.

Health

GET /plugins/{id} reports this node’s load state under health: loaded (with the compile time), failed (with the stage and reason), disabled (the sandbox is off on this node) or inactive (the version is not the one this node’s generation carries). /health carries a plugins component that is ok, degraded when an active plugin did not load, or disabled; the admin-only detail lists every loaded version and every failure. The stages a failure names are manifest, signature, artifact, compile, link, size and self_test.

Trust

Installing a plugin needs the admin credential — the one that already reads and writes connector secrets — so a plugin adds no new principal. The optional hardening on top is a detached Ed25519 signature over the component digest, configured under [plugins.trust]:

[plugins.trust]
public_keys = ["MCowBQYDK2VwAyEA…"]   # raw 32-byte keys, base64

When any key is configured, an upload must carry signature: the base64 Ed25519 signature over the ASCII digest string (sha256:<64 hex>) by one of those keys. The digest is what is signed, not the bytes, so a release pipeline signs the identity every other surface already names and never needs the component in memory. An upload with no signature is refused at signature with REQUIRED; one that does not verify, with INVALID. The signature is stored on the version and verified again by every node that loads it — a row that arrived through an import on a node without keys, or a peer’s activation, is checked by the node that runs it, and one that fails is a signature load issue that quarantines the workflows naming its functions. A node with no keys configured checks nothing and stores whatever the upload sent.

Signing with OpenSSL, given an Ed25519 private key in signer.pem:

digest=$(printf 'sha256:%s' "$(sha256sum plugin.wasm | cut -d' ' -f1)")
printf '%s' "$digest" | openssl pkeyutl -sign -rawin -inkey signer.pem | base64 -w0 > plugin.sig
openssl pkey -in signer.pem -pubout -outform DER | tail -c 32 | base64 -w0   # the value for public_keys
orion-cli plugins create -f plugin.toml --signature plugin.sig

orion-cli plugins create --signature <file> reads the base64 text from a file; over the API, signature is a field of the JSON body or a part of the multipart form.

Packages and offline tooling

A plugin is the fourth member of a package. package export resolves every plugin function a selected workflow calls to the active version and digest serving it — GET /workflows/{id}/dependencies reports them under plugins — and carries each under plugins[]; with --include-artifacts the component travels inline, so apply installs and activates it on a target that has never seen it before any workflow that calls it. Without the component the target must already hold the digest, and plan says so. A plugin the source no longer serves at that digest goes to requires.plugins, which plan checks the target has active. compile does the same for a plugin.toml in a definition set, inlining the component beside it.

Offline, lint, clippy and compile read the manifests in the set and in every --plugin-dir, and validate a plugin task’s input against the manifest’s field table exactly as the admin API validates it against the active plugin. A function of a plugin no manifest accounts for is reported as unverifiable — a note, never an error — because only the serving instance can answer for it. dry-run and test go further: given --plugin-dir with the components beside the manifests, plugin functions run for real in the same sandbox the server uses, under the host’s default ceilings. They are never stubbed — a plugin is capability-free, so the real answer is always available — and a case naming a function whose component is absent fails as PLUGIN_ARTIFACT_UNAVAILABLE rather than passing on a stand-in.

fmt reads no manifest: one style everywhere is its whole value, so a plugin task’s input keeps its author order. clippy has no plugin-specific rule; a plugin’s writes are proven structurally through output, as crypto’s are.

Performance

What a plugin invocation costs per request is a fresh store, an instantiation from the pre-linked component, one JSON serialisation in and one parse out. Scenario H of the benchmark suite measures exactly that: the test fixture’s identity function on the hot path against the same rewrite as a JSONLogic map, both behind parse_json, so the difference between the two rows is the sandboxed call. It runs in the default set (bench.sh plugin runs it alone) and its rows are recorded with every release’s benchmark record under crates/orion-server/tests/benchmark/results/, on the dedicated hardware RELEASING.md requires for published numbers. On a development build the two rows are within a few percent of each other; treat that as the shape of the answer, not the answer — the release record is.

Two things move the number more than the sandbox does. A component compiles once per digest per process, on a blocking thread, when its version loads; that cost never sits on a request. And max_live_instances bounds how many invocations can be in flight at once across every function, so a pool sized below the concurrency the node actually sees surfaces as instantiation failures under load rather than as latency.