Spec — Secret reference URL scheme (canonical grammar)
- Status: Locked (binding for SPRINT_20260622_008…012).
- Date: 2026-06-22
- ADR: ADR-032 (supersedes ADR-031 §D2).
- Code home:
src/__Libraries/StellaOps.Cryptography.CredentialStore/SecretReference.cs(extend the existing parser; do NOT fork a second parser).
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
- Scheme is case-insensitive on parse, normalised lower-case on format.
- provider-id is OPTIONAL. It selects a specific configured instance when an install has more than one provider of that kind. Omitted ⇒ the default instance for that scheme (the first registry entry of that kind, or the entry flagged default).
- The URL carries no secret bytes — it is a pointer. Safe to store/show/log.
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:
vault://vault-prod/secret/app/db#3→ provider-idvault-prod, pathsecret/app/db, version3vault://secret/app/db#3→ default vault instance, pathsecret/app/db, version3
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 kind | Reference shape | Resolution |
|---|---|---|
| 1 (single) | provider-id omitted, or first segment not a configured id | The sole instance; the first segment is the bare mount/path. Unambiguous. |
| ≥ 2 (multiple) | explicit, matching provider-id | That exact instance (kind-checked). |
| ≥ 2 (multiple) | provider-id omitted entirely | Fail 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
| Form | Meaning |
|---|---|
| omitted, single instance of the kind | The sole (unambiguous) default instance of the scheme’s kind. |
| omitted, multiple instances of the kind | Fail-closed at routing — the default is ambiguous; an explicit provider-id is required. |
| present, matches a registry id of the same kind | That exact configured instance. |
| present, matches no registry id, single instance of the kind | The first segment is the bare mount/path under the sole instance (unambiguous). |
| present, matches no registry id, multiple instances of the kind | Fail-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 kind | Fail-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
| Scheme | Path | Version (#) | Resolves via |
|---|---|---|---|
builtin | tenant owner-key (e.g. integration:<guid>) | builtin credential generation (optional) | PostgresSecretCredentialStore (tenant-scoped) |
vault | <mount>/<path>[/<field>] | KV v2 version | Vault HTTP KV v2 |
openbao | <mount>/<path>[/<field>] | KV v2 version | OpenBao (same transport) |
authref | alias of vault (authref://vault/<path>#<field>) | — | Vault HTTP KV v2 |
file | filesystem path | — | inline (builtin provider) |
base64 | base64 payload | — | inline (dev) |
plain | cleartext payload | — | inline (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= mountsecret, pathapp/db, fieldpassword, version3). The parser MUST keep accepting the legacy#<field>form when the#body is non-numeric and no/<field>is present, mapping it toField(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:
Parse/TryParsestay non-throwing on recognised-but-empty payloads and throw only on an unrecognised scheme (a typo must be loud).BindpromotesCandidateFirstSegmenttoProviderIdiff it is in the set; otherwise it folds the candidate back intoPathand stays config-free (it knows only the id set, not per-kind instance counts). The fail-closed decision — unknown/ambiguous provider-id — is deferred to routing (RoutingSecretProvider), which knows how many instances of each kind are configured and applies the deterministic single-vs-multiple rule above. With a single instance of the kind a folded-back candidate is a valid bare mount; with two or more it is rejected as an unmatched provider-id (fail closed). Routing reads the originalCandidateFirstSegment(beforeBindcollapses it) to tell “operator named a segment” apart from “bare body”.Format()re-emits the canonical lower-case URL;versionandprovider-idare omitted when null. Round-trip stable.
6. Back-compat matrix (no stored reference must break)
| Stored value | Parsed as | Notes |
|---|---|---|
authref://vault/secret/x#field | scheme authref (vault alias), path secret/x, field field | unchanged |
vault://secret/x#field (non-numeric #) | scheme vault, path secret/x, field field | legacy field-via-# retained |
vault://secret/x#3 (numeric #) | scheme vault, path secret/x, version 3 | new version form |
builtin://owner-key | scheme builtin, owner-key | unchanged |
file:// base64: plain: | inline | unchanged |
bare single-backend config (Crypto:SecretProvider:Backend=vault) | default vault instance | back-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>.
