Domain neutrality and the SPI model
The engine core never names a business domain, a vendor, or a wire format. Not SWIFT, not FedNow, not ISO 20022, not Kafka, not PostgreSQL-as-a-brand. Payments, health, and EDI live entirely in codecs, transports, validators, and deployment packages — never in the token executor, the BPMN model, or the channel dispatcher. This is the single rule everything in this chapter exists to serve, and it is not a style guideline: it is checked by a build gate on every change.
Why this is load-bearing
A domain-neutral core is what lets one engine binary serve payments today and healthcare tomorrow without a fork, what lets a hardened build strip out everything it doesn't need down to the byte, and what keeps the surface a security review has to reason about small and stable. The moment a business term leaks into the executor or the model, that guarantee is gone for everyone, silently.
The gate: sutra-archtest
rust/crates/sutra-archtest is the Rust twin of what used to be an ArchUnit test on the retired
reference baseline. It walks a fixed set of crates' src/ trees, strips comments (a domain term
cited in a doc comment is fine — only a term baked into an identifier or a string literal is a
violation), and fails the build on any hit against a denylist:
#![allow(unused)] fn main() { pub const DOMAIN_DENYLIST: &[&str] = &[ "swift", "fednow", "fedwire", "pacs", "camt", "nacha", "edifact", "hl7", "iso20022", "x12", ]; }
Enforced — must be domain-literal-free:
- The neutral core:
sutra-executor,sutra-bpmn,sutra-feel,sutra-dmn,sutra-srl,sutra-templates,sutra-persistence,sutra-datastore, and the codec/format/schema SPI itself (sutra-codec-spi). - The assembly/binding layer:
sutra-channels(protocol-neutral channel binding) andsutra-engine(the library) — domain-literal-free since the transport and codec extraction and thesutra-distcomposition-root split.
Deliberately excluded — the legitimate domain edge, by design:
sutra-dist— the composition root. It force-links the built-in formats, the redactors, the vendor secret-resolvers, and the feature-selected transports, so it necessarily names them. That's the one place wiring concretes to SPIs is supposed to happen — not a core leak.- The concrete implementations themselves:
sutra-formats,sutra-codec-schema, everysutra-transport-<vendor>crate, andsutra-redactor-pci. - Tooling and test crates (
sutra-cli,sutra-conformance,sutra-testkit, the generators).
Read that exclusion list again for what isn't on it. This repository contains no
message-standard codec at all. The built-in codec set is the six schema-less formats in
sutra-formats — json, xml, yaml, csv, raw-text, raw-bytes — and sutra-dist
force-links nothing else that registers one. Every wire standard — SWIFT MT, SWIFT MX, rail
variants such as FedNow, and the EDI/segment families (HL7 v2, X12, NACHA/ACH, EDIFACT) — is a
proprietary extension crate maintained outside this repository, consuming exactly the public
SPIs a third party consumes.
That is the strongest available evidence that the SPI boundary is real rather than aspirational:
adding a message standard to a distribution costs one Cargo dependency and one use <crate> as _;
line in that distribution's own composition root. Nothing in sutra-engine, sutra-channels, or
this repository's composition root changes, and there is no central list anywhere to add a name
to. The gate has nothing to exclude for those crates because they are not here; the neutral core
cannot leak a term that never enters its dependency graph.
make lint runs this gate alongside clippy on every change (see
Contributing) — a business term landing in a gated crate fails CI, not a
code review comment.
The mechanism: self-registration behind a neutral SPI
Every extension point follows the same shape: a small SPI crate defines a trait and a
process-wide registry; each concrete implementation is its own crate that self-registers into
that registry via inventory at link time. The neutral crate
collects what got linked in — it never imports or names a specific implementation. Implementing
an extension is registering it; there is no separate central list to forget to update (a gap
that bit an earlier iteration of the codec set, closed by moving to this pattern).
Transports — sutra-transport-spi
#![allow(unused)] fn main() { pub struct TransportFactory { pub transport: &'static str, // the `transport:` value in channels.yaml this wires pub spawn: TransportSpawn, // (defs, engine, pool, envref-resolver, runtime) -> Arc<dyn TransportChannels> pub register_sink: fn(&mut SinkRegistry), pub handles_on_complete: bool, // self-declared capability — see below } inventory::collect!(TransportFactory); }
Each sutra-transport-<vendor> crate submits one TransportFactory. The engine assembly iterates
transport_factories() (sorted by name for determinism) and drives every one of them through the
identical TransportChannels lifecycle trait (rewire on an activation flip, drain on
shutdown) — there is no if transport == "kafka" anywhere in the engine.
Capability self-declaration, not a hardcoded transport check. handles_on_complete is how a
transport tells the engine whether it can realize ack-mode: on-complete (a broker deferring its
settle through the engine's ack registry, or an on-listener transport like HTTP holding the
connection to completion). When a channel declares on-complete on a transport whose factory
reports false, the engine assembly emits SUTRA.ACK.ON_COMPLETE_UNSUPPORTED and runs
on-persist instead — a loud, generic degrade driven by a flag the transport itself set, not a
list of vendor names the engine maintains. See
Acknowledgement modes for the full per-transport wiring table this
flag produces.
Codecs — sutra-codec-spi
#![allow(unused)] fn main() { pub trait PayloadCodec { fn name(&self) -> &str; fn accepted_content_types(&self) -> Vec<String>; fn decode(&self, body: &[u8], content_type: Option<&str>) -> DecodeResult; // never panics fn declared_message_types(&self) -> Vec<String> { Vec::new() } fn shape_of(&self, message_type: Option<&str>) -> Option<SchemaShape> { None } // + encode (the reply direction) } }
A zero-config, globally-named codec — each built-in format (json, xml, yaml, csv,
raw-text, raw-bytes), and any extension crate that wants a global name — submits a
BuiltinCodec { name, make }; builtin_codecs() collects and sorts them, and each is addressable
as urn:sutra:codec:<name> with no per-package configuration. A schema-backed user codec
(compiled from a package's own schemas/<name>/*.xsd) does not self-register this way — it's
instantiated per package, per the path-derived URN rule in
Deployment packages. Either way, the engine only ever calls
through the PayloadCodec trait; it has no branch for "this is the such-and-such standard
one," and no way to acquire one — the type it would have to name is not in its dependency
graph.
Bundle codec kinds — a codec crate registers its own schema-folder shape
The newest extension point in this family follows the identical inventory-pull shape, one level
into how a package's own schema folder is interpreted. schemaKind: xsd and schemaKind: json-schema are generic — a folder of schema files the engine validates against — but some
standards are a whole profile: an envelope grammar, a mapping from wire-level message names to
schema files, versioned editions revved on the standard's own release cadence. A codec crate that
needs that shape submits a BundleCodecKind { kind, build } in sutra-codec-schema::bundle next
to its PayloadCodec impl — implementing the kind is registering it, exactly like a
BuiltinCodec, and sutra-codec-schema itself stays free of any knowledge of which standards use
it. A package's schemas/<name>/codec-manifest.yaml declaring schemaKind: <kind> is what selects
a registered bundle over the generic ones; an unserved kind is a fail-closed deploy error naming
the kinds the running build actually serves.
A market-infrastructure rail codec — an extension crate outside this repository — is the first user of this extension point (see Channels and transports and Deployment packages for the manifest shape and deployment-scoped registration it produces). It's also a useful case study for the SPI pattern more broadly: everything specific to one rail's profile — its envelope root names and namespaces, its per-direction wrapper tables, its quirks (a wrapper shipped without the profile's usual name prefix, several wrappers backed by one underlying message) — is a single data value the codec's internal walker/validator/projection code reads generically. A second rail that follows the same envelope-and-wrapper-editions pattern would supply its own profile value and reuse that machinery rather than re-implement it — the same "data, not a branch" discipline the rest of this chapter describes, one layer further in.
Data stores — sutra-datastore
The DataStore SPI is the same shape one level down: a provider (sql today; see
Data stores) resolves its own connection from datastores.yaml
— never the engine's internal datasource — and exposes get/put/get_for_update/
put_if_revision against (store_name, store_key). The engine's executor calls through this
trait for every <q:store>-bound data association; it has no idea whether the value on the other
side is an account balance or a customer record.
Secrets — sutra-envref-spi
The vendor-neutral seam behind env:, secret:, ${…} placeholders, and vendor schemes like
vault:… / aws-secrets:…. A vendor resolver crate (sutra-envref-vault, sutra-envref-aws, …)
submits an EnvRefResolverEntry; the engine resolves a whole reference generically and names no
vendor SDK. This is what lets channels.yaml and datastores.yaml carry credentials as
references, never literals — see Configuration reference.
Redactors — sutra-redactor-spi
A sutra-redactor-<standard> crate submits a RedactorEntry that locates sensitive spans in a
decoded payload (a JSON-Pointer-shaped path + a reason code); the engine masks every located path
on every observability surface and marks it for encryption at rest. Fail-closed by construction: a
redactor that panics or errors tells the engine to over-mask the whole bound payload rather
than risk a leak — the opposite failure mode from a validator crash, which becomes an ordinary
business-reject issue instead.
Validators
<q:simpleValidator ref="…"> names a field-content validator (iso-3166-country,
iso-4217-currency, iso-9362-bic, …) out of a neutral registry the core knows nothing about
(see The q: namespace). The concrete validators are domain content
and live in extension crates, outside this repository, for the same reason the domain codecs do —
which is why the neutrality gate has nothing to exclude here.
The lifecycle bus — ExecutionListener
One more neutral seam worth knowing about even though it isn't a "plug in a new vendor" SPI: every
cross-cutting concern that needs to react to instance/token/task lifecycle events — audit, OTel
metrics, the deferred-ack registry — implements the same ExecutionListener trait and is fanned
out from a plain Vec<Rc<dyn ExecutionListener>> the executor calls on on_instance_started /
on_instance_completed / on_instance_suspended / etc. There's no dependency-injection
container; listener registration happens explicitly at executor-construction time in the engine
assembly. See Acknowledgement modes for DeferredAckRegistry as a
concrete example, and Observability for the OTel listener.
Where business content actually lives
Given all of the above, a real deployment's domain-specific content lives in exactly two places:
- Inside a deployment package — the BPMN processes, DMN/
.srlrules,channels.yaml,datastores.yaml, and the package's own XSD-backed codec (see Deployment packages). This is where almost all business logic belongs, and it needs zero Rust code. - In an extension crate, only when the domain needs a new kind of codec, transport,
validator, or redactor that doesn't already exist — implemented against the relevant SPI above
and linked in by a composition root (
sutra-dist, or your own if you build a custom binary).
Worked example: adding a transport
Say you need a new broker Sutra doesn't ship a transport for. The shape is fixed by the pattern above:
- Create
sutra-transport-<vendor>, depending onsutra-transport-spiandsutra-channels. - Implement
TransportChannels(transport(),consumer_count(),rewire(active),drain(),stop_all_detached(), and the one optional capabilityinbound_router()— only HTTP-shaped transports returnSome). - Implement the inbound spawn function with the uniform signature
(&[ChannelDefinition], EngineHandle, Option<PgPool>, EnvRefResolver, tokio::runtime::Handle) -> Result<Arc<dyn TransportChannels>, Diagnostic>, and an outbound sink registrarfn(&mut SinkRegistry). - Decide whether your transport can realize
ack-mode: on-complete(deferred settle, or holding the connection) and sethandles_on_completehonestly — get this wrong and channels either silently under-deliver on a promise, or trigger the loud unsupported diagnostic needlessly. inventory::submit! { TransportFactory { transport: "your-broker", spawn, register_sink, handles_on_complete } }next to your implementation.- Add your crate as an optional dependency behind a same-named Cargo feature in whatever
composition root force-links it (
sutra-dist, or a custom one) — nothing insutra-engineorsutra-channelschanges.
A new codec follows the identical shape against PayloadCodec instead, submitting a
BuiltinCodec for a zero-config global codec, or shipping as a package-local schema-backed codec
with no self-registration at all. Every message-standard codec in existence takes that first
path — including the ones a downstream distribution ships — so it is a well-travelled route, not
a theoretical extension point kept alive by a single in-tree user.
Next
- Deployment model — how a package's declared channels/codecs/stores get resolved against whatever is linked into the running binary.
- Contributing — the repo map, including where each SPI crate and its concrete implementations live.