Consume a Constructor Provider
This guide covers the consumer side: referencing a provider member from a packaged workflow so it ships, loads, and runs with no operator staging. For authoring the provider itself, see Author a Constructor Provider.
The contract in one sentence: declare the provider as a dependency, reference a member by name, and the compiler bundles everything the deployed package needs.
The provider is an ordinary Cargo dependency of the workflow crate:
[dependencies]
cloacina-provider-fs = "0.1" # crates.io, or { path = ... } / { git = ... }
Inside a #[workflow], a constructor!(...) node instantiates one member:
#[workflow(name = "constructor_demo")]
pub mod constructor_demo {
use super::*;
constructor!(
id = "reader",
from = "cloacina-provider-fs@0.1.0",
constructor = "read_file",
config = { path = "/etc/hostname" },
grants = { fs = ["ro:/etc"] },
);
#[task(id = "summarize", dependencies = ["reader"])]
pub async fn summarize(context: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
let contents = context.get("contents").and_then(|v| v.as_str()).unwrap_or("").to_string();
context.insert("bytes", serde_json::json!(contents.len()))?;
Ok(())
}
}
At build time the compiler scans the source for constructor! /
#[reactor(... from = ...)] references, resolves each named provider from the
crate’s resolved Cargo graph, builds it to a wasm32-wasip2 component, and
bundles the packed provider into the workflow package. A referenced provider
that is not a dependency (or whose pinned version the graph doesn’t
provide) fails the build — never silently at load.
Python packages have no Cargo manifest, so the provider declaration lives in
package.toml — this section is authoritative (the only source of provider
dependencies for a Python package):
[metadata.providers]
cloacina-provider-fs = "0.1.0"
# or detailed specs, same shapes as Cargo dependencies:
# cloacina-provider-fs = { path = "/workspace/providers/cloacina-provider-fs" }
# cloacina-provider-fs = { git = "https://github.com/...", tag = "v0.1.0" }
The workflow module wires a member exactly like a task:
import cloaca
cloaca.constructor(
id="reader",
from_="cloacina-provider-fs@0.1.0",
constructor="read_file",
config={"path": "/etc/hostname"},
grants={"fs": ["ro:/etc"]},
runtime="wasm", # optional trust-tier pin; see Capability Grants
)
@cloaca.task(dependencies=["reader"])
def summarize(context):
contents = context.get("contents") or ""
context.set("bytes", len(contents))
return context
The compiler synthesizes a scratch Cargo project from the declared specs,
builds each provider to wasm, and bundles it — the same hermetic result as the
Rust path. A cloaca.constructor reference to a provider missing from
[metadata.providers] fails at load with “no such provider” (it never resolves
against another package’s bundle).
The optional @version suffix on from is enforced, at build time and at
load, with segment-prefix semantics:
| Pin | Matches | Does not match |
|---|---|---|
@0.1.0 |
exactly 0.1.0 | 0.1.1 |
@0.1 |
0.1.x | 0.10.x |
@1 |
1.x.y | 10.x.y |
A mismatch is a clear error naming both the pinned and the resolved version.
Full semver operators (^, ~, ranges) are not supported; pin a segment
prefix instead.
- The server’s reconciler unpacks the package’s bundled providers and resolves each declared node before the workflow assembles — a package that declares constructor nodes but carries no bundles refuses to load (fail-closed, hermetic).
- Execution agents do the same: they fetch the bundles from the server (content-addressed, alongside the artifact) and resolve nodes in their own load path — fleet dispatch is transparent to constructor workflows.
- The node executes inside a WASI sandbox scoped to the consumer’s
grants— see Capability Grants. With no grant, the sandbox reaches nothing. - Trust tier is checked before anything loads.
grantsare enforced on WASM providers and advisory on native ones, so declaringgrantsagainst a provider that resolves NATIVE is a load error unless you acknowledge it. An optionalruntime = "wasm" | "native"on theconstructor!(...)(and on#[reactor(...)]) pins the tier outright — the load fails, naming both runtimes, if the resolved provider ever disagrees. See Capability Grants. - After a Cloacina upgrade that bumps the plugin ABI, previously-compiled packages are detected as stale at load and automatically recompiled from retained source — no manual rebuild sweep.
A computation graph’s stream accumulator can be supplied by a provider
instead of host-compiled backend code. In package.toml, add
provider/constructor routing keys to the accumulator config and declare the
provider under [metadata.providers]:
[[metadata.accumulators]]
name = "ticks"
accumulator_type = "stream"
[metadata.accumulators.config]
provider = "cloacina-provider-kafka" # routing: which bundled provider
constructor = "kafka_source" # routing: which member
broker = "{{ KAFKA_BROKER }}" # member #[config], name-keyed + templated
topic = "tour.ticks"
group = "my-consumer-group"
[metadata.providers]
cloacina-provider-kafka = "0.1" # crates.io / { git = … } / { path = … }
[metadata.providers] works for both languages: it’s the only declaration
form for Python packages, and for Rust packages it’s additive to source-scanned
constructor! refs — use it when the provider shouldn’t join your crate’s own
Cargo graph (a native rdkafka provider is the canonical case). The compiler
builds each declared provider per its own runtime marker (wasm component or
native cdylib) and bundles it; the reconciler stages the bundle and the
accumulator’s source streams straight into the graph’s boundary channel.
Config values bind by name against the member’s declared #[config] schema
(unknown or missing keys fail the load with a key-named error), and {{ VAR }}
templates resolve via CLOACINA_VAR_* server env. Native providers are the
trusted tier — see the trust-tier table in
Author a Constructor Provider.
The runnable end-to-end example is
examples/features/computation-graphs/cg-feature-tour
(angreal demos features cg-feature-tour: real Kafka messages fire the graph
through the bundled native provider).
Embedded runners resolve from against a provider search path instead of a
bundle: the process-wide override (set_provider_search_path), else
CLOACINA_PROVIDER_PATH, else ./providers. Stage provider packages there
(e.g. via cloacinactl constructor package + unpack). The runnable
examples/constructor-contract/fs-grant-demo shows the full embedded flow.
Full precedence, highest first:
- the per-load scope — the directory a host staged for the package being loaded. This is what the server’s reconciler installs, and it is per package (and therefore per tenant), never shared;
- the process-wide
set_provider_search_pathoverride; CLOACINA_PROVIDER_PATH;./providers.
set_provider_search_path is the embedded/single-tenant knob. A multi-tenant
host must not use it: every tenant would resolve from against one directory, so
one tenant’s staged providers would be visible to another’s constructor nodes.
Hosts scope each load instead (the loader’s *_in entry points take the directory
explicitly, and ScopedProviderSearch installs it for call sites like Python’s
cloaca.constructor(...) that cannot take an argument). A package that bundles no
providers gets a scope that deliberately skips level 2, so it can never inherit
another load’s staged tree.