Plugin development — shared foundation#
“I’m writing an ovstorage plugin and want to know how the ABI works and how to test my plugin.”
This directory is the entry point for plugin authors. The active
overlay is plugin-storage. This README is the canonical reference for
the foreign interface every plugin crosses, the Layer contract every plugin
implements, the type vocabulary every plugin speaks in, the conformance
harness every plugin is tested against, the build-and-load loop every
plugin runs through, and the ABI-stability rules every plugin commits
to at 1.0.
Where to read first. The single most useful starting point for a
new storage-backend author is
../plugin-storage/CONFORMANCE.md —
the cross-cutting behavioral contract for every storage Layer method
(what each method must do on success, the edge cases the type signature
doesn’t capture, and the capability-driven branch points). Read it,
then come back here for the C-ABI / type-vocabulary / build-and-load
machinery.
Foreign interface — the C ABI#
The C ABI is the canonical foreign interface every plugin crosses.
Even a Rust-authored plugin loaded by a Rust-authored host crosses it,
because the Rust ABI is unstable and a cdylib is the only shape that
survives independent compilation. The generated ovstorage.h /
ovstorage.hpp, the OvStorage_* type prefix convention, the
always-async callback shape, the OvStorage_Bytes / OvStorage_Error
ownership rules, and the catch_unwind panic-discipline contract
live in library-cpp.
(catch_unwind is the Rust standard-library guard wrapping every FFI
entry point; it converts a panic into a typed Result
(Status::Internal) before it can unwind across the C ABI. The
workspace keeps the default panic = "unwind" so those walls can
catch — together they keep a panicking plugin from corrupting the
host’s runtime state.)
The C ABI is the ABI stability layer, not a hand-authoring target. Authors write plugins in Rust (or C++ / Python wrappers as those mature). Hand-written-C plugin authoring is out of scope; the C surface is designed for stability and correctness, not C-only ergonomics.
For C ABI implementors, the typed AUTH_CREDENTIAL decoder is plugin support
code, not a host callback or a process-global host export. A C auth wrapper
compiles auth_credential.c, plugin_values.c, plat.c, and utf8.c from the
shipped source distribution into its own cdylib, so
ovstorage_plugin_auth_credential_decode and _free bind within the plugin.
Rust plugins receive the same helper implementation by linking the
ovstorage-plugin crate. Exact Layer ABI matching keeps the helper’s wire and
value layouts aligned with the loading host.
Layer shape — vtables, manifest, init#
Plugin loading is a two-symbol handshake. The host opens the cdylib,
validates the ovstorage_plugin_manifest_v1 static, then calls
ovstorage_plugin_init_v1(host_callbacks). The _v1 in those symbol
names is frozen and does not track the Layer ABI version; the manifest
carries that version. Init
returns PluginInitResultV1, which owns plugin-scoped state, a
PluginVTableV1, and the descriptors for every Layer kind in the
binary. Stack construction calls the matching create_backend,
create_wrapper, or create_router slot and receives a LayerHandle.
Every handle uses the uniform LayerVTableV1 operational surface.
The full Layer shape — Rust Layer and factory traits, request envelopes,
the Capabilities bitset, ReadResult / WriteStep, the manifest,
and C-ABI ownership rules — is reproduced below.
Surface boundary — host APIs vs plugin Layer#
The plugin Layer ABI is not the public Stack convenience API. It pins the
contract between a host process and a loaded Layer plugin.
Application-facing APIs live in library-rust, library-cpp, and the broker / REST protocol surface. They expose address-routed helpers such as
capabilities_for,read_bytes,read_stream,materialize, andcheck_access, plus local management surfaces for connections, aliases, visibility, and authentication. The Rust type vocabulary they speak in is documented under § Type vocabulary below.Host responsibilities live in the library or broker, and a backend plugin does not implement them: route resolution, alias expansion, cache lookup and herd collapse, cache commit / eviction, local materialization of a remote object, redirect execution, retry and backoff policy,
copy/renamefallback composed from read plus write plus delete, secret persistence, broker authorization, observability, and conversion between plugin-facing values and public API shapes. Each of those is a row in plugin-storage § Layer ownership registry, which names the owning wrapper Layer and the slots a backend leaves at theLayertrait default.Layermethods are what plugins implement. The Rust trait is#[async_trait]; object and connection operations receive a typedRequest<T>pluscancel: Option<CancellationToken>. Synchronous introspection coversroot_info_for,list_kinds,list_address_roots, andlist_connections. Capabilities flow inRootInfo, not through a separate backend-instance object.
A directory-facing Layer method receives the address the caller wrote,
and must derive its own directory key. Layer::create_directory,
Layer::delete_directory, Layer::list and Layer::watch_directory do
not get a trailing slash added for them. docs and docs/ name one
node and every host-side comparison knows that, but on a flat namespace
they may be two distinct objects — so choosing a spelling for you would
be choosing which object a delete_directory destroys. Use
address::directory_key on the decoded key; a backend that lists on the
key verbatim returns the contents of docsx for a listing of docs,
which is a disclosure and cannot be undone. Public stat is input-guided, but the host may answer an
unversioned exact-object stat with both cost flags clear from a
cached or freshly fetched one-level parent Layer::list entry when
the route supports one-level list and sets wants_list_backed_stat
(a stat with a cost flag set is answered only from a cached listing
whose recorded flags cover it). If that
list-backed path is unavailable or does not contain the object,
stat("foo") dispatches Layer::stat for exact foo, and only
if that returns NotFound does the host issue Layer::stat for
foo/; stat("foo/") arrives only as Layer::stat for foo/.
Permission/auth errors from the attempted spelling are final.
Plugins should not implement a second trailing-slash policy of
their own.
Factory traits construct the three composition shapes.
BackendFactory::create_backend has no child,
WrapperFactory::create_wrapper receives one owned inner handle, and
RouterFactory::create_router receives owned children. Connection
lifecycle is part of Layer, so wrappers can participate uniformly.
This split is load-bearing for the “same plugin binary in library and broker” rule: the plugin exposes one Layer contract; each host decides how that contract maps onto its public surface.
Type vocabulary#
The Rust type vocabulary every host and every plugin uses lives in
ovstorage_plugin: ObjectAddress, ObjectInfo, ObjectKind,
IfDestExists, ResolvedTarget, the options structs, the error
taxonomy, the connection / alias / auth types, LocalDelegate, and
SecretBytes. The dispatcher re-exports these so application code
does not need to depend on the plugin crate directly; this section
is the source of truth for their shape.
Core types#
Caller-facing addresses are url::Url — there is no separate
ObjectAddress newtype. The ovstorage_plugin::address module
exposes free helpers (parse, key, is_directory, to_directory,
parent_and_name, replace_prefix, is_prefix_of,
join_relative) that operate directly on Url. Version pins are part
of the address itself; the dispatcher does not expose a separate
version-selection value. URL canonicalization is whatever
url::Url::parse produces; the per-address invariants below still
apply.
pub struct ObjectInfo {
pub address: Url,
pub kind: ObjectKind,
pub etag: Option<String>,
pub etag_strength: EtagStrength,
pub version: Option<String>,
pub versioned_address: Option<Url>, // this version's pin; None when unversioned or not asked
pub size: Option<u64>, // None for directories
pub mtime: Option<SystemTime>, // None for DirectoryInferred
pub checksums: ChecksumSet,
pub effective_permissions: Option<EffectivePermissions>,
pub system_metadata: Option<SystemMetadata>,
pub user_metadata: Option<UserMetadata>,
pub modified_by: Option<String>,
}
pub enum EtagStrength {
Unspecified, // undeclared zero value; consumers treat as Weak
Strong, // equal tokens promise byte-identical content
Weak, // valid precondition, not proof of byte identity
}
pub enum ObjectKind {
File,
Directory, // backend with native directory inodes (POSIX, ADLS Gen2 HNS, Nucleus)
DirectoryMarker, // zero-byte marker object on a flat key namespace
DirectoryInferred, // directory inferred by the dispatcher from common prefixes; no backing object
}
impl ObjectKind {
pub fn is_file(&self) -> bool { matches!(self, Self::File) }
pub fn is_directory(&self) -> bool { !self.is_file() }
}
pub enum IfDestExists {
Overwrite, // default; replace any existing destination
Fail, // refuse if the destination exists (AlreadyExists)
MatchEtag(String), // refuse unless the destination's current etag matches
}
pub struct ResolvedTarget {
pub backend_id: BackendId,
pub resolved_address: Url,
}
pub struct ChecksumSet { /* zero or more (ChecksumAlgorithm, bytes) entries */ }
pub struct ChecksumAlgorithm(String); // normalized ASCII token, e.g. "sha256", "crc32c", "md5"
// Hand-rolled u32 newtype — kept off the `bitflags` crate so the C ABI
// shadow type and the Rust type stay in lock-step. `READ | WRITE |
// DELETE | UPDATE_METADATA` constants plus `BitOr` / `BitAnd` operator
// impls.
pub struct EffectivePermissions(u32);
impl EffectivePermissions {
pub const READ: Self = Self(1 << 0);
pub const WRITE: Self = Self(1 << 1);
pub const DELETE: Self = Self(1 << 2);
pub const UPDATE_METADATA: Self = Self(1 << 3);
}
pub type SystemMetadata = HashMap<String, String>; // backend-owned, opaque vendor pass-through
pub type UserMetadata = HashMap<String, String>; // caller-owned, set on write, edited via update_metadata
// `Body` is not `Clone`/`PartialEq`/`Eq` because `Stream`'s iterator is
// stateful. Plugins that haven't implemented chunked uploads return
// `ErrorCode::Unsupported` for `Stream` rather than draining it.
pub enum Body {
Bytes(Vec<u8>),
LocalFile(PathBuf),
Stream(BodyStream),
}
pub struct BodyStream { /* boxed Iterator<Item = Result<Vec<u8>>> + Send */ }
pub struct WriteResult {
pub info: ObjectInfo,
}
pub type Result<T> = std::result::Result<T, Error>;
pub type ReadStream =
std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>;
pub struct BackendId(pub String);
pub struct ByteRange { pub start: u64, pub end_inclusive: Option<u64> }
pub struct AccessOps { pub read: bool, pub write: bool, pub delete: bool, pub update_metadata: bool }
pub struct AccessDecision { pub allowed: bool, pub denied_ops: AccessOps, pub reason: Option<String> }
SystemMetadata and UserMetadata are separate types so ownership
is enforced at the type level — backends own SystemMetadata,
callers own UserMetadata.
Ownership and invariants:
Caller-facing
Urls are always the URL the application passed, or a caller-facing URL projected by the dispatcher from a backend result address. Plugins never return rewritten physical URLs to application code.ResolvedTargetis internal to routing, plugin dispatch, tracing, and errors. It is the full URL handed to the plugin after anyrewrite_toprefix swap, paired with the selectedBackendId.ObjectInfo.etag/version/size/mtimecontain only facts the backend returned or the plugin could verify from the backend response. The library never invents an ETag, version, size, or mtime to make preconditions easier.ObjectInfo.addressis canonical for the object being described. Public callers see it in the same address namespace they used; plugins return it in the resolved backend namespace they were handed. Forlist,watch_directory,list_versions, andget_latest_version, the dispatcher projects each returned address from the requested resolved prefix back into the caller-facing prefix. A backend result outside the requested scope is anInternalbackend contract violation, not a child the dispatcher should silently hide or rewrite.ObjectInfo.etag(together withversion/size/mtime) is the latest response metadata known for that address.ObjectInfo.system_metadatais backend-owned and read-only;ObjectInfo.user_metadatais caller-owned and mutable only throughwriteandupdate_metadata. Every backend that stores user metadata accepts a key matching[a-z_][a-z0-9_]*, wider names are backend-specific, and theovstorage_prefix inside that subset is reserved for host-attested keys — see User-metadata keys.ObjectInfo.kinddiscriminates files from directories. The three directory variants encode persistence and post-delete behavior:Directoryis a native directory inode (persists independently of contained objects;delete_directoryremoves it cleanly when empty);DirectoryMarkeris a zero-byte marker object on a flat backend (afterdelete_directoryremoves the marker, the directory falls toDirectoryInferredif children remain, or vanishes entirely if not);DirectoryInferredexists only because at least one child key shares the prefix (no backing storage object; vanishes once the last child is deleted). Callers asking “is this a directory?” usekind.is_directory().The
addressmodule exposes parser-level helpers used by dispatchers and bindings:is_directory,to_directory, andparent_and_name.parent_and_nameintentionally returnsNonefor directory-form URLs and any URL with a fragment. The dispatcher does not try to identify which query key is a provider’s version pin; the whole query-bearing address is non-cacheable for list-backed stat.
Options structs#
Etag-sensitive read / delete / metadata operations use a single
if_match: Option<String> precondition — an opaque etag string the
plugin compares against the current backend etag. Writes and
copy / rename use the richer IfDestExists enum on the destination
side. Copy and rename additionally accept if_source: Option<String>
to constrain the source. Listing, routing introspection, connection
management, alias management, and auth streams do not take etag
preconditions because they are not conditional object mutations.
The full vocabulary:
pub struct StatOptions {
pub full_metadata: bool,
pub versioned_address: bool, // cost knob: return the pinned address even if it costs a lookup
}
pub struct ReadOptions {
pub if_match: Option<String>, // etag
pub range: Option<ByteRange>,
}
pub struct WriteOptions {
pub if_dest: IfDestExists, // Overwrite (default) / Fail / MatchEtag(etag)
pub size_hint: Option<u64>,
pub user_metadata: Option<UserMetadata>,
pub message: Option<String>, // human-readable per-operation annotation
/* other fields... */
}
pub struct DeleteOptions { pub if_match: Option<String> }
pub struct UpdateMetadataOptions {
pub if_match: Option<String>,
pub allow_rewrite_emulation: bool,
pub user_metadata_set: HashMap<String, String>,
pub user_metadata_remove: Vec<String>,
pub message: Option<String>,
}
pub struct CopyOptions {
pub if_source: Option<String>, // etag constraining the source
pub if_dest: IfDestExists, // destination-side precondition
pub message: Option<String>,
}
pub struct RenameOptions {
pub if_source: Option<String>,
pub if_dest: IfDestExists,
pub message: Option<String>,
}
pub struct ListOptions {
pub recursive: bool, // false: one level; true: full subtree
pub max_results: Option<u32>,
pub page_token: Option<String>,
pub full_metadata: bool,
pub versioned_address: bool, // cost knob: return the pinned address even if it costs a lookup
}
pub struct ListVersionsOptions {
pub max_results: Option<u32>,
pub page_token: Option<String>,
}
pub struct CreateDirectoryOptions {}
pub struct DeleteDirectoryOptions; // unit struct; the Layer's directory-delete
// removes only the directory representation.
pub struct WatchDirectoryOptions {
pub recursive: bool, // default false
pub include_metadata_changes: bool, // default true
pub since: Option<WatchDirectoryCursor>,
pub poll_interval: Duration, // default 1s
}
Enforcement contract#
Plugins MUST enforce the options they receive. Most options pass through
verbatim. Watch options are the exception when a competing-consumer backend
self-coalesces via the SDK WatchCoalescer: it opens one recursive,
metadata-inclusive physical watch and post-filters that superset for each
logical subscriber. Therefore a recursive watch MUST include every event
that the same backend would emit for the corresponding non-recursive watch, and
a metadata-inclusive watch MUST include every event that it would emit with
metadata changes excluded. A backend must not replace direct-child events with
different directory-rollup events when recursion is enabled.
Field |
Op |
Plugin contract |
|---|---|---|
|
read / delete / update_metadata |
If |
|
copy / rename |
If |
|
write / copy / rename |
|
|
read |
Plugin MUST apply the range. For |
|
read |
Host-side cap on buffered read size. Plugins MAY ignore it; the host applies it to the returned stream/bytes. |
|
write |
Hint for routing decisions (e.g., inline vs. multipart). Not a contract; backends may treat it as advisory. |
|
mutating |
Plugin MUST persist if its backend supports user metadata, and MUST reject a non-empty map with |
|
mutating |
Plugin SHOULD attach as a per-operation annotation if the backend supports one (e.g., a version commit message); otherwise store it as user metadata under the shared |
Silently ignoring any of the MUST fields is a bug — a caller’s
if_match precondition that the plugin ignores will let stale reads
succeed and lose the optimistic-locking guarantee. If the wire
protocol can’t carry a precondition, the plugin must emulate it
(read then compare, or refuse with Unsupported) rather than drop
the field on the floor.
For the common “I just did stat, now read with the same etag”
case, the caller sets if_match: info.etag.clone() directly on the
relevant options struct. There is no if_matches(&info) constructor
helper; every options field is constructed by struct-literal
expressions. For writes that should refuse to overwrite, the caller
sets if_dest: IfDestExists::Fail; to constrain a write against an
existing etag, if_dest: IfDestExists::MatchEtag(etag).
Directory-facing operations accept either spelling for a directory
address. create_directory("foo") is equivalent to
create_directory("foo/"); the same rule applies to
delete_directory and list. The canonical directory form
appends the slash before any query or fragment
(foo?x=1 becomes foo/?x=1), and returned directory ObjectInfo
/ list child addresses use the canonical slash form.
stat uses the caller’s spelling to choose probe order because it
can describe either an object or a directory. stat("foo") first
stats the exact object address foo; only if that returns
NotFound does the library try the canonical directory form
foo/. stat("foo/") stats only the directory form and never
falls back to exact object foo. If object foo and
directory/prefix foo/ coexist, stat("foo") returns the object
and stat("foo/") returns the directory. Permission/auth errors
from either probe are final.
create_directory is idempotent: it makes the requested directory
exist according to the backend’s directory model and returns
success when the target directory representation already exists.
Layer::delete_directory removes only the backend’s directory
representation: a real directory must be empty, and a
flat-object-store marker removal leaves children untouched. The Layer
contract does not expose a recursive subtree-delete.
Version-pinned addresses#
Version selection lives in the Url, not in a separate side value
type. A backend that supports historical reads uses its native address
syntax (?versionId=..., ?generation=..., Nucleus checkpoint query
forms, and so on) as the pin. ObjectInfo.versioned_address carries
that pin for the version an ObjectInfo describes: list_versions and
get_latest_version return it equal to ObjectInfo.address, which is
already pinned; stat and list return it when the backend has the id
in hand or when the caller sets the versioned_address cost flag on
StatOptions / ListOptions; a stat of an address that already
carries an immutable pin echoes it; every directory kind, every
non-versioning backend, and every write / update_metadata /
create_directory result returns None. The dispatcher projects the
route prefix of address and versioned_address between backend and
caller namespaces and does not interpret the pin. A pin is not an
identity: it is refused as a mutation target
(reject_pinned_for_mutation), grants no capability, and resolves only
as long as the backend’s retention and the stack’s alias rules keep it
addressable.
ChangeKind and WatchDirectoryCursor#
pub enum ChangeKind { Created, Modified, Deleted, MetadataChanged }
pub struct WatchDirectoryCursor(pub Vec<u8>);
ChangeKind is shared between the Layer’s BackendChangeEvent
and the public ChangeEvent.
Connection-management types#
pub struct ConnectionId(pub String);
pub struct StorageBackendKindDescriptor {
pub kind: String,
pub display_name: String,
pub description: Option<String>,
pub config_schema: Vec<ConfigField>,
pub credential_schema: Vec<CredentialField>,
/// Named credential entry-points referencing `credential_schema` keys;
/// empty means the host walks every credential field individually.
pub credential_methods: Vec<CredentialMethod>,
pub icon: Option<Vec<u8>>,
pub supports_runtime_add: bool,
pub supports_user_metadata: bool,
}
pub struct ConfigField {
pub key: String,
pub display_name: String,
pub kind: ConfigFieldKind,
pub required: bool,
pub default: Option<ConfigValue>,
pub help: Option<String>,
pub example: Option<String>,
pub group: Option<String>,
/// Hidden from interactive flows that only surface common fields; still
/// parsed from TOML and honored at runtime.
pub advanced: bool,
}
pub enum ConfigFieldKind { Url, Text, Integer, Bool, Enum { source: EnumSource }, Path }
pub enum EnumSource { Static(Vec<String>), Discovered }
pub struct CredentialField {
pub key: String,
pub display_name: String,
/// Literal text, or a `${NAME}` reference resolved from the process
/// environment at TOML-load time.
pub default: Option<String>,
pub help: Option<String>,
/// Hidden from flows that only surface common fields.
pub advanced: bool,
}
pub struct CredentialMethod {
pub key: String,
pub display_name: String,
pub fields: Vec<String>,
pub help: Option<String>,
pub advanced: bool,
}
pub enum ConfigValue {
String(String),
Int(i64),
Bool(bool),
/// Reserialized TOML payload — a nested table or array of tables
/// captured opaquely by the host. The plugin parses with
/// `toml::from_str` on receipt.
Toml(String),
}
pub struct ConnectionRequest {
pub backend_kind: String,
pub config: HashMap<String, ConfigValue>,
pub credentials: SecretBundle,
pub persist: bool,
pub display_name: Option<String>,
}
pub struct SecretBundle { pub fields: HashMap<String, SecretValue> }
pub enum SecretValue {
Bytes(SecretBytes),
OAuthToken { token: SecretBytes, refresh: Option<SecretBytes>, expires_at: Option<SystemTime> },
File(SecretBytes),
MtlsCertPair { cert_pem: SecretBytes, key_pem: SecretBytes },
SystemIdentity,
}
pub struct Connection {
pub id: ConnectionId,
pub backend_kind: String,
pub display_name: String,
pub source: ConnectionSource,
pub capabilities: Capabilities,
pub current_addresses: Vec<Url>,
pub auth_state: ConnectionAuthState,
pub last_probed: Option<SystemTime>,
pub user_metadata: UserMetadata,
pub owning_target: Option<String>,
}
pub enum ConnectionSource {
Static { layer: ConfigLayer },
Runtime { persisted: bool },
BrokerDelivered { broker_principal: String },
}
pub enum ConnectionChange {
Added(Connection),
Removed { id: ConnectionId },
Updated(Connection),
Snapshot(Vec<Connection>),
}
Alias types#
pub struct AliasId(pub String);
pub struct AliasRequest {
pub from: Url,
pub to: Url,
pub visibility: AddressVisibility,
pub persist: bool,
pub display_name: Option<String>,
pub user_metadata: UserMetadata,
}
pub struct Alias {
pub id: AliasId,
pub from: Url,
pub to: Url,
pub visibility: AddressVisibility,
pub source: AliasSource,
pub state: AliasState,
pub display_name: Option<String>,
pub user_metadata: UserMetadata,
}
pub enum AliasState {
Live, // `to` resolves to a non-alias row
Dangling, // `to` does not resolve currently
ChainTooLong { reason: String }, // `to` resolves to another alias
}
pub struct AddressVisibilityOverride {
pub address: Url,
pub visibility: AddressVisibility,
pub persisted: bool,
}
Connection authentication types#
pub enum ConnectionAuthState {
Authenticated { last_authenticated_at: SystemTime, expires_at: Option<SystemTime> },
AwaitingAuth { reason: AuthReason, last_attempt: Option<AuthAttempt> },
AuthFailed { error: Error, attempts: u32 },
Anonymous,
}
pub enum AuthReason {
NeverAuthenticated,
RefreshTokenExpired,
RefreshTokenRevoked,
CredentialsRotated,
ManuallyRequested,
BackendUnreachable,
Unknown { details: String },
}
pub struct AuthAttempt {
pub at: SystemTime,
pub error: Option<Error>,
}
pub enum AuthEvent {
OpenBrowser { url: String, expires_at: SystemTime },
DeviceCode { user_code: String, verification_url: String, expires_at: SystemTime, interval: Duration },
Progress { message: String },
Succeeded { connection: Box<Connection> },
Failed { error: Error },
Cancelled,
}
/// Host-declared limit on what kind of interactive auth the plugin
/// may attempt. Set by the host; threaded into
/// `Layer::authenticate_connection` so the plugin picks the right OAuth subflow
/// (or fails fast). Default `Browser`.
pub enum InteractiveAuthCapability {
/// CI / render workers / sandboxed services. Plugins emit
/// `Err(AuthRequired)` immediately; **no `AuthEvent` ever lands
/// on the wire**.
None,
/// SSH session / container shell. Host can show URLs and codes
/// for the user to act on a different device but cannot bind a
/// 127.0.0.1 redirect listener. OAuth-IDP plugins use device
/// flow (RFC 8628). PKCE is forbidden in this mode.
Headless,
/// Desktop GUI / local terminal. Host can both launch a browser
/// and bind a redirect listener. PKCE is preferred.
Browser,
}
Layer::authenticate_connection carries the capability in its request:
async fn authenticate_connection(
&self,
request: Request<AuthenticateRequest>,
cancel: Option<CancellationToken>,
) -> Result<AuthEventStream>;
Plugin behavior matrix:
Capability |
OAuth-IDP plugins |
Long-flow plugins |
Anonymous / non-interactive |
|---|---|---|---|
|
|
|
|
|
Device flow (RFC 8628) |
URL+nonce-poll (works since the user can open the URL on any device) |
|
|
PKCE (or device fallback if the IDP advertises only device) |
URL+nonce-poll |
|
The last column is capability-independent because the capability describes what
the host can drive, and these backends have nothing to drive under any of
them: their credentials arrive with the connection. Unsupported is the code
Layer::authenticate_connection documents for it, and because the error is
raised before any event stream exists, the connection’s state is untouched — a
connection parked by a refused credential stays parked rather than being
reported Authenticated on no grant and no probe. In-tree, azure, gcs,
s3 and opendal answer it from their connection-auth drivers, as does the
broker client for its direct-endpoint addresses — anything that is not
http(s)://, so grpc://, grpc+tcp://, grpc+tls://, unix:/… and
npipe:/… —
which have no OAuth surface and take whatever credential bring-up resolved. A
layer with no connection-auth driver at all — file, http — answers it from
the Layer leaf default, which is the same code. huggingface sits between
the two: its driver’s interactive answers Unsupported, but the layer keeps
the leaf default for authenticate_connection — the simpler honest spelling
of the same answer.
Answer this column before any capability check. Whether a backend has an
interactive flow is a property of the backend, not of what the host can drive,
and the two codes mean different things to a caller: Unsupported says no flow
was ever offered, so nothing ran and the registration stands, while
AuthRequired is an ordinary failure of a flow that does exist. What a host
does with that is its own policy — ovstorage connect keeps the connection and
reports its state, while ovstorage reauth, whose whole purpose was to run a
flow, reports the refusal.
The capability flows transparently across the broker via the
x-ov-iauth: <browser|headless|none> gRPC metadata header
(HPACK-indexed by Tonic’s interceptor; ~1-2 bytes per call after the
first).
LocalDelegate#
pub struct LocalDelegate {
pub path: PathBuf,
pub info: ObjectInfo,
}
materialize returns a path to an existing file on local disk —
either a cache entry the library already materialized, or, for the
file plugin, the source file itself. The caller opens the file
directly: File::open(&local.path), mmap for random access, hand
the path to a child process. No bytes flow through the Stack
API.
Cache leasing (the RAII handle that pins the file in place against
GC eviction) and time-bounded delegate expiry are not modelled on
the plugin-facing struct. The struct carries only the path and
ObjectInfo; the cache layer keeps lease bookkeeping out of band.
Plugins that hand back a path to a file with finite lifetime encode
that out of band; there is no typed lease/expires_at extension.
SecretBytes#
pub struct SecretBytes(/* heap-allocated, zeroized on drop */);
A newtype around a heap-allocated buffer that:
Zeros its memory on drop (
zeroize+Drop).Implements
Debugas the literal stringSecretBytes(<redacted>). There is noDisplayimpl — values do not silently format into log lines.Derives
Cloneso credentials can be threaded through plugin callbacks;into_innerconsumes the wrapper without zeroizing (the caller takes responsibility), andas_bytesborrows under the existing Drop guard.Serializes only over the broker’s authenticated gRPC channel, never to disk in plaintext, never to logs. The channel’s own transport authentication is the broker listener’s
authn_mode; this bullet makes no additional mTLS guarantee.Crosses the C ABI as
ffi::SecretBytes. The two live on different heaps — an ABI buffer on the shared process heap (see Allocator contract), aVecon the Rust global allocator — so a secret is copied across rather than adopted in place, in both directions. Each copy erases its own source:ffi::SecretByteszeroizes on drop, andmarshal::descriptor::secret_bytes_to_ffizeroizes theVecit consumed. Do not “optimize” either direction back into an in-place adoption (Vec::from_raw_partsover an ABI buffer, or leaking aVecinto one): that hands one allocator’s block to the other and is the corruption the allocator contract exists to prevent.
The runtime properties on SecretBytes (zeroize-on-drop, redacted
Debug, no Display impl, no serde::Serialize impl) carry the
redaction guarantee through the type system: a hypothetical
audit-record serde::Serialize would fail to compile if it tried
to take a SecretBytes field, and the redacted Debug keeps trace
lines clean.
Error model#
The shared error taxonomy lives in GLOSSARY.md.
The categories: generic, retryability rules, brokered, cache/state,
preconditions, routing, credential, authentication, and content.
Plugins translate backend failures into the shared ErrorCode enum
and return promptly; retry, backoff, circuit breaking, cache
fallback, and user-visible policy are host responsibilities unless a
backend protocol has an indivisible internal retry required for a
single operation to complete safely.
Policy retry has one owner because backoff composes badly. Two layers each retrying on independently-tuned schedules multiply their attempt counts rather than adding them, and neither can report the true total — so the bound a caller was promised and the numbers an operator reads both stop being true. Keeping it in one place is what makes a total bound expressible at all, and it is why policy retry is a composed Layer rather than something each plugin implements.
RetryWrapper::list_stream has two delivery-safe retry phases under that one
policy owner: opening the stream, then establishing its first item when the
consumer polls. Each phase receives the configured max_attempts; the stream
opened by the first phase counts as the first attempt of the second, so the
combined bound is 2 * max_attempts - 1 inner opens. No retry occurs after the
first item is delivered. Size max_attempts for that combined bound when a
backend defers its first page request until polling.
Two other kinds of re-attempt are not that, and are not forbidden by it. A
protocol may require an indivisible internal retry for one operation to complete
safely, as above. And the plugin SDK’s ConnectionSet::with_recovery runs a
bounded credential recovery. That one is the plugin’s to drive, not the
host’s: you wrap a call in connection_set.with_recovery(&id, || ...), and if
it fails with a driver-classified recoverable credential error the connection is
refreshed and the call replayed exactly once before the error surfaces — so a
stat or read can be attempted twice without any policy Layer being involved.
The replay is not guaranteed by routing alone: it happens only if the recovery
step decides to retry, and a driver whose credentials are static keeps refresh
at its Unsupported default, so that attempt normally surfaces the error
instead. Write the closure so that a second call is safe, not so that a second
call is certain.
It covers only what you route through it, and the criterion for routing
something through it is replayability, because the closure may run a second
time. The first-party Layers that own a ConnectionSet therefore keep write,
write_stream and write_redirect out of it: the body is consumed by the first
attempt, or the mutation spans rounds this call does not own. continue_write
is the judgement call — it carries no body of its own, so a Layer whose
finalization is keyed by a server-side identifier can route it through recovery,
and one does. A credential error from an op you left outside surfaces with no
refresh and no replay. Neither re-attempt is a tunable backoff schedule, which
is the thing that must not be duplicated.
Connection lifecycle errors#
Connection-management methods (add_connection, remove_connection,
update_connection_credentials, authenticate_connection) report errors as the same flat
ErrorCode shape used elsewhere; there is no separate
ConnectionError type. The mapping from lifecycle stage to typical
ErrorCode value:
Lifecycle stage |
Typical |
|---|---|
Config schema mismatch / missing field |
|
Config conflict / route already exists |
|
IdP rejects supplied credentials |
|
No credentials reached the plugin |
|
Plugin requires interactive auth |
|
Refresh token revoked / cancelled |
|
Refresh token expired |
|
Backend unreachable / 5xx / network blip |
|
Plugin must be re-instantiated |
|
Plugin’s internal setup fails |
|
This list is informative, not exhaustive — every plugin is free to
return any ErrorCode value that preserves recovery semantics. The
host’s lifecycle layer (auth refresh, re-instantiate-on-IncompatibleType,
retry on Transient) reads the code and reacts; the message field
carries human context.
Addressing a connection: finding the layer that owns it#
A ConnectionKey is (target, id), where target is a graph-unique layer
instance name. A Connection reports both halves: owning_target is the
target a connection op must address to reach id. So a caller holding a
connection — a listing, a snapshot from list_connections, a route conflict —
can address it directly, without recovering the owner first.
Who fills it. The layer that owns the connection sets it, at the moment it
builds the row. That is the only party with the answer rather than an inference,
and stamping it at construction is what carries it through everything downstream:
a ConnectionSet clones the stored row, so the snapshot and every
ConnectionChange frame inherit it for free.
Two host-side tiers sit under that, and neither overwrites a value a layer reported:
A loaded plugin’s rows are filled by the host from the plugin’s sole
owned_targetsentry, where it declares exactly one.A
Router’s fan-out fills from the child that produced the row, again only where that child owns a single target. The Router is the one place that both knows which child answered and holds thetarget -> childtable the answer has to agree with.
None means the row could not be attributed here — never that it has no
owner. Stated as a rule rather than a list of shapes, because the list goes
stale every time a mechanism is added: the host attributes a row only for a
layer declaring exactly one owned target, and the Router replaces or clears a
name it routes to a different child. A row is None wherever neither the
layer nor those two tiers produced a name — which today covers a composite that
declined to attribute its own rows, a layer declaring zero owned targets, a
native leaf outside a Router that simply does not stamp, and a misreport the
Router could not correct.
Attribute your own rows, and do not rely on the host fill to do it for you.
The fill is keyed on owned_targets() reporting exactly one entry, and that is
a narrower question than “can this layer be addressed”. If your owned_targets
is gated on anything else — the in-tree broker-client and services-client layers
return empty unless supports_runtime_add is set, while still answering
remove_connection and authenticate_connection keyed on their own name — then
the host has nothing to fill from and your rows arrive unattributed unless you
stamp them.
What to put in it: the name a connection op must address to reach this row.
That is the field’s definition, and it is what your remove_connection,
update_connection_credentials and authenticate_connection slots gate on.
Usually it is one of your owned_targets() entries; where those two answer
different questions, as above, the addressable name is the correct one. A
composite sets the internal child’s name, never its outer host name.
A wrong answer is caught only where the graph can see it is wrong. A name
this Router routes to a different child is replaced with the one that reaches
you — which it can do only where you declare exactly one owned target, since
that is the one case with a knowable substitute. Declare any other number,
several or none, and the row is cleared instead. Either way a warn goes to
ovstorage.router and the row shows a corrected target or an empty column
rather than reaching your slot.
A name this Router does not route at all is passed through untouched, because
owned_targets may legitimately be narrower than what you answer for. Under a
Router that name is still unaddressable, and the refusal is the Router’s rather
than yours: every connection op routes by route_target first, and that table
is built only from owned_targets, so a key naming a target outside it is
answered target layer not found before your slot is called. The diagnosis is
graph-level, not local. Through a plain wrapper chain — no Router between the
caller and you — the lifecycle ops forward down the chain and your slot does see
the key, which is where a stamped name outside owned_targets earns its
keep.
Do not recover a target from a proxy. Each of the obvious ones is lossy in a different direction, and each has produced a wrong answer in practice:
display_nameis free-form, may be absent, and a backend may default one the caller never supplied. Comparing against it fails for the ordinary unnamed case and matches synthesized names it should not.backend_kindis a kind, not an instance. A graph with two layers of one kind — a router over two HTTP tenants — has no answer, so any resolution built on it is not merely imprecise, it is unreachable on the graphs that matter most.
Recovering a target for an unattributed row#
Where owning_target is None, the two mechanisms below recover it, chosen by
what the caller actually holds. Both are the fallback for that one shape, and
the rules that make the second of them safe are scoped to it.
1. Address in hand — derive it. When the caller has the address the operation is about (repairing a route conflict, acting on a prefix an operator typed), ask the graph:
[
Layer::root_info_for(url)] gives the servingRootInfo, whoseconnection_idis the connection. Guard the answer with node equality (address::same_node(&root.root, url)): lookup is longest-prefix, so a broader root that merely contains the address comes back as a hit, and it belongs to a different connection. Acting on it would mutate a tenant that was working.RootInfo::owning_targetgives the target — the same answer, not a second one. The id and the layer holding it are two facts about one route, so reading both from theRootInfoabove is what keeps them consistent: a live route change cannot race two lookups into pairing one backend’s id with another backend’s target.[
Layer::owning_target_for(url)] is the fallback, for a backend that reports a connection but publishes noowning_targeton its roots. Do not prefer it. It is not the same asRootInfo::layer_kind, and where it bottoms out in theLayerdefault it applies a leaf rule —leaf_owning_target: the soleowned_targetsentry, elsename()— so a loaded composite backend reporting several owned targets answers with its outer host-layer name, which noroute_targetcan find. A key built on that failsNotFoundagainst a graph that holds the connection. This is why connection attribution usessole_owning_targetinstead, which declines rather than guessing: aRootInfocarries the address it is about and a caller has this fallback, and a connection parked inAwaitingAuthhas neither.
This resolves for a connection in any auth state, but only where the layer publishes roots unconditionally. Where it does not, use the second mechanism.
2. Id in hand — discover it, by attempting the operation. When the caller
has only the id — removing a connection, re-driving authentication — there may
be no address to derive from, because a parked or refused connection may publish
no root. Enumerate [Layer::owned_targets()], attempt the real operation against
each candidate ConnectionKey, and treat ErrorCode::NotFound as “not mine, try
the next”.
Prefer mechanism 1 wherever it applies, and treat this as the last resort it is. Every rule below exists because this probes for ownership by side effect rather than reading it: the operation is what asks the question, so a layer that starts work before checking whether it holds the key has already acted on somebody else’s connection, and no answer the scan gets afterwards can take that back.
The scan is sound only while the id names one connection in the graph, and
that is not guaranteed. A connection id is unique only within its owning
target — that is the contract, and it is what makes (target, id) the identity
rather than id alone. Most backends mint ids through
ovstorage_plugin::routing::fresh_id, which stamps a process-global counter and
so collides with nothing; but a layer may take a caller-supplied id instead, and
the in-tree alias layer does exactly that, so two layers can genuinely both hold
rule. A scan across such a pair would act on whichever owned_targets()
yielded first, which is not the connection the caller resolved.
So a caller must check before scanning: count how many connections in the listing carry the id — every one of them, attributed or not — and scan only if the answer is one. Where it is not, the exact derivation above is the only permissible answer, and where that is also unavailable the honest outcome is to tell the operator the id cannot be told apart — not to remove or re-authenticate a namesake.
An attributed namesake does not drop out of that count, and the reason is
that the scan enumerates targets, not rows. It walks
[Layer::owned_targets()] and attempts the operation against each, so the field
telling two rows apart does not take either one’s target off that list.
The count is therefore deliberately conservative: it refuses on some graphs
where a scan would have been harmless, because deciding otherwise means knowing
which namesakes the scan will actually reach, and a caller holding a listing
does not know that. Excluding an attributed namesake would leave a graph holding
an attributed, live http_b/rule alongside an unattributed composite row also
answering to rule — the count comes back one, the scan runs, reaches http_b,
and drives an interactive sign-in against a tenant nobody addressed. The
opposite case exists too and is why the rule is stated as conservatism rather
than as a fact about targets: a layer that stamps its own rows while declaring
no owned targets is not on the scan’s path at all, so counting it costs a
refusal that was not needed. Erring that way is the cheap direction.
Three rules keep the scan honest:
NotFoundadvances it. That is the answer this contract asks for, and the one a scan can act on.Only a
NotFoundraised before the operation began. For a streaming operation likeauthenticate_connection, aNotFoundfrom a flow already in progress means something else entirely; retrying that against a sibling would drive a second flow over a failure that had nothing to do with ownership.Unsupportedadvances it too, but is remembered. It is not evidence either way: theLayerdefault for an unimplemented slot answers it without reading the key, so a layer that has no interactive flow at all says the same thing for a connection it has never seen as for one it holds. A scan that took it as final would stop at whichever such layer came first and report “this backend has no interactive flow” about a backend it never asked. So keep it as the fallback answer, let a later candidate that genuinely owns the connection win, and return the remembered one only if none does.Every other error ends the scan. A layer that fails with some other code got far enough to fail meaningfully, and continuing past it would bury a real diagnosis under a final
NotFound.
What this means for a plugin: answer NotFound for a ConnectionKey you do
not hold, and answer it before doing anything else. A layer that instead
starts work degrades every connection in the graph, not only its own — a scan
cannot undo a flow it should never have driven. Answering a different code is
survivable for Unsupported, because the host treats that as inconclusive for
the reason above, but nothing else is: the scan stops there.
Nothing enforces that, so the reach of the obligation is what limits the
damage. The host cannot check it — the whole point of the Unsupported rule is
that a layer may answer without reading the key, so “did you look first?” is not
observable from outside — and a layer that gets it wrong passes its own tests,
because in isolation there is no sibling to damage. Reporting owning_target
is what keeps a scan from being aimed at you in the first place, and it is the
better half of the answer for a plugin author: attribute your own rows, and a
caller addresses you directly instead of asking every layer in turn. Where you
cannot — and a composite that genuinely cannot attribute a row is the case —
implement remove_connection, authenticate_connection or any other keyed
connection verb with a test that asserts NotFound for an id you never
registered, on a fresh instance that has done no work. That is the only place
this obligation is checkable.
URL canonicalization#
An address names a node, and the address a plugin receives has already
been normalized. Canonicalization runs at parse time and again at the
Stack boundary, so every layer and every plugin sees one spelling of a
given object.
What canonicalization does to the path#
The path is percent-decoded to bytes, dot segments are resolved, runs of
/ are collapsed to one, the fragment is dropped, and the result is
re-encoded so that no byte can be decoded a second time. A %2F
therefore becomes a separator, which is what lets a dot segment hiding
behind one resolve.
The trailing slash is never touched, in either direction. x and
x/ name the same node, and every site that compares two addresses
knows that; but on a flat store they may be two distinct objects, so the
spelling a caller wrote survives to the backend.
Elsewhere in the URL: the scheme is lowercased, the host is lowercased
and IDN-punycoded, default ports are stripped, and an empty-authority
path gains its /.
Keys that are not expressible as a URI path#
A backend key containing a dot segment, a doubled separator, or a
literal / inside a segment cannot be named by an address: any address
built for it re-derives to a different key. Such an object is
unaddressable, and the host’s emitter drops it from a listing with a
warn! rather than handing out an address that names something else.
Invisible is the deliberate choice — the alternative is a delete of
the address list just returned destroying the wrong object.
Key segments are canonicalized rather than preserved byte-for-byte
— escapes are decoded, normalized and re-encoded once. So
s3://b/pub%20x names the key pub x, and a key that literally
contains the three characters %20 is named by escaping the escape:
s3://b/pub%2520x.
The plugin emitter obligation#
A plugin that turns a backend key into an address must escape the key,
and must drop any key whose emitted address does not re-derive to it.
The host cannot check this for you: the original key never crosses the
ABI, so there is nothing to compare against. address::join_relative
(and join_relative_bytes, for a key that is not valid UTF-8) does both
halves — it escapes % and refuses a key that is not addressable — so
call it rather than building the address by hand, and skip the entry
with a warn! when it refuses.
The conformance suite asserts this obligation. See ### Address projection and ## Conformance harness below.
The host refuses an address whose own spelling the URL parser
rewrites. A returned address is the plugin’s claim about which object
it named, so the host validates it instead of normalizing it — rewriting
a claim is retargeting. Two things can rewrite one, and both are
refused: the URL parser resolves . and .. segments (including the
%2e spellings), removes ASCII TAB, LF and CR from anywhere in the
string, trims a leading or trailing space, and folds \ to / on
http, https, ws, wss, ftp and file; and ovstorage’s own path
canonicalization decodes escapes and collapses separator runs. So
s3://bucket/public/../private/secret is refused, because it names
private/secret while reading as a key under public/.
Nothing built with join_relative or serialized from a Url can hit
this: a Url’s serialization has its dot segments already resolved,
carries no raw TAB, LF or CR and no untrimmed edge, and carries no \
in the region where a special scheme would fold one. (A \ in a query
or a fragment is untouched by the parser and is accepted, because it is
what the Url itself serializes to.) Only an address assembled by
string formatting can be refused, and such an address was about to name
a different object than the key it came from.
Object keys are bytes#
address::key returns the decoded path as bytes, because a key is an
arbitrary byte sequence and the file: backend resolves one byte for
byte. A backend whose wire API cannot carry those bytes calls
address::key_utf8, which rejects such a key with InvalidArgument
rather than converting it lossily — a lossy conversion would make the
backend fetch one object for two distinct addresses, which authorization
treats as two.
Mutating ops whose backend wire format cannot carry a version pin
must reject any caller-supplied version-pinned address with
InvalidArgument rather than silently dropping the pin and writing to
head. The shared helper
ovstorage_plugin::url_helpers::reject_pinned_for_mutation takes
the resolved URL and a list of version-pin keys (versionId,
generation, versionid, version, checkpoint, …) and returns
the typed error so each plugin’s mutating-op surface stays
consistent.
ReadResult: the read shapes#
Every plugin’s read Layer call returns one of four shapes. The
library’s typed helpers (read_bytes, read_stream, materialize)
consume the enum and present the appropriate result to the caller;
applications never branch on the variant directly.
pub enum ReadResult {
Bytes { bytes: Vec<u8>, info: ObjectInfo },
Stream { stream: ReadStream, info: ObjectInfo },
LocalDelegate(LocalDelegate),
Redirect(ReadRedirect),
}
LocalDelegate— bytes are already on local disk under a leased path. Returned from cache hits and from thefileplugin. The library hands it back unchanged;read_streamopens the file and emits 64 KiB chunks.Bytes— the plugin returns an in-memory byte buffer plusObjectInfo. Used for small responses (under the per-plugin small-response threshold) and for ranged reads where the caller already bounded the slice.read_streamwraps it as a single iterator chunk.Stream { stream, info }— async chunk-stream for whole-object reads above the small-response threshold. Returned by every cloud plugin’s whole-object path. Peak host memory is bounded by the stream’s chunk size, never by object size — this is the substrate that prevents the “redirect a 10 GiB read and OOM the host” pattern.Redirect— the plugin returns a short-lived HTTP request envelope plus response-parsing hints. The host follows the request through the shared redirect follower.
The conformance harness asserts that streaming reads from a multi-GiB fixture peak at chunk-size memory rather than object-size memory; plugins that rebuild buffers internally fail this assertion even if they advertise streaming.
Plugin Layer contract#
The checked-in Rust Layer trait is ovstorage_plugin::Layer. One operational
trait covers backends, wrappers, and routers. A wrapper returns its inner
handle from inner_layer; default method bodies then delegate, so it
implements only the operations it intercepts. Backend and router leaves
keep the default Unsupported behavior for unimplemented operations.
Layer is #[async_trait]. Every asynchronous operation receives a
typed Request<T> (or, for the introspection queries, an &Extensions
fact bag) plus cancel: Option<CancellationToken>. Introspection splits
by cost: the three runtime-state queries — root_info_for,
list_address_roots, and list_connections — are async and cancellable,
because a connection-owning backend resolves them against live connection
state and a router fans them out across its children. The one structural
introspection query, list_kinds, is synchronous: the reachable kind
set is fixed manifest/topology metadata.
The structural methods — name, descriptor, inner_layer,
owned_targets, and list_kinds — are synchronous and bound by a
no-I/O contract: they return bounded, in-memory data and must not
perform I/O, block, spawn or enter an async runtime, or (in bindings)
touch the Python GIL. They are safe to call from any thread with no
ambient executor. The C projection is one uniform LayerVTableV1:
synchronous methods write caller-owned out values; asynchronous methods
invoke on_complete exactly once. Plugins export the surface with
ovstorage_layer_plugin! (see § Plugin macros).
#[async_trait::async_trait]
pub trait Layer: Send + Sync {
// Structural (synchronous, no-I/O): bounded in-memory metadata only.
fn name(&self) -> &str;
fn descriptor(&self) -> LayerKindDescriptor;
fn inner_layer(&self) -> Option<&LayerHandle> { None }
fn owned_targets(&self) -> Vec<String> { ... }
fn list_kinds(&self, cx: &Extensions) -> Result<Vec<LayerKindDescriptor>>;
// Runtime-state introspection (async, cancellable).
async fn root_info_for(&self, url: &Url, cx: &Extensions, cancel: Option<CancellationToken>)
-> Result<RootInfo>;
async fn list_address_roots(&self, cx: &Extensions, cancel: Option<CancellationToken>)
-> Result<(RootInfoSnapshot, Option<RootInfoUpdateStream>)>;
async fn list_connections(&self, cx: &Extensions, cancel: Option<CancellationToken>)
-> Result<(ConnectionSnapshot, Option<ConnectionUpdateStream>)>;
async fn stat(&self, request: Request<StatRequest>, cancel: Option<CancellationToken>) -> Result<ObjectInfo>;
async fn read(&self, request: Request<ReadRequest>, cancel: Option<CancellationToken>) -> Result<ReadResult>;
async fn write(&self, request: Request<WriteRequest>, cancel: Option<CancellationToken>) -> Result<WriteResult>;
async fn list(&self, request: Request<ListRequest>, cancel: Option<CancellationToken>) -> Result<Vec<ObjectInfo>>;
// The remaining object and connection operations use the same envelope.
}
Plugin load and inspect are synchronous, local loader operations: opening a
cdylib, validating its manifest, and running its init entry point read only
in-process state and perform no backend network discovery. Discovering a
backend’s live roots or connections happens later, through the async
list_address_roots / list_connections queries against a built Stack.
The complete trait in ovstorage-layer/src/traits.rs is authoritative.
Factories implement descriptor() plus exactly one creation method and
return LayerHandle (Arc<dyn Layer> in Rust). A LayerKindDescriptor
declares its layer_type and whether the kind accepts connections.
list shape contract#
Two shapes, picked by ListOptions.recursive:
recursive: false— one-level listing. Plugins emit oneObjectInfofor every object directly under the prefix and oneObjectInfofor every immediate child container (native directory, marker, or inferred prefix); the plugin populateskindperObjectKind(Directory/DirectoryMarker/DirectoryInferred).recursive: true— full subtree listing. Plugins emit every descendant object plus any directory facts the backend natively returns or stores: real directories asDirectory, zero-byte flat markers asDirectoryMarker, and backend-reported prefixes asDirectoryInferred. Flat backends may also emit inferred ancestor directories implied by descendant objects. The host normalizes public recursive list results the same way after address projection, sofoo/bar/baz.txtimplies visiblefoo/andfoo/bar/DirectoryInferredentries when no concrete directory fact exists. When the same address appears as both a concrete directory fact (DirectoryorDirectoryMarker) and an inferred prefix, emit only the concrete fact; it carries metadata the inferred prefix lacks.
Plugins that cannot honor a recursive list natively (anonymous HTTP,
single-resource backends) advertise
Capabilities::supports_recursive_list = false AND MUST return
Unsupported when called with ListOptions { recursive: true, .. }.
The host forwards recursive = true to the plugin verbatim rather than
filtering it out — silently dropping it would hand callers a one-level
enumeration in place of the full subtree they asked for. Implementing
recursive list, or refusing it, is therefore the plugin’s obligation and
not something the host will paper over.
WriteStep and redirect shape#
pub enum WriteStep {
Done(WriteResult),
Redirects(WriteRedirectBatch),
}
ReadResult::Redirect and per-batch write redirects share the
fields below. The host (library or broker) is a generic HTTP
follower over them: it executes request, slices the body per
body_source, parses response headers per response_parsing, and
(for writes) extracts only the headers listed in
result_capture.headers and at most body_max_bytes of the
response body. Plugins are the only component that has to know
S3, GCS, Azure, etc. exist.
pub struct HttpRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
}
pub struct ResponseParsing {
pub etag_header: Option<String>, // default "etag"
pub etag_strength: EtagStrength, // declaration for a custom etag_header; default Unspecified
pub version_header: Option<String>, // e.g. "x-amz-version-id", "x-ms-version-id", "x-goog-generation"
pub size_header: Option<String>, // default "content-length"
pub mtime_header: Option<String>, // default "last-modified"
pub mtime_format: MtimeFormat, // RFC 1123 (default), ISO-8601, Unix-seconds
pub system_metadata_headers: Vec<String>,
pub content_checksum_header: Option<String>,
pub content_checksum_algorithm: Option<ChecksumAlgorithm>,
pub checksum_headers: HashMap<ChecksumAlgorithm, String>,
}
pub struct RedirectScope {
pub physical_url_prefix: String,
pub operations: AccessOps,
pub expires_at: SystemTime,
}
pub struct ReadRedirect {
pub request: HttpRequest,
pub response_parsing: ResponseParsing,
pub expires_at: SystemTime,
pub scope: RedirectScope,
pub audit_id: String,
pub policy_epoch: u64,
}
pub struct WriteRedirect {
pub request: HttpRequest,
pub body_source: RedirectBodySource,
pub result_capture: ResultCapture,
pub expires_at: SystemTime,
pub scope: RedirectScope,
pub audit_id: String,
pub policy_epoch: u64,
}
pub enum RedirectBodySource {
Empty, // no request body
UserBytes { offset: u64, len: u64 }, // bytes from the application's write stream, sliced
Inline(Vec<u8>), // bytes the plugin supplied (e.g. a multipart Complete XML body)
}
pub struct ResultCapture {
pub headers: Vec<String>, // headers to forward back
pub body_max_bytes: u32, // default 4096
}
pub struct RedirectResult {
pub status_code: u16,
pub captured_headers: Vec<(String, String)>,
pub captured_body: Vec<u8>, // bounded by ResultCapture.body_max_bytes
}
pub struct WriteRedirectBatch {
pub continuation: Vec<u8>, // opaque, plugin-owned continuation echoed back to continue_write
pub redirects: Vec<WriteRedirect>,
}
pub struct RedirectResultBatch {
pub results: Vec<RedirectResult>,
}
When etag_header names the standard HTTP ETag header, the follower derives
strength from the wire entity-tag and ignores etag_strength: a valid
nonempty quoted value is Strong, an explicit W/ value is Weak, and a bare
or malformed unmarked value is Unspecified. For any other header, the value
stays opaque and the plugin must set etag_strength
from the backend’s actual equality promise. The follower carries a custom
header value verbatim, including surrounding quotes or a literal W/ prefix;
it does not apply HTTP entity-tag normalization to that value. Leave its
strength Unspecified when the backend’s promise is unknown. For example, GCS
declares its numeric x-goog-generation header Strong.
Mid-stream resume uses any nonempty projected validator and requires the
resumed response to repeat the configured header value. etag_strength does
not gate this textual resume check. A standard HTTP ETag, when present, is
captured independently and sent as the resume request’s If-Match.
A simple S3 PUT collapses into two Layer calls: write returns
Redirects { state, [put_request] }, the host runs the request,
calls continue_write(state, [response]), and the plugin returns
Done(result). An S3 multipart upload of N parts collapses into
four: write returns Redirects { state, [initiate] };
continue_write returns Redirects { state', [upload_part_1, ..., upload_part_N] }
after parsing the UploadId; continue_write again returns
Redirects { state'', [complete] } after collecting part ETags;
continue_write finally returns Done(result). Plugins that can’t
redirect (file, an SCM plugin) handle their write internally and
return Done(result) directly from write; they never implement
continue_write meaningfully.
While a write is in a redirect step, no part of the object may be
observable at the backend. Stage the transfer somewhere a reader cannot
reach — the multipart upload id above, an uncommitted block list — and let
the object become visible only in the transition to Done. A caller that
abandons the write mid-flight must leave the address exactly as it was.
Hosts depend on this to keep their own derived state coherent: a mid-flight
step means the address still holds what it held before, so anything a host
computed from it remains valid. A plugin that published partial content there
would silently invalidate those derivations. (The in-tree byte cache is
additionally robust to a plugin that breaks this — it invalidates before
continue_write runs — but that is defence in depth, not permission.)
This is achievable only where continue_write performs the commit. A
single-redirect fast path — one presigned PUT that the caller’s own request
commits — has already written the object by the time continue_write is
called, so there the verb reports rather than commits, and abandoning after the
PUT does change the address.
Which shape you have is a property of the individual write, not of the
backend. Several in-tree backends implement both and choose per call — by
size_hint against a threshold, or on whatever the remote service answers — so
“does this backend stage?” is the wrong question to ask of your own plugin. The
one to ask is: for this write, is the commit an outbound call my
continue_write makes, or was it the caller’s own request? If your
continue_write finalizes — an S3 CompleteMultipartUpload, an Azure
Put Block List, a Nucleus create_asset — you have the guarantee and must
keep it. If the caller’s presigned PUT was the commit and continue_write
only reports
or reads back, you do not have it on that path, whatever the backend does on
others. Keep the staged shape wherever you have a choice, and where you do not,
say so in the plugin’s own documentation rather than leaving a reader to assume
the guarantee holds.
At the Layer boundary, everything continue_write receives except the
address is reported input, not an observation you made — the results, the
redirect batch echoed with them, and the continuation blob inside it. The
reporter is a redirect follower inside a host stack on one route and a remote
client on the other; the broker has both, because its own write path follows
redirects in process. Check the
cardinality yourself, because no follower route checks it, and expect any status
to arrive: only the broker’s continue_write RPC refuses one outside 200..300,
and its cardinality check compares two counts the same caller supplied. On a
follower route, no signature binds the continuation to the operation you issued
it for. The broker’s client-driven RPC is the exception: it verifies an
HMAC-SHA256 envelope binding the continuation bytes to the request address and
strips the envelope before Stack dispatch. That host-boundary seal does not
cover redirect results, prevent same-address replay, bind a principal, or
extend to follower routes and non-broker hosts; the Layer SPI itself still
provides no continuation integrity.
The address is the only authenticated part of the call, because authorization is
decided on it. Derive the object you act on from the address rather than
taking it from the continuation. Where the provider’s handle makes that
impossible, keep whatever comparison you have but say in the code that it is
defence in depth rather than presenting it as the control. The broker seal
protects its client-driven route, but a direct SPI caller or another host can
still supply both sides of a comparison. Acting on whatever object the blob
names lets an unsealed caller pick its own authorization. Values the address
cannot supply — a
server-issued session handle, the preconditions of the original write — have to
travel in the blob. The broker seal authenticates them as part of the batch it
issued for that address, but on an unsealed route they remain caller-chosen
rather than facts. Beyond that, none of these values may be evidence about
connection auth state, credential validity, quota, principal identity, or
metrics. The full rule is At the Layer boundary, treat everything
continue_write receives except the address as reported input in
CONFORMANCE.md.
Reads stay one Layer call. read returns ReadResult directly; the
host inspects the variant and acts. There is no continue_read.
list_versions makes no order guarantee. Plugins return
version-pinned ObjectInfo values in whatever order their backend’s
native list API produces. supports_version_listing gates the
operation; version_list_order advertises what the backend natively
does (Newest, Oldest, or Unordered).
watch_directory Layer method. Plugins translate native feeds into a single shape:
pub enum BackendChangeEvent {
Object {
address: Url,
kind: ChangeKind,
etag: Option<String>,
at: SystemTime,
cursor: WatchDirectoryCursor,
},
Lapsed { since: Option<SystemTime>, cursor: WatchDirectoryCursor },
}
Backends without a native feed leave supports_watch_directory = false;
the Layer contract does not include a polling mode (callers can drive list
themselves on whatever cadence they choose). Plugins are responsible
for translating native gap conditions into Lapsed: a plugin that
can’t distinguish “everything fine” from “we dropped events” is
required to emit Lapsed defensively on every reconnect.
Every successful concurrent call is an independent logical subscription: from
the point it returns, it receives every event eligible under its own options.
A competing-consumer backend — where each notification is delivered to exactly
one reader, so two watches sharing the connection’s queue would cannibalize
each other’s events — MUST self-coalesce via the SDK WatchCoalescer and pass
the watch-concurrent-cross-prefix-no-split conformance scenario. The
coalescer collapses live subscriptions sharing one connection onto a single
recursive, metadata-inclusive physical watch and filters each subscriber. It is
principal-blind: it never interprets a principal itself, and it does not enforce
admission quotas. An adopter whose physical watch is authorized only when it is
opened and cannot reauthorize during its lifetime MUST fence coalescing keys at
credential-identity changes. The broker-client does this by cancelling and
replacing its per-connection registry when that generation changes and by
keying within the generation by resolved prefix. S3 instead reauthorizes each
receive request from its shared live credential cell, so its coalescing key may
remain stable across in-place credential rotation. GCS authenticates each pull
or acknowledgement through its immutable connection authenticator; an identity
change requires removing and re-adding the connection, which drops that
connection’s coalescer. Neither provider leaves an authorize-once physical
watch alive across an identity change.
The broker daemon supplies that central chokepoint with its broker-native
watch_limit Layer, enforcing
per-principal and process-total limits across all of its connections and
backends. (The REST gateway’s embedded Stack is outside that broker-only
chokepoint.) A call with a since cursor requests replay: a resumable backend may
serve it from a dedicated seek reader with real replay, while a non-resumable
competing-consumer backend coalesces onto the live stream and prepends a single
initial Lapsed. Either way a since subscription never attaches behind an
existing live consumer that could truncate its replay.
The broker-client also adopts WatchCoalescer as a transport adapter: each
broker connection coalesces non-resumable subscriptions with the same resolved
prefix onto one recursive, metadata-inclusive gRPC watch and filters that
superset for each logical subscriber. A since cursor on this non-resumable
path produces the coalescer’s initial Lapsed. If the selected broker root
advertises watch_directory_resumable = true, the client bypasses coalescing
and opens a dedicated gRPC stream with the caller’s options and cursor
unchanged.
Cache watch_invalidation opens one identity-free logical subscription per
advertised root because cache invalidation is address-wide, independent of any
caller watch — and, where a root’s own watch is refused with
PermissionDenied, a bounded number of narrower subscriptions on directories
the cache holds entries for instead. On a competing-consumer backend that
subscription shares the connection’s queue with caller watches and may
coalesce onto a single physical
upstream, so the backend’s self-coalescing is what lets both still receive every
event; a backend that does not self-coalesce must leave cache watch invalidation
disabled and rely on TTL invalidation.
Address projection#
For Layer calls that return object addresses, plugins return
ObjectInfo.address values in the resolved backend namespace they
were handed. The library, which is the only component that knows
about routes and aliases, projects those addresses back into the
caller’s namespace before returning them publicly. Projection is a
prefix replacement, not display-label generation: callers derive
labels from parent and child addresses with parent_and_name or
equivalent URL parsing.
list— each returnedObjectInfo.addressmust be inside the requested resolved prefix. The library replaces that prefix with the caller’s request prefix and leaves the rest of the address intact.List-backed
stat— when a route setswants_list_backed_statand the host usesLayer::listto satisfy an objectstat, it reuses only returnedObjectInfovalues whosekind.is_file().list_versions— each returnedObjectInfo.addressis the full version-pinned backend address for that version. The library projects only the route prefix; the version pin remains part of the address.versioned_address— on any projectedObjectInfo, and on the address-lessupdate_metadata/create_directoryresults, the pin is a full backend address and is projected exactly likeaddress: route prefix replaced, query preserved. It must lie inside the same resolved scope asaddress.watch_directory— eachBackendChangeEvent::Objectcarries a full backend address in the watched resolved prefix and is projected the same way aslist.
If a plugin returns an address outside the requested resolved prefix
for any projected result, the host treats it as Internal: the
backend violated the Layer contract.
address_roots and watch_address_roots are not differential.
They return absolute addresses, not route-prefix-relative fragments.
Publishing a scheme fallback root#
A connection-owning backend may serve an address on its scheme that no
connection routes — s3://bucket/key with no connection for bucket —
by publishing one extra RootInfo per granted scheme: a scheme
fallback root. The route table consults it only after every prefix
misses, so the root says “an address on this scheme that nothing else
routes may be attempted here, anonymously”. ovstorage_plugin::fallback
carries the contract. Build the root with scheme_fallback_root rather
than by hand, and read the operator’s grant with
decode_anonymous_fallback after split_anonymous_fallback_keys has
separated the anonymous_fallback* keys from the connection keys your
factory would otherwise seed a static connection from; refuse a
connection request that carries one of those keys, because a layer key
on a connection would be ignored while the operator believed it took
effect.
The root is recognized by marker and shape, and both halves are required:
user_metadata["org.omniverse.ovstorage/scheme-fallback"]is"anonymous", matched exactly. An alias presentation patch cannot set or remove this key.The address is the bare scheme,
{scheme}:///—s3:///,gs:///,azure:///— with path/and no query. A scheme theurlcrate refuses to spell hostless (http,https) uses the reserved placeholder host instead,https://anonymous-fallback.invalid/, under a TLD RFC 6761 guarantees never resolves.visibility: Hidden,visible: false,connection_id: None,owning_target: None, sourceStatic { Programmatic }. The root is a routing fact, not a place a user browses to, and no connection exists for it to name.
A hostless root without the marker is not a catch-all. file:///
and mem:/// are ordinary roots whose prefix relation already answers
every address under them, and the table treats them as it treats any
prefix root. A marked root that names a bucket (s3://bucket/) is
not a fallback either: it serves its prefix through the first tier and
the marker on it is inert.
The two-tier RouteTable contract. RouteTable::lookup runs the
longest-prefix scan first; only on a miss, and only when the address’s
query carries no credential (generated_rule_may_answer is the gate: a
presigned or SAS URL authenticates itself, and an anonymous attempt
would discard the authority the caller presented), does it return the
marked root registered for url.scheme(). A second-tier hit is not
a prefix hit: relative_suffix(url, &root.root) is None for it, so
never compute a suffix from a hit — derive the bucket, account or
container from the address itself. RouteTable::lookup_prefix exposes
the first tier alone, for the question “who serves this exact node”;
the host Router’s collision check reads it, and the same_node guard
in § Recovering a target discards a scheme root for the incumbent
check on its own. Because the type is shared by the Router, every
plugin’s internal table and the brokered client, all of them gain the
tier at once: a marked root reaches the same backend whether the
address arrives at the Router or directly at the layer that published
it. A hostless address on the scheme (s3:///, s3:///key) is a
first-tier hit under the root; refuse every such address with
InvalidArgument, because it names no bucket. An address carrying a
credential-bearing query never takes the tier, so no slot sees one
through it.
A fallback root advertises only the bits its slots honour without
touching the wire. Pass your capabilities through
read_only_capabilities — scheme_fallback_root applies it again
whatever you pass — so every write, delete, copy, rename, directory
mutation, metadata patch and watch bit is false and only the read side
(list, list_stream, recursive list, versions, access check, and the
shape facts) survives. scheme_fallback_root leaves
range_read_strategy at its default because it does not know your
backend; when your connection roots advertise Native ranged reads,
set the same on the returned root, so root_info_for answers one
strategy for a fallback-served address and for a configured one on the
same store. Then self-gate: every mutating slot and
watch_directory on a fallback-reached address returns
fallback_mutation_refused(scheme, &node_root) — typed Unsupported,
naming the bucket root a connection would be added for and never the
caller’s path — before any body is read or request sent, and copy
and rename check both ends. Build the client with no credential source
at all, judge every derived outbound host with
refuse_instance_metadata before building it, answer root_info_for
on a fallback-reached address with the scheme root verbatim, register
no Connection, and evict the cached client for a node when an
explicit connection for that node installs.
ovstorage_plugin::fallback_cache::FallbackCache is the bounded,
single-flight, budgeted cache the in-tree backends hold those clients
in; its budget answers ResourceExhausted.
C-ABI surface#
From the library’s perspective there is one plugin transport: a
dynamically loaded shared library (.so / .dylib / .dll) opened
through libloading. Every plugin binary exports two C-ABI symbols:
ovstorage_plugin_manifest_v1— astatic PluginManifestV1describing the plugin (struct_size: usize,abi_version: u32, NUL-terminatednameandversionpointers,test_only: bool). There is noplugin_kindfield on the manifest; storage vs. authz is disambiguated by cdylib filename prefix and by which symbols the loader resolves.ovstorage_plugin_init_v1— the binary init function. It receives a*const HostCallbacksand returnsPluginInitResultV1 { struct_size, abi_version, plugin_state, plugin_vtable, kinds, kind_count }. The descriptors are borrowed for the plugin lifetime.PluginVTableV1owns the three creation slots; each successful call returns aLayerHandlewhoseLayerVTableV1contains identity, introspection, object-operation, and connection-lifecycle slots.
Async callback status convention. Every async vtable method’s
on_complete callback receives (status: i32, result, error, user_data).
status == FFI_STATUS_OK (0) is reserved exclusively for success;
every error path returns FFI_STATUS_ERR (-1). The real
ErrorCode lives on the heap-allocated *mut Error pointer the
callback receives, never in the integer status word. Outcome
dispatch is pointer-presence based: error == NULL is success;
error != NULL is failure.
*_free ownership. The C ABI exposes two distinct allocation
patterns and each ovstorage_plugin_*_free export honours exactly
one of them — calling the wrong one is undefined behaviour:
Callback-delivered (heap) — the plugin’s thunk
Box::into_raw(Box::new(...))’s the value and the host receives a*mut Tthat owns aLayout::new::<T>allocation. The matching_freereclaims it viaBox::from_raw.Caller-owned (in-place clear) — the value lives in storage the caller already owns. The matching
_freedoesstd::ptr::drop_in_place(value)so nested allocations are released without trying to free the outer slot.
Plugin and manifest live in the same binary by construction; there is no separate file to drift, lose, or forge.
Ownership and lifetime invariants#
Manifest memory is static and immutable for the lifetime of the loaded binary. The host never frees it.
Plugin, Layer, stream, future,
LocalDelegate, and error handles are opaque. Whoever creates an owned handle also exports the destructor for that handle.No borrowed pointer, slice, string, header map, options struct, or callback reference may be retained after the vtable call returns unless the ABI field is explicitly an owned handle.
Backendmethods take&selfsemantically and may be called concurrently by the host. Plugins must protect non-thread-safe SDK clients, refresh-token state, multipart upload maps, and native handles internally.Capabilitiesare immutable for the lifetime of aBackendinstance.WriteRedirectBatch.continuationis an opaque, plugin-owned continuation blob. The host returns its exact bytes tocontinue_write, may persist it only for the lifetime of the in-flight operation, and must not inspect or log its contents. A host may transparently authenticate the opaque bytes at its boundary: the broker’s client-driven RPC wraps them in an address-bound HMAC envelope and strips it before Layer dispatch. Follower routes and non-broker hosts provide no such SPI guarantee, and redirect results remain unchecked input. Derive the object you act on from the request address instead of decoding it out of the blob, and treat what the address cannot supply as caller-chosen on an unsealed route. See At the Layer boundary, treat everythingcontinue_writereceives except the address as reported input in CONFORMANCE.md.Redirect
HttpRequest.methodis drawn from a fixed allowlist, matched exactly and in uppercase: aReadRedirectmay useGETorHEAD; aWriteRedirectmay usePUT,POST, orPATCH. Any other method — including a lowercase spelling of a permitted one — is rejected withPermissionDeniedbefore any network I/O. A plugin that needs a non-write verb against the origin (aDELETEto abort a multipart upload, say) performs it through its own client rather than as a redirect.A
Content-Lengthheader you put on a write redirect must be1*DIGITper RFC 9110. Surrounding whitespace is trimmed, but a sign character —+123as much as-123— is refused withInvalidArgumentbefore any network I/O, as is an empty value and a secondContent-Lengthheader. The read path replays your headers unchecked, so a malformed value there reaches the origin instead of being refused — that is a gap in the check, not a licence. Format it as bare digits on both. Same rule, stated in full, in CONFORMANCE.md.Every redirect must declare what its credential authorizes, on
RedirectScope.credential. The four values areNone(no credential; the URL alone fetches the target),Request(authorizes this one request and expires with the redirect),Connection(authorizes the connection at large — other objects, and time beyond this redirect’s expiry), andUnspecified(the plugin forwards a credential it did not construct and cannot classify).Declare it, do not compute it: the host cannot. An account-wide signature and one scoped to a single blob are the same shape on the wire, so no header or URL inspection recovers the difference, and only the code that built the credential knows what it covers. Derive the value from the branch that constructs the credential — the auth mode, the signing call — rather than from the redirect’s shape, so the two cannot drift apart.
Unspecifiedis fail-safe, not neutral: a host treats it exactly asConnection. Declare it honestly when you are copying a header set from upstream; guessingRequestto keep the redirect path is the failure this field exists to prevent.Choose
UnspecifiedoverConnectionwhen you do not know rather than when you know it is broad, even though hosts treat the two the same today. The two say different things —Connectionis a claim about the credential,Unspecifiedis a statement that you are forwarding something you did not construct and cannot introspect — and only the second stays true on its own if the upstream narrows what it returns. The in-tree OpenDAL and Omniverse Storage Service plugins are bothUnspecifiedfor exactly that reason; Nucleus LFT isConnection, because it knows its redirect carries the connection’s own auth headers.What the host does with the declaration is the operator’s choice, not the plugin’s — the deciding question is whether the clients are trusted, which no plugin can know. Under the default policy a host withholds redirects declared
ConnectionorUnspecifiedand moves the bytes itself; an operator may opt in to handing them over.A host may lower a declaration but never raise one: a redirect declared
Requestthat also carries a header the host cannot account for as addressing or conditioning the request is treated asConnection. So attaching an unusual header to a request-scoped redirect costs a proxied transfer, not a disclosure. Prefer scoping the credential into the presigned URL where the backend allows it.A
LocalDelegatepath is meaningful only on the host that received it from the plugin or cache. The plugin must not delete or mutate the delegated file while the lease is alive.Plugin unload is reverse-topology: all in-flight calls and returned streams/delegates must be dropped before backend destruction; all backends before factories; all factories before plugin-state destruction; plugin-state destruction before the shared library is closed.
Capability vocabulary#
Capabilities is application-facing through RootInfo. It describes
what the caller can do against a root: in Direct mode it reflects the
plugin behavior; in Brokered mode the broker may strip operations its
policy forbids. Dynamic-root plugins publish updated RootInfo values
through the list_address_roots update stream. The host gates dispatch
on the effective root capabilities.
Bits are hints, and the two answers are not symmetric#
A capability bit is a hint that lets a caller skip a round-trip it knows cannot succeed. It is not an enforcement mechanism and not a promise:
falseis actionable. The operation is known to be unavailable on this route, so a caller may skip it and a UI may grey it out.trueis not a guarantee. It means only “not known to be impossible.” The operation can still fail — including withUnsupported— for reasons the bit cannot express: the deployment behind a protocol has not implemented the RPC, a policy layer intercepts the slot, or the specific arguments fall outside what the backend supports.
Two obligations follow.
Plugin authors: self-gate. A backend layer that does not support an
operation returns Unsupported (or the appropriate typed error) with
no side effects when a caller ignores the hint and calls the slot
anyway. The host does not pre-gate on your behalf. Where a capability
varies by deployment rather than by protocol, negotiate it per root in
capabilities_for_root rather than hard-coding the protocol’s answer —
the services-client plugin does this for folder mode and optimistic
locking.
Callers: handle Unsupported from advertised operations. Treat a
true bit as “worth attempting,” never as “will succeed.” Masking a
bit is likewise not enforcement: a wrapper that strips a bit from
root_info_for changes the hint only, so a policy-enforcing Layer must
also intercept and reject the operation slots it forbids.
Availability, mechanism, and guarantee are three different claims#
For copy and rename the bits answer three separate questions, and
conflating them is what makes a stack behave inconsistently:
Availability —
supports_copy,supports_rename: the operation can be attempted on this root. This is what a caller asking “willcopywork?” wants. A layer that emulates the operation sets it even though the backend beneath performs nothing.Mechanism —
supports_server_side_copy,supports_server_side_rename: the backend moves the bytes itself, so there is no egress through the host and native metadata and checksums survive. Only a backend that really does this sets it; an emulating layer never raises it.Guarantee —
supports_atomic_rename: the backend’s own rename is indivisible. It describes the native path, not every request: an emulated rename is a copy followed by a delete and is never atomic.
An emulating layer clears the mechanism and guarantee bits when emulation is
certain — a root reporting no rename of its own is never asked, so every
rename against it is copy-then-delete and supports_atomic_rename = true
would be a promise the stack cannot keep for any call. It leaves them alone
when emulation is merely possible, because that is decided per request.
Availability is a property of the root. Mechanism and guarantee describe
what the backend does when it handles the request itself — and whether it
does is decided per request, not per root, because a backend can perform
most renames server-side and decline the one carrying a precondition it
cannot express. So an emulating layer raises availability and leaves the
other two alone: lowering them would deny a capability that holds for
nearly every request. The degradation is reported per request instead —
copy_rename_fallback emits a tracing event whenever it emulates.
A backend whose rename is its own copy-then-delete therefore reports
supports_rename = true, supports_server_side_rename = false,
supports_atomic_rename = false — all three answers differ, and each is
true.
The bits, stable across the project:
Concurrency
supports_if_match_write(writes honorIfDestExists::MatchEtag(etag)— real CAS, not best-effort)supports_no_overwrite_write(writes honorIfDestExists::Fail)
Metadata
supports_native_metadata_patch(backend can patchUserMetadatawithout rewriting bytes — GCS, Azure, local FS, Perforce)supports_metadata_rewrite_emulation(backend can emulate metadata patch by full object rewrite — S3, where modifyingx-amz-meta-*requiresCopyObject-onto-self)
Write
writes_are_atomic(an overwrite is all-or-nothing from the reader’s perspective; partial writes are never visible)supports_write(buffered single-shotwriteis implemented)supports_write_stream(write_streamis implemented)supports_write_redirect(write_redirect, and by implicationcontinue_write, is implemented)supports_copy(availability:Layer::copycan be attempted on this root, natively or by emulation above it)supports_server_side_copy(the backend can executeLayer::copywithout the host streaming bytes through itself)supports_delete(deleteis implemented)
Naming
supports_rename(availability:Layer::renamecan be attempted on this root, natively or by emulation above it)supports_server_side_rename(the backend can executeLayer::renameas a backend operation)supports_atomic_rename(cross-prefixrenameis atomic and crash-safe)has_real_directories(the backend has actual directory inodes that exist independently of contained objects)supports_create_directory(create_directoryis implemented)supports_delete_directory(delete_directoryis implemented)
Listing
supports_list(the backend can serveListOptions { recursive: false, .. })supports_list_stream(the layer implements boundedlist_streamdirectly rather than using the buffered compatibility adapter)wants_list_backed_stat(the host may use one-levellistas an object-stataccelerator on this route)supports_recursive_list(the backend can serveListOptions { recursive: true, .. })populates_subdirectory_metadata(whether directory-kindObjectInfometadata fields ever carry useful data on this route)
Address roots
Address roots are published through
list_address_rootsas a snapshot and an optional update stream; changing roots is not a capability claim.
Range reads
RangeReadStrategy::MaterializeOnlytells callers that random access requires host materialization. It is caller-side advice about the composed Stack, not a backend capability claim.
Versions
supports_version_listing(the backend can servelist_versions)version_list_order: Option<VersionListOrder>(advertises native order:Some(Newest),Some(Oldest), orSome(Unordered))
Permissions
populates_effective_permissions_on_stat(whetherObjectInfo.effective_permissionsis everSomeon this route)supports_access_check(whethercheck_accessanswers)
Watches
supports_watch_directory(the plugin implementswatch_directorynon-trivially)watch_directory_kinds: ChangeKindSet(whichChangeKindvariants the backend’s feed can distinguish)watch_directory_resumable(whether the plugin honorsWatchDirectoryOptions.since)watch_directory_max_lag: Option<Duration>(advisory upper bound on event lag the plugin expects under normal conditions)
Redirect dispatch
redirect_size_threshold: Option<u64>— smallest size at whichwrite_redirectis worth calling.Nonemeans “always trywrite_redirectfirst” (the host’s optimistic default; honors plugin-sideUnsupporteddegrade).
Kind-level (on StorageBackendKindDescriptor, not on a per-connection Capabilities value)
supports_runtime_add: bool— the plugin’s factory can be invoked fromadd_connectionat runtime. Most plugins settrue. Plugins that hook native libraries with global, non-thread-safe initialization state setfalseand can only be created from static config at process start.
The conformance suite skips a test only if the relevant capability is absent; every skip cites the capability.
Object information from the backend#
The dividing line: ovstorage understands a value, or it doesn’t.
Values ovstorage parses, validates, or compares against semantics
live in typed fields on ObjectInfo. Values ovstorage merely
shuttles between backend and application live in SystemMetadata,
an opaque String → String map whose keys are plugin-chosen and
whose semantics are entirely the backend’s.
Typed fields on ObjectInfo (in addition to address, kind,
etag, etag_strength, version, versioned_address, size, mtime):
checksums: ChecksumSet— backend-supplied content hashes. The plugin populates one entry per algorithm the backend actually returned; an algorithm that’s absent means “the backend didn’t tell us.”effective_permissions: Option<EffectivePermissions>— what the calling principal is allowed to do against this specific object, when the backend can answer for free.NoneandSome(EffectivePermissions::empty())mean different things:Nonemeans the backend didn’t tell us;Some(empty)means the backend told us this principal cannot perform any of these operations.
Opaque values in SystemMetadata. Everything backend-owned that
ovstorage does not understand. Keys are plugin-chosen; ovstorage
neither parses nor normalizes them.
Conformance harness#
The conformance harness is the in-tree controllable plugin the
workspace’s conformance suite drives ABI shapes through. It is not
a production backend and not a third-party plugin TCK. It exists so
host conformance suites can exercise ABI shapes that real services
cannot produce reliably on demand: streamed reads that fail
mid-flight, redirects with exact header whitelists, multipart writes
whose final continue_write returns Done, change streams that
emit Lapsed and resume, auth flows that cancel.
The conformance plugin’s manifest carries test_only = true.
Production hosts treat test_only = true plugins as opt-in: direct
loading returns ErrorCode::PluginRejected against a default-posture
host, and directory discovery skips the cdylib at debug-log level
so the bundled conformance fixture in <archive>/plugins/ doesn’t
crash startup. See “What’s not supported” below.
Registry entry shape#
Every conformance scenario has one registry entry, and the registry is the only place where scenario behavior is declared. Each entry carries:
name: the stable string used in URLs, reports, and test filters.vtable_slots: theOvStorage_LayerVTableslots the scenario is expected to exercise.required_profileandrequired_capabilities: the capability profile and exact capability bits required before the test is meaningful.required_config: keys such asscratch_dir,redirect_base_url,required_host, orclock = real.allowed_hosts:library,broker, or both. Host-specific scenarios must say why the difference exists.expected_calls: recorder assertions, including negative assertions such as “must not callupdate_metadata”.failure_contract: the typed error expected on the primary failure path, when the scenario is negative.report_tags: short tags (redirect,lifetime,capability-skip,broker-parity,ffi) so nightly reports can be grouped without parsing names.
Scenario names are append-only once published. If a behavior changes incompatibly, add a new scenario name and leave the old one as a compatibility test until the ABI revision that removes it.
Streaming seams#
Every boundary a Body::Stream crosses needs a regression test that
asserts the stream is forwarded chunk-by-chunk — never drained to a
buffer in the middle. Buffering at any seam is a memory-DoS vector
on the public REST gateway. Rule: if you add a seam, add a
streaming_invariant test for it.
What the test asserts#
The shared helper drives a Body::Stream of N≥3 chunks totaling ≥
64 MiB at 4 MiB chunk size, with a seam-specific Recorder
observing what crosses the boundary. The test asserts:
Max in-flight bytes ≤ chunk_size × small_const. Buffering would push this above 64 MiB.
Chunk count preserved end-to-end. N out → N observed.
Chunks observed in order, not all-at-once. The recorder timestamps each chunk; the spread must exceed a small floor.
Inventory#
Each row’s “Test” column is the home of the seam-specific test that
uses the helper. Rows marked (missing) indicate a seam that has no
seam-specific test yet; the marker is about test coverage, not about the
seam itself.
Seam |
Test |
|---|---|
host ↔ FFI plugin |
extend the existing host-plugin behaviors test |
host ↔ nucleus gRPC client |
nucleus transport tests (extend |
broker server ↔ plugin |
broker streaming-invariant write test (missing) |
REST ↔ host |
REST streaming-invariant objects test (missing) |
s3 plugin ↔ S3 transport |
s3 plugin streaming-invariant test (missing) |
gcs plugin ↔ GCS transport |
gcs plugin streaming-invariant test (missing) |
azure plugin ↔ Azure transport |
azure plugin streaming-invariant test (missing) |
opendal plugin ↔ opendal |
opendal plugin streaming-invariant test (missing) |
http plugin ↔ HTTP transport |
http plugin streaming-invariant test (missing) |
file plugin ↔ filesystem |
file plugin streaming-invariant test (missing) |
broker-client ↔ broker |
broker-client streaming-invariant test (missing) |
Deferred-streaming seams#
Plugins that return Unsupported for Body::Stream writes pin the
gap with a test asserting the Unsupported return — pre-empting an
accidental disk-spool half-measure that would silently buffer to disk
and present as unbounded memory-and-disk growth under a name that says
“streaming”. An honest Unsupported is the required answer; a spool is
not a lesser version of streaming.
Recorder shapes per seam#
gRPC seams (nucleus, broker, broker-client): tonic
Servicelayer interceptor that records the size + timestamp of each out-boundBytesframe.HTTP seams (REST, http plugin): a hyper
Servicethat wraps the inner with a stream-tap.FFI seam (host ↔ plugin): a mock plugin whose
body_stream_next_thunkimpl records call counts and chunk sizes.Filesystem seam (file plugin): poll the temp directory size at intervals during the stream and assert it stays well below the total payload size.
opendal: the opendal in-memory backend exposes a stream sink that already records chunk arrival.
Build and load#
A plugin is a cdylib (.so / .dylib / .dll) loaded via
dlopen. The loader contract:
Open the shared object.
Resolve
ovstorage_plugin_manifest_v1. Validatestruct_size, require the exact Layer ABI version, and refusetest_only = trueunless the host configuration enables test plugins.Resolve
ovstorage_plugin_init_v1. Call it with the host callbacks pointer; the plugin returnsPluginInitResultV1with its kind descriptors and plugin-scoped creation vtable.Stack construction creates each configured Layer and dispatches through the resulting
LayerVTableV1.
The loader accepts only an exact match on the Layer ABI version the host was
built with — 19 in the current source tree and 15 in ovstorage 0.2.1. A direct
load of an incompatible manifest or init result returns
ErrorCode::IncompatibleType;
directory discovery skips that artifact and continues loading compatible
neighbors. ErrorCode::PluginRejected is reserved for host policy, including
an opted-out test_only plugin.
Hosts discover plugins while constructing their plugin registry.
OVSTORAGE_PLUGIN_DIR overrides the default <exe_dir>/plugins/
directory. Embedded hosts may call load_plugin or
load_plugins_from_dir against paths controlled by their package or
container image. Direct loading rejects an opted-out test_only
plugin; directory discovery skips it at debug level. A directory scan
fails if two plugin libraries advertise the same Layer kind, including
when an upgrade leaves old and new copies of one plugin together. Keep
exactly one installed library for each kind. (A copy built against a
different ABI version does not collide, because it is skipped at the ABI
gate before its kinds are read.)
A minimum viable Rust plugin is two files. The Cargo.toml — note that
version is your plugin’s version and has nothing to do with the
ovstorage-plugin pin two lines below it:
[package]
name = "my-storage-plugin"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
ovstorage-plugin = "0.3"
async-trait = "0.1"
And src/lib.rs (the in-tree
ovstorage-core/examples/plugin-rust/ crate is this template, kept
compiling in CI):
use async_trait::async_trait;
use ovstorage_plugin::{
BackendFactory, CancellationToken, Error, ErrorCode, LayerConfig,
LayerHandle, LayerKindDescriptor, LayerType, Result,
ovstorage_layer_plugin,
};
#[derive(Default)]
pub struct MyFactory;
#[async_trait]
impl BackendFactory for MyFactory {
fn descriptor(&self) -> LayerKindDescriptor {
LayerKindDescriptor {
kind: "my-backend".into(),
layer_type: LayerType::Backend,
display_name: "My Backend".into(),
description: None,
config_schema: vec![],
credential_schema: vec![],
credential_methods: vec![],
icon: None,
accepts_connections: false,
// Whether a write's `user_metadata` survives this backend.
// This stub creates no backend and stores nothing, so it
// declares `false`. Declare `true` only if every write slot
// keeps the map: a host that attributes composes its
// attribution layer over a declaring kind's branch and, under
// the default `user_metadata` strategy, stamps a reserved key
// into the writes that reach it.
supports_user_metadata: false,
}
}
async fn create_backend(
&self,
_name: &str,
_config: &LayerConfig,
_cancel: Option<CancellationToken>,
) -> Result<LayerHandle> {
Err(Error::new(
ErrorCode::Unsupported,
"my-backend factory is a stub",
))
}
}
ovstorage_layer_plugin!(backend, MyFactory::default);
A real plugin returns an Arc’d Layer implementation from
create_backend (see ovstorage-core/ovstorage-plugin-test-layer for
a minimal working in-memory backend).
cargo build --release produces target/release/libmy_storage_plugin.so
(or .dylib / .dll); copy it under the plugin dir and the loader
picks it up.
Plugin macros#
Rust plugin authors get the manifest and init exports from a single function-like proc macro:
ovstorage_layer_plugin!(backend, MyFactory::default);
ovstorage_layer_plugin!(backend, MyFactory::default, test_only);
ovstorage_layer_plugin!((
(backend, MyBackendFactory::default),
(wrapper, MyWrapperFactory::default),
(router, MyRouterFactory::default),
));
Argument shape: a layer-type tag (backend / wrapper / router)
followed by a constructor expression fn() -> F where F implements
the matching factory trait (BackendFactory / WrapperFactory /
RouterFactory). The bundled form takes one or more
(tag, constructor) pairs and exports all of their kinds from the same
cdylib. The macro invokes each constructor once at load time, wraps each
factory in an Arc, and installs the factory vector as the plugin’s
LayerPlugin state behind the ABI-v2 vtable thunks. The host rejects a
plugin that advertises one kind more than once, two discovered plugins
that advertise the same kind, or a plugin that collides with the built-in
file kind.
The optional trailing , test_only flag flips the manifest’s
test_only bit for the whole plugin, including every factory in a
bundle. Production hosts configured with
allow_test_plugins = false refuse to load such plugins.
Direct loading returns ErrorCode::PluginRejected; directory
discovery used by the broker and REST gateway silently skips the
plugin at debug-log level and continues the scan, so a release
archive that ships a test plugin alongside production plugins still
starts cleanly against a default-posture host. Any other token after
the comma is a compile error so misspelled flags don’t slip through
silently.
The plugin’s name and version come from CARGO_PKG_NAME /
CARGO_PKG_VERSION at the macro-expansion site (the plugin’s own
Cargo.toml); the macro reads these via std::env::var during
expansion.
Generated symbols#
The expansion declares the two stable plugin exports:
ovstorage_plugin_manifest_v1:PluginManifestV1static carryingstruct_size, the compile-timeOVSTORAGE_PLUGIN_ABI_V2_VERSIONconstant (at or aboveOVSTORAGE_PLUGIN_ABI_V2_FLOOR, then validated by exact match), NUL-terminatedname/versionpointers, and thetest_onlyflag.ovstorage_plugin_init_v1:unsafe extern "C" fn(*const HostCallbacks) -> PluginInitResultV1. The thunk registers the host callbacks, installs the tracing log layer, invokes the factory constructor, and returns aPluginInitResultV1carrying the plugin’s per-kind factory vtable state (thunks_v2::LayerPlugin).
Both exports use #[unsafe(no_mangle)] (Rust 2024 spelling) to
keep the symbol names exactly what the host loader expects.
ABI version handshake#
The generated PluginInitResultV1 repeats the manifest’s
OVSTORAGE_PLUGIN_ABI_V2_VERSION. The host requires an exact match;
plugins do not advertise a compatibility band, and a plugin cannot
declare support for more than one ABI version.
The Layer ABI version is a constant — 19 in the current source tree, the
version that adds the version-pinned address to object and list metadata and
the matching cost flags to the stat and list options, and pins the
reserved-namespace and annotation-key spellings a plugin links. The ovstorage
0.2.1 artifacts use ABI 15. The per-version history in
the generated ovstorage_plugin.h header doc block is authoritative. Read the
constant rather than a number quoted in prose, including this page’s. The two
surfaces spell it differently, which is worth knowing before you go looking:
Rust has OVSTORAGE_PLUGIN_ABI_V2_VERSION, while the generated header emits
#define OVSTORAGE_PLUGIN_ABI_VERSION, with no V2. The floor is
OVSTORAGE_PLUGIN_ABI_V2_FLOOR in Rust and
OvStoragePlugin_OVSTORAGE_PLUGIN_ABI_V2_FLOOR in the header, and remains 5.
The header’s prose keeps the Rust spellings, so grepping it
turns up both — the #define lines are the ones a compiler sees.
Note that the ABI version number, the v2 in these constant names, and the
ovstorage package version are three separate things. v2 names the ABI
family; the current source tree uses version 19 within it, while the 0.2.1
release uses version 15. A plugin author reasoning about compatibility cares
only about the version number.
When a change needs a version bump. The default is that it does. One narrow case is exempt, and it is exempt only when all of its conditions hold. Check them in both directions, because a version that does not bump lets an old binary pair with a new one either way round:
the growth consumes slots already reserved in a struct’s tail, the struct’s size is unchanged, and every existing field keeps its offset;
the new field’s alignment is satisfied by the bytes it takes over;
old producer, new consumer: the all-zero bytes an older binary leaves there decode as a valid, correct value — not merely a parseable one;
new producer, old consumer: the bytes mean nothing to the older binary, so the new field must impose no obligation it cannot know about. A field it must free, unmap, close, or otherwise act on is not exempt, because the older binary will do none of those things.
That last condition is the one this repository has already spent. RootInfo
gained owning_target against three of its eight reserved pointers, and it
satisfies the layout conditions and the zero-decode condition: an older plugin
zeroes those bytes and a zeroed Optional reads as absent, which is the truth.
It does not satisfy the fourth. owning_target is an owning Optional<Str>,
so a plugin built against the current header can return Some to a host built
against the ABI version before the field existed, and that host — seeing only
reserved pointers — never runs the release path. The pairing stays
memory-safe, which is what the in-tree comment claims, but it leaks the string
on every call. Treat RootInfo as an accepted exception rather than the
pattern to copy. This pairing is reachable only within the v2 ABI family.
Consuming reserved bytes is therefore not by itself a licence to skip the bump. A field with no valid zero representation, one whose zero means something false rather than “absent”, one needing stricter alignment than the slot provides, or one that changes who owns or drops a value, all break an older binary while looking like a tail append. Under exact-version matching those are semantic conditions you must check, not consequences the layout gives you — and the ownership case is listed among the shapes that bump, three paragraphs down, for exactly this reason.
Everything else bumps. The shapes below are drawn from the ABI versions this project has spent. It is a record rather than a taxonomy — do not read the list as exhaustive, and do not read it as a porting checklist either.
An insertion that moves an existing field. Two bools —
supports_copyandsupports_rename— were added to the middle ofCapabilitiesrather than its tail, shifting every field below them and every struct embedding it by value, so a cdylib built against the older header misreads the whole block.A trailing field in a struct with no reserved tail.
ReadOptionsgainedmax_bytesat the end and bumped anyway, because there was no reserved budget to spend. “At the tail” is not the exemption; “into reserved slots” is. The optional versioned address onObjectInfoandBackendItemInfois the same shape and bumps for the same reason. The matching flag on thestruct_size-prefixedStatOptions/ListOptionslands in padding those structs already carry, sostruct_sizecannot see it and the exact-match version check is the only guard.A field whose type changes. Re-typing the
Extensionsentries swapped one two-word representation for another, moving nothing — the bytes simply mean something else. (The ABI version that carried this also carried a layout change, so read the bullet for the shape rather than as a clean example.)A function-pointer signature change. Adding a request-context parameter to the introspection slots changed no struct at all. It is invisible to the
struct_sizehandshake, which is exactly why it needs the version.A change to who owns a value. Moving every ABI buffer onto the shared OS heap left most layouts untouched; the bump is what forces the rebuild that carries the allocator choice, which a cdylib bakes in at compile time. (The ABI version that carried this also carried a real layout change, so it is not a pure example — the ownership half is the part worth learning from.)
So the question is not “did the bytes move” but “could a binary built against
the header one ABI version earlier still be right about this value”. Some of
that is checked
mechanically — the one reserved-tail append carries a const assertion pinning
the sizes it spent and its offset relative to the field before it — but the
assertion is narrower than it looks: it catches a reordering around the appended
field, not growth somewhere above it, which moves both offsets together and
leaves every clause true. Treat the version decision as yours to make, not as
something the compiler will make for you.
Allocator contract#
Values that cross the ABI live on the process-wide operating-system
heap — malloc/free on POSIX, the process heap on Win32 — not on
whatever allocator the producing binary happens to install. That covers
the outer heap envelope of a result and every Str, Bytes, and List
buffer nested inside it, in both directions.
This matters because #[global_allocator] is a per-artifact choice: a
plugin cdylib and its host each pick their own. A plugin is free to
install jemalloc, mimalloc, or any other allocator — that choice governs
the plugin’s own internal allocations and nothing else. A value handed
across the ABI must not come from it, because the receiving binary
releases that value through its own allocator.
Rust plugins get this for free: ovstorage-plugin’s marshalling layer
routes every ABI buffer through std::alloc::System, which
#[global_allocator] cannot redirect. Plugins written directly against
the C header use malloc/free (or, on Win32, HeapAlloc/HeapFree
against GetProcessHeap()) for the same buffers, and must not substitute
a private arena or a statically linked CRT’s heap.
Routing a release through an exported ovstorage_plugin_*_free symbol is
not a substitute. Those exports ship in the ovstorage-plugin rlib,
which links into the host and into every plugin cdylib, so each binary
carries its own copy and a call from Rust binds to the caller’s — the
caller’s heap, which is the thing being worked around.
Panic safety#
The catch_unwind walls in the ABI-v2 thunk runtime around
per-method async dispatch are the panic-safety contract: they convert
a plugin panic to ErrorCode::Internal before it can unwind across
the C ABI. The workspace keeps the default panic = "unwind" for both
dev and release so those walls can catch. The generated
ovstorage_plugin_init_v1 deliberately does not wrap its body in
catch_unwind — a panic in the plugin author’s factory constructor
escapes the extern "C" init fn and is force-aborted by rustc (≥1.81,
guaranteed on edition 2024) rather than unwinding the C frame, which
is the desired hard-fail.
ABI roadmap#
The Layer ABI uses exact version matching. Plugin authors depend on
ovstorage-plugin; the macro writes its
OVSTORAGE_PLUGIN_ABI_V2_VERSION into both manifest and init result.
A host running a different ABI rejects the cdylib with
IncompatibleType. Compatibility for a future
frozen ABI requires an explicit new versioning decision rather than an
inferred version band.
ABI-stability rules#
The plugin C ABI freezes at ABI 1.0 — a milestone of the ABI’s own versioning, not the ovstorage 1.0 release — and once that ships, a break is an ABI 2.0. Neither number tracks the package version. Until that freeze, the ABI version simply increments. Three layered defenses authors must understand:
struct_sizehandshake on every options struct. EveryOvStoragePlugin_*Optionsstruct (read, write, list, stat, etc.) carriessize_t struct_sizeas its first field. The callee validatesstruct_size >= sizeof(known_minimum)before reading any tail field; an undersized struct returnsInvalidArgumentrather than reading uninitialized memory. Newer callers passing a larger size remain compatible because the callee ignores fields past its known maximum. This handshake operates within one ABI version and does not soften the exact-version gate described under ABI version handshake: a plugin declaring a different ABI version is refused before any options struct is read, sostruct_sizenever gets the chance to bridge a plugin built against a different ABI version to this host.Reserved padding on request structs and vtables. Every plugin request struct ends with reserved pointer slots. The Layer and plugin vtables end with zero-initialized reserved-fn slots. Additive growth consumes the next free slot in tree order; existing fields never reorder. This is a narrower set than the rule above: the
*Optionsstructs have no reserved slots (the two directory ones are the only exception), so a new option is added by appending the field and bumpingstruct_size— the prefix check is the whole guarantee there.Exact ABI handshake.
PluginManifestV1andPluginInitResultV1carryOVSTORAGE_PLUGIN_ABI_V2_VERSION; the host requires exact equality before reading the v2 init result.
The first two rules are specific to ovstorage_plugin.h, which crosses a
real dlopen boundary. The application C API in ovstorage.h uses
neither: it ships as source compiled together with its implementation, so
its public structs carry no struct_size and no reserved padding.
What’s not supported#
test_only = trueplugins are host-gated, not package-gated. The host’sallow_test_pluginssetting is the opt-in; a default-posture host treats atest_onlycdylib as unloadable. Direct loading surfacesErrorCode::PluginRejected; directory discovery skips the cdylib silently at debug-log level. The release archive ships the conformance plugin under<archive>/plugins/so downstream host authors can opt in to it for testing; default-posture deployments scan the same directory without disruption.No plugin sandboxing. Plugins run in-process with the host’s privileges. There is no seccomp filter, no namespace isolation, no capability dropping. Operators who need plugin isolation must run the plugin behind a separate process.
No plugin hot-reload. Loaded plugins live for the host process’s lifetime. Replacing a
.sorequires restarting the host.