Spec — Secret reference URL scheme (canonical grammar)

A secret reference is a non-secret URL that names the provider (scheme + optional instance id) and the path within it. It is the canonical hint stored, displayed, logged, and audited everywhere a secret is configured.


1. Grammar

reference   = scheme "://" [ provider-id "/" ] path [ "#" version ]
            | inline-scheme ":" payload            ; dev-only inline forms
scheme      = "builtin" | "vault" | "openbao" | "authref"   ; authref = vault alias
inline      = "file" "://" path | "base64" ":" payload | "plain" ":" payload
provider-id = 1*( ALPHA / DIGIT / "-" / "_" )       ; matches a Crypto:SecretProviders[].id
path        = scheme-specific (see §3)
version     = 1*( ALPHA / DIGIT / "-" / "." )        ; KV version or builtin generation

Distinguishing provider-id from the first path segment

The first segment after :// is treated as a provider-id IFF it matches a configured Crypto:SecretProviders[].id; otherwise it may be the first path segment under the default instance — but only when that default is unambiguous. This keeps both forms working:

Because this disambiguation needs the configured provider set, SecretReference exposes BOTH a context-free parse (records the candidate first segment) and a routing-time Bind(IProviderIdSet) that finalises ProviderId vs Path. The context-free parse never throws on the ambiguity; routing resolves it.

Deterministic default rule (fail-closed on ambiguity)

Folding an unmatched first segment back into the path is only safe when the scheme’s kind has exactly ONE configured instance — then the default instance is unambiguous. The routing layer (RoutingSecretProvider) enforces this deterministically (ADR-032 §D3):

Configured instances of the scheme’s kindReference shapeResolution
1 (single)provider-id omitted, or first segment not a configured idThe sole instance; the first segment is the bare mount/path. Unambiguous.
≥ 2 (multiple)explicit, matching provider-idThat exact instance (kind-checked).
≥ 2 (multiple)provider-id omitted entirelyFail closed — the default is ambiguous; an explicit provider-id is required.
≥ 2 (multiple)first segment present but matches no configured id (e.g. a typo or an unconfigured prod-typo)Fail closed — treated as an unmatched provider-id, never silently folded into the path and mis-resolved against an arbitrary instance.

So vault://prod-typo/kv/x with two configured vault instances and no prod-typo instance throws (it does NOT silently resolve prod-typo/kv/x as a path against the first vault instance). The bare-mount form (vault://secret/app/db) keeps working only when there is a single instance of the kind.


2. provider-id semantics

FormMeaning
omitted, single instance of the kindThe sole (unambiguous) default instance of the scheme’s kind.
omitted, multiple instances of the kindFail-closed at routing — the default is ambiguous; an explicit provider-id is required.
present, matches a registry id of the same kindThat exact configured instance.
present, matches no registry id, single instance of the kindThe first segment is the bare mount/path under the sole instance (unambiguous).
present, matches no registry id, multiple instances of the kindFail-closed at routing (unmatched provider-id) — never silently folded into the path / fall through to a default.
present, matches a registry id of a different kindFail-closed (scheme/kind mismatch).

Why the single-vs-multiple split: the first segment is only ambiguous between “provider-id” and “mount/path” when more than one instance of the kind could serve it. With exactly one instance the default is unambiguous, so the bare-mount form stays valid. With two or more, silently choosing one would let a typo’d or unconfigured provider-id mis-resolve against the wrong backend — so routing fails closed instead (ADR-032 §D3). The seal path applies the same rule to a schemed seal target.


3. Path semantics per scheme

SchemePathVersion (#)Resolves via
builtintenant owner-key (e.g. integration:<guid>)builtin credential generation (optional)PostgresSecretCredentialStore (tenant-scoped)
vault<mount>/<path>[/<field>]KV v2 versionVault HTTP KV v2
openbao<mount>/<path>[/<field>]KV v2 versionOpenBao (same transport)
authrefalias of vault (authref://vault/<path>#<field>)Vault HTTP KV v2
filefilesystem pathinline (builtin provider)
base64base64 payloadinline (dev)
plaincleartext payloadinline (dev only)

Field vs version note (back-compat): the legacy vault://<path>#<field> form used # for the KV field. The new grammar uses # for version. To keep both unambiguous, the field stays in the path as a trailing /<field> segment (e.g. vault://secret/app/db/password#3 = mount secret, path app/db, field password, version 3). The parser MUST keep accepting the legacy #<field> form when the # body is non-numeric and no /<field> is present, mapping it to Field (existing behaviour) — see §6.


4. Examples

builtin://integration:7b3f…d2                 # default builtin, owner-key, current generation
builtin://integration:7b3f…d2#2               # builtin, generation 2
vault://secret/polaris/gitlab-pat/token       # default vault, mount secret, path polaris/gitlab-pat, field token
vault://vault-prod/secret/app/db/password#5   # instance vault-prod, field password, KV version 5
openbao://bao/kv/notify/smtp/password         # instance bao, openbao KV v2
authref://vault/secret/app/db#password        # legacy alias (field via #), still parses
file:///run/secrets/master.key                # inline file (dev/air-gap material)

5. SecretReference parse / format contract (C#)

SecretReference (existing record) gains: ProviderId, Version, the candidate-first-segment field, and Bind/Format. Existing members (Scheme, Path, Field, OwnerKey, RawValue, Parse, TryParse, ForBuiltin) are preserved.

public sealed record SecretReference(string RawValue)
{
    public string Scheme   { get; init; } = "builtin";
    public string? ProviderId { get; init; }   // NEW: configured instance id, null = default
    public string? Path    { get; init; }
    public string? Field   { get; init; }       // back-compat (#<field> legacy + trailing /<field>)
    public string? Version { get; init; }       // NEW: #<version> (KV version / builtin generation)
    public string? OwnerKey{ get; init; }
    public string? CandidateFirstSegment { get; init; }  // NEW: unresolved id-vs-path candidate

    public static SecretReference Parse(string rawValue);                 // existing
    public static bool TryParse(string? rawValue, out SecretReference? r, out string? error);  // existing
    public static SecretReference ForBuiltin(string ownerKey);            // existing

    // NEW: finalise ProviderId vs Path using the configured provider id set.
    public SecretReference Bind(IReadOnlySet<string> configuredProviderIds);

    // NEW: canonical re-serialisation (idempotent: Format(Parse(s)) == canonical(s)).
    public string Format();
}

Rules:


6. Back-compat matrix (no stored reference must break)

Stored valueParsed asNotes
authref://vault/secret/x#fieldscheme authref (vault alias), path secret/x, field fieldunchanged
vault://secret/x#field (non-numeric #)scheme vault, path secret/x, field fieldlegacy field-via-# retained
vault://secret/x#3 (numeric #)scheme vault, path secret/x, version 3new version form
builtin://owner-keyscheme builtin, owner-keyunchanged
file:// base64: plain:inlineunchanged
bare single-backend config (Crypto:SecretProvider:Backend=vault)default vault instanceback-compat shim (ADR-032 §D2)

The parser disambiguates #<field> vs #<version> by content: an all-[0-9] body after #Version; otherwise ⇒ Field (legacy). A trailing /<field> segment is the preferred new form and always wins over a #<field>.