Skip to content

ADR 0008: Remote providers behind the same contract

One contract, two shapes. sourceorrery-core defines SecretClient (synchronous) and AsyncSecretClient (asynchronous) with the same methods and the same…

7 min read

Status: accepted · Date: 2026-10-23

0.3.0 clarification: The provider interfaces and adapters remain in use in 0.3.0. Manifest-declared remote vaults in the original design were superseded by ADR 0009: provider settings and credentials now belong to local-vault connections. Use the provider guides for current configuration.

SourceOrrery’s contract, SecretClient, is synchronous because its one backend is a local encrypted file. Milestone 4 adds remote backends, HashiCorp Vault and AWS Secrets Manager, whose every operation waits on the network. Applications that use those services are largely async Rust, while the CLI, the C ABI and every SDK built on it are synchronous and must keep working unchanged against any backend. There is no SourceOrrery server: the providers’ own services are the only remote ends, and the rest of the system treats a remote vault as one more backend behind the contract.

The workspace had no async runtime, and the lifecycle conformance suite was a set of tests written against the SQLite vault, so nothing could judge a second backend.

One contract, two shapes. sourceorrery-core defines SecretClient (synchronous) and AsyncSecretClient (asynchronous) with the same methods and the same semantics table; the docs of SecretClient are the contract for both. Providers implement the async shape. The local vault stays synchronous. Policy (enabled, not_before, expires_on, newest accessible version) is evaluated in core after a provider returns metadata, so every backend enforces it identically, and a value crosses a provider as SecretBytes the moment it leaves the HTTP body.

Object safety through async-trait. A provider is chosen at run time from the manifest, so the CLI and the client crate hold a Box<dyn AsyncSecretClient>. Native async fn in traits cannot yet be called through dyn, so the trait uses the async-trait crate (one proc-macro dependency; it boxes each returned future). The trait requires Send + Sync so one client serves many tasks. Revisit when dyn-compatible async traits land in stable Rust; the change would be an implementation detail for callers that use async/.await.

Adapters, in a new crate sourceorrery-provider. Blocking<A> implements SecretClient for any AsyncSecretClient by driving each call to completion on a Tokio runtime: a current-thread runtime the adapter owns (Blocking::new), or a handle to the application’s (Blocking::with_handle). AsyncLocal<C> implements AsyncSecretClient for any Send + Sync SecretClient by running each call on Tokio’s blocking pool, so an async application reads the local vault without stalling its executor. Neither adapter interprets a call: arguments, results and errors pass through, and unit tests check that all 23 error codes survive both adapters and that the page methods and resolve reach the wrapped client’s own implementation rather than the trait defaults. The crate is the base the provider crates build on; it holds no provider itself.

Runtime and TLS. Tokio, with only the rt feature in the adapter crate. Providers that speak HTTP use reqwest with rustls-tls only, never OpenSSL, so the Linux release archives stay free of system TLS libraries.

Features. The provider crates are optional dependencies behind Cargo features remote (all providers) and provider-vault, provider-aws, provider-azure in sourceorrery and the CLI, on by default in the CLI, the Rust SDK and the release archives of the C ABI, so sourceorrery run and every SDK reach a remote vault out of the box; --no-default-features builds stay local-only for size. The features are introduced with the first provider (M4.3), when there is something for them to gate.

Audit on a remote backend. The provider’s own service holds the audit trail (Vault audit devices, CloudTrail). audit_events on a remote client returns unsupported with a hint naming where the log is; the metadata-read recording the local vault does is not emulated. How both logs are collected is M4.8.

Conformance as a function. The backend-independent lifecycle scenarios are functions over &dyn SecretClient in sourceorrery_conformance::lifecycle, and lifecycle(fresh) runs them all on fresh clients and names every failing scenario. The SQLite tests run the suite directly and through Blocking<AsyncLocal<SqliteVault>>, so the adapter pair is proven before any provider exists; each provider’s CI job runs the same function through its Blocking adapter against a local emulator or a test service. What stays backend-specific (version-id shape, purgeable vaults, the audit log, protectors, the registry, reopening, backups, file tampering) stays in the SQLite test files. Among the scenarios is the canary test that no error, listing or Debug output carries a value, so it runs against every backend.

A provider may legitimately differ from the local vault in what its service cannot express; each provider’s documentation lists its differences, and the scenarios a provider skips because of them are named in its CI job. audit_events returning unsupported is the one difference common to all.

Taken on the first implementation (this repository at this ADR’s date, a 4-core Linux container, Rust 1.97, clean --release builds):

What Cost
Crates added to the dependency graph of sourceorrery-core 1 (async-trait, a proc macro; its own dependencies were already present)
Crates added for sourceorrery-provider 3 more (tokio with rt only, pin-project-lite, memchr); 37 crates in its graph against 33 for core
Clean release build of sourceorrery-core 10 s
sourceorrery-provider on top of it (Tokio and the adapters) 2 s
libtokio.rlib, rt feature only 2.7 MB before linking
The release CLI with the HashiCorp Vault provider (remote on, reqwest with rustls, Tokio, the provider) against the same build without it 12.5 MB against 8.7 MB on x86_64 Linux: 3.8 MB for the HTTP and TLS stack, which the next providers share
The release CLI with the AWS Secrets Manager provider added (the official SDK, default-features = false plus rt-tokio and default-https-client, which keeps the legacy hyper 0.14 and rustls 0.21 stack out) 20.1 MB against 12.5 MB: 7.7 MB, the largest dependency tree in the workspace (198 crates in the provider’s graph), as the plan weighed
  • The CLI, the C ABI and every SDK gain remote backends without a change to their code: a Blocking adapter is a SecretClient. Async Rust applications get the local vault through AsyncLocal and remote ones directly.
  • sourceorrery-core depends on a proc macro and sourceorrery-provider on Tokio. Neither is reachable from a --no-default-features build of the CLI once the features exist; until then the CLI does not link the provider crate at all.
  • A Blocking adapter must not be called from inside an async task: Tokio refuses to block a runtime thread and panics. Async code uses the async shape directly. The adapter’s docs say so.
  • CONTRACT_VERSION stays "1": a second shape of the same operations is additive, and nothing about the C ABI, the vault file or an existing declaration changes (ADR 0007: a minor release, 0.3.0).
  • Every future backend is judged by the same code as the local vault, and a backend that fails is reported scenario by scenario.

The provider work originally planned for 0.3.0 is included in 0.2.0. The original release reference above is retained as decision history. This remains a minor release under ADR 0007: the new public Manifest and OpenOptions fields and AuditOperation variants can require changes to Rust callers. The C ABI and vault-file compatibility rules remain separate. This is historical release planning; the current roadmap describes the 0.3.0 baseline, including the superseding key and connection model.