ADR-0006: Plugin SDK, contracts and capabilities¶
- Status: Accepted (2026-10-04). Not built yet, now follow-ups: the CLI builds providers directly rather than through
RegistryandProviderFactoryfrom[providers.*](§2).SDK_VERSIONis 0.8 and the output envelope 1.0, not the 0.3 and 0.1 this text names. - Date: 2026-09-24
- Issues: #2 (plugin SDK), #3 (capability negotiation); related #99 (conformance), #28 (locking)
- Deciders: @n1ckyb
Context¶
Every ODS module talks to the outside world through providers: dbt artifacts,
warehouse metadata, state stores, executors, locks, LLMs and so on. ADR-0001 fixes the
dependency direction: modules depend on ods-sdk contracts, and only the CLI wires
concrete providers in. AGENTS.md rule 1 forbids core code that branches on vendor
identity.
Issue #2 lists twelve contracts. Most of them take domain types that don't exist yet:
- ArtifactProvider needs the #4 semantic graph;
- StateStore needs the #11 state model;
- SchemaProvider needs the #60 ERD model.
Defining all twelve now would freeze guessed signatures that the owning issues would then have to break.
Options considered¶
Contract rollout¶
- Framework now, one complete reference contract, the rest with their owning issues (chosen). Every mechanism is built and exercised end to end now: versioning, capabilities, factories, conformance and fakes. Each later contract follows a proven pattern and is written against real types.
- All twelve traits now, with placeholder types. The acceptance list would be ticked off, but most signatures would change when the domain types arrive, which is churn for every provider author.
Sync or async contracts¶
- Async traits via
async-trait(chosen). Providers do network and database I/O (ADR-0002: async at I/O boundaries).async-traitkeeps the traits object-safe (Box<dyn LockProvider>), which the registry needs. Nativeasync fnin traits is not yet dyn-compatible. We'll switch when it is, as a minor bump that providers won't notice. - Sync traits. Simpler, but real providers (
sqlx, HTTP clients) would each have to block on their own runtime.
Decision¶
1. Contracts¶
- A contract is a trait in
ods-sdk/src/contracts/<name>.rsplus aContract { name, version }constant. Its documentation states the semantics precisely enough for a conformance suite to test. - Every contract trait extends
Provider, which requiresSend + Syncandfn info() -> ProviderInfo. LockProvider(#28) is the reference contract:acquire,renewandreleaseon leases;- optional expiry (
LeaseExpiry) and fencing tokens (FencingTokens). - Every grant has a token unique per key, even for the same owner, so a stale lease
from a retried run can never renew or release its successor.
FencingTokensadds that tokens strictly increase. - TTLs are a validated
LeaseTtl(1 s to 24 h), so providers never overflow a timestamp or grant an already expired lease. Expiry is inclusive: atgranted + ttlthe key is free. An expired lease can't be renewed (the holder acquires again and gets a new token), and releasing it is a no-op.
It has no dependencies on unfinished domain types, and non-trivial semantics.
- The other contracts are listed in contracts/mod.rs with the issue that will add each
one: ArtifactProvider #12, MetadataProvider #15, StateStore #25,
FingerprintProvider #13, Executor #23, ChangeProvider #16, CloneProvider #29,
PolicyProvider #9, EventSink #8, UsageProvider #55, SchemaProvider #60,
SecretProvider #126, LlmProvider #33. Each lands with a fake and a conformance
suite in the same PR.
2. Provider identity, factories and configuration¶
ProviderInfo { kind, instance, version, capabilities }is how a provider describes itself, in diagnostics and to planners.ProviderFactory<dyn Contract>creates a provider from[providers.<name>]configuration (ADR-0005):- it validates the provider-specific
settings; - unknown keys and wrong types are
ProviderError::InvalidSettings, which never echoes values. Registry<dyn Contract>holds factories keyed bykind. It rejects duplicate kinds and factories built against an incompatible contract version, and rejects a created provider whoseinfo()reports a different kind or instance than configured.- The CLI (the composition root) owns the registries. Core never reads
kind.
3. Capabilities (#3)¶
- The vocabulary lives in
ods-core, per ADR-0001. - Well-known capabilities are enum variants:
relation_versions,relation_probe(ADR-0022),relation_existence(ADR-0016),zero_copy_clone,atomic_replace,change_tracking,query_history,source_freshness,schema_versioning,column_usage,constraint_metadata,lease_expiry,fencing_tokens,run_events(ADR-0024)error_explain(ADR-0025),relation_link(§7),run_ledger(ADR-0029) andhealth_check(ADR-0030). - Third parties extend the vocabulary with
x-<namespace>.<name>, a validatedCustomCapabilitythat can only be built by parsing, so it can never spell a well-known name. - Capabilities serialize as their names and are ordered by name, so a sorted
CapabilitySet(and anything hashed from it) doesn't change when variants are added. - Strategy choice: a planner lists
Strategy { id, requires, value }in preference order, andods_core::choosepicks the first strategy whose requirements the provider's capabilities meet. - The list must end with a fallback that requires nothing, so there is always a conservative choice (rule 3).
- The choice records which strategies were skipped and which capabilities each one
lacked, so plans can explain themselves (rule 4).
Choice,StrategyandSkippedserialize, so the reasons can be shown as JSON. - Enforcement:
scripts/check-vendor-neutral.pyruns in CI. It fails if core, foundation, SDK or module source names a vendor or runtime in code or string literals. It tokenizes Rust (comments and#[cfg(test)]items are skipped) and splits identifiers on_and case changes, soDatabricksClientanddbtManifestare caught. Its own--self-testruns in CI too. Providers and the CLI are exempt.
4. Errors¶
ProviderError is #[non_exhaustive]. Its variants are UnknownKind,
InvalidSettings, Unsupported(Capability), Conflict, Unavailable (the only
retryable one) and Other. Messages must never contain secret values (rule 9).
5. Conformance and fakes (#99)¶
- Each contract has a suite in
ods-sdk/src/conformance/, behind theconformancefeature. A provider crate runs the suite from its own tests through a small harness trait (for locks: a fresh provider per case, plus an optional clock control that moves time for every provider the harness created). The suite checks that every provider advertises the same capabilities. - Cases that need a capability the provider doesn't advertise are skipped and reported, not failed. Providers are tested exactly for what they claim, and a test can assert which cases were skipped.
providers/ods-provider-fakehas reference in-memory implementations with a controllable clock and switchable capabilities. They must pass the full suites, and modules use them in tests instead of real services.
6. Versioning (SemVer policy)¶
SDK_VERSIONversions the SDK, and eachContracthas its own version.- While major is 0, any minor bump may break providers.
- From 1.0, removing or changing a method is a major bump.
- Adding a method with a default implementation, or adding a contract, is a minor bump.
- Compatibility follows from that:
- before 1.0, a provider must be built against exactly the host's contract
version (
0.pis accepted only by a0.phost); - from 1.0, a provider built against
M.pis accepted by a host atM.mwhenp <= m. - In-process providers are compiled against the SDK, so the check matters most for out-of-process plugins (ADR-0004 §6). It is enforced at registration regardless.
7. Relation links (#329)¶
Amended 2026-09-30. The dashboard and the CLI link a node's relation to the warehouse's own UI (for Unity Catalog, Catalog Explorer). This is a small, pure contract, so it is recorded here rather than in an ADR of its own: it adds no persisted format, no dependency and no crate.
- Capability
relation_link: the provider can turn a relation's name into a link. - Contract
relation_linker0.1 (ods-sdk/src/contracts/relation_link.rs):RelationLinker::link(relation) -> Result<RelationLink { url, label }, NoRelationLink>. - Synchronous, unlike the other contracts: it only formats a URL from configuration, with no I/O (ADR-0002: async only at I/O boundaries).
- The relation is passed as the project's artifacts render it (quoted as the warehouse quotes identifiers), so each provider parses its own dialect.
- Conservative (rule 3): a link is where the manifest says the relation is, never
proof that it exists. When no link can be built the provider says why
(
unsupported,not_offered,not_configured,invalid_setting,not_qualified,invalid_name,no_relation), and never guesses one (e.g. a default catalog for a two-part name). - Names are never repaired.
split_relation(in the SDK, given the warehouse's quote character) allows whitespace only around the.separators; whitespace inside an unquoted name, text straight after a closing quote, a stray or unclosed quote and an empty name are refused.path_segmentrefuses.and.., which browsers resolve as dot segments even percent-encoded, so a link can't leave its path. - Private (rule 9): a link is
https://only, with no user part or fragment; a query string may carry configuration (e.g. a workspace id), never a credential. Each name is percent-encoded into the path. The host is configuration, not a secret. - The label comes from the provider ("Open in Catalog Explorer"), so hosts never write a warehouse's name.
- Where it is used: only the CLI maps the target's adapter to a linker, checks the
capability with
choose(falling back to no link), and fills neutralRelationLinkFields(relation_url,relation_url_label,relation_url_unavailable) into the lineage document, the lineage JSON andods-web's Catalog input.ods-webrenders them and never imports a provider (ADR-0001, ADR-0009). - Providers:
ods-provider-databricks::CatalogExplorerbuildshttps://<host>/explore/data/<catalog>/<schema>/<table>from the configuredhost(ADR-0021 §3;DATABRICKS_HOSTfirst). The path is the one Databricks' own documentation uses for a table's page (thedatabricksWorkspaceUrlin access-request notifications). Those links also carry?o=<workspace id>, which selects the workspace when a host serves several, and so doesCatalogExplorer: from the provider'sworkspace_idsetting, else from a host that names it (Azure'sadb-<id>.<n>.azuredatabricks.net, GCP's<id>.<n>.gcp.databricks.com), else not at all. It is never guessed: an id that isn't one, that differs from the host's, or that differs between providers is refused with a reason. The conformance suite checks that a query doesn't change with the relation; that it holds no credential is the provider's to keep. A link may therefore carry a query string of configuration (never a credential, rule 9), but never a user part or fragment. - Alternatives considered:
- A method on an existing contract (
RelationInspector,RelationProbe): they are async and implemented by executors (the dbt executor), which don't know the workspace's UI; a linker needs only configuration. Rejected. - A URL template in configuration (
relation_url = "https://…/{catalog}/…"): no provider code at all, but every user would have to know the UI's URL scheme, and quoting and encoding would be left to a template. Kept as a possible later addition for warehouses without a provider. - Conformance:
conformance::relation_linkchecks the capability, a qualified relation's link (https, no user part or fragment, non-empty label, deterministic), that a missing part is never guessed, that names are encoded into exactly one segment each, and that an unconfigured provider says so. The fake (FakeRelationLinker) andCatalogExplorerboth pass it. - Versions: a new contract at 0.1; no existing contract changes. Adding a
contract is an SDK minor bump (§6), and it ships under the bump to
SDK_VERSION0.3 already in[Unreleased]for #323, besideerror_catalogue. - The lineage document (
GraphDocument,schema_version1) gains the three fields as optional additions, with no version change: its version is a single integer, which can't record a minor bump, and readers ignore fields they don't know. The JSON output envelope stays at 0.1 for the same additive change, as with earlier additions.
Consequences¶
- Positive:
- Every provider mechanism (versioning, capabilities, factories, conformance, fakes) is exercised end to end now.
- Rule 1 is enforced by CI, not just by review.
- Later contracts get real signatures and a template to copy.
- Negative / trade-offs:
-
2's full contract list is spread over later issues. Until then,
contracts/mod.rs¶is the tracker.
async-traitboxes every call's future, which is negligible next to provider I/O.- The vendor check is name-based. It catches
kind == "databricks"andDatabricksClient, but not behaviour smuggled through other means; review still matters. Common words that are also product names (oracle, fabric) are not checked, to avoid false positives. - Follow-up work:
- Each owning issue adds its contract with a fake and a suite.
-
99 generalises the harness pattern and publishes a plugin-author guide.¶
-
28 implements distributed locking on top of
LockProvider.¶
References¶
-
2, #3, #99, #28, #329; ADR-0001 (layers), ADR-0002 (async at I/O boundaries), ADR-0005 (provider config)¶
crates/ods-core/src/{capability,strategy}.rs,crates/ods-sdk/src/,providers/ods-provider-fake/