ADR-032 — URL-based per-resource secret references + multi-provider routing

Context

ADR-031 delivered ISecretProvider (builtin / vault / openbao adapters), the master-key-at-setup ceremony, and durable builtin persistence. A final audit of the implemented stream found one critical gap that this ADR closes:

Per-resource backend selection is captured in the UI but never routed. The setup wizard, the registry-credential selector, and the deployment selector all let an operator choose which backend a secret lives in (builtin / vault / openbao), but ISecretProvider is bound to exactly one provider for the whole process at DI time (ADR-031 §D2 + Crypto:SecretProvider:Backend). A reference resolved at runtime always hits that one global provider regardless of the per-resource choice. So an install cannot mix vault + openbao + builtin simultaneously, and the per-resource UI choice is silently inert.

The operator decision is: a secret is referenced by a URL that names the provider (kind + optional instance id) and the path within it. The URL is the canonical, non-secret hint shown/stored/logged everywhere a secret is configured. The UI gives a Select… affordance (pick provider, then path) plus type-ahead autocomplete that searches the provider for real paths.

Decision

D1 — A secret reference is a URL that names provider + path

The canonical reference grammar (full spec: docs/implplan/specs/secret-reference-url-scheme.md):

<scheme>://[<provider-id>/]<path>[#<version>]

The URL is non-secret — a pointer, never the value — so it is safe to store, display, log, and put in audit records. This is the “hints everywhere” property.

D2 — Multi-provider config replaces the single global backend

ADR-031 §D2’s single Crypto:SecretProvider:Backend is superseded by a provider registry plus a default seal target:

"Crypto": {
  "SecretProviders": [
    { "id": "builtin",   "kind": "builtin" },
    { "id": "vault-prod","kind": "vault",   "address": "https://vault.example:8200", "auth": { /* … */ } },
    { "id": "bao",       "kind": "openbao", "address": "http://openbao:8200",        "auth": { /* … */ } }
  ],
  "SecretProvider": {
    "DefaultSealTarget": "builtin"   // where NEW secrets seal when the URL omits a target
  }
}

The single-backend mode becomes the degenerate one-provider case: a config with exactly one entry behaves identically to today, and the legacy Crypto:SecretProvider:Backend=<kind> is honoured as a one-entry shorthand (back-compat shim) that synthesises a single default-id provider of that kind.

D3 — RoutingSecretProvider dispatches by URL

A new RoutingSecretProvider : ISecretProvider holds all configured providers (keyed by (kind, id)) and dispatches every operation by parsing the reference URL:

RoutingSecretProvider is registered as the active ISecretProvider; the concrete builtin/vault/openbao providers become inner instances it owns (constructed one-per-registry-entry). This is the concrete supersession of ADR-031 §D2: there is no longer a single inner provider bound globally.

Fail-closed: an unresolvable reference (unknown scheme, unknown provider-id, absent provider for a required seal) is rejected loudly (throw) — never a silent downgrade to another provider. A recognised-but-absent value still returns null (the ADR-031 contract is preserved).

Deterministic provider-id resolution (no silent mis-routing). The first URL segment is a provider-id IFF it matches a configured Crypto:SecretProviders[].id. Whether an unmatched first segment is folded into the path or rejected depends on how many instances of the scheme’s kind are configured:

The SealAsync path applies the same rule to a schemed seal target. Bind on SecretReference stays config-free (it knows only the id set); the instance-count-aware fail-closed decision lives in RoutingSecretProvider, which reads the parsed candidate first segment before binding. Full grammar + the single-vs-multiple table: secret-reference-url-scheme.md §1–§2.

D4 — Browse/search capability (ListAsync) powers autocomplete

A new ISecretProvider.ListAsync(providerId, pathPrefix, pageToken) returns path names only — never values:

It is gated crypto:read + tenant, rate-limited, and paged. It powers the UI type-ahead. It MUST NOT read or return secret material.

D5 — Reference-existing vs seal-new-here

The reference input supports both modes:

A new platform endpoint group /api/v1/admin/crypto/secret-providers enumerates configured providers + their describe state, and /api/v1/admin/crypto/secret-providers/{id}/paths?prefix=&pageToken= proxies ListAsync for autocomplete.

D6 — Relationship to existing ADRs

Rejected alternatives

Consequences