Resolvers¶
- Status: ✅ Accepted
- Scope: the
Resolverabstraction, how the Loader invokes resolvers on the merged tree, the default resolvers (env,secret,file), how custom schemes register, and — the core of this spec — how thesecret:scheme reaches a vault through the background agent, spawning and unlocking one the same way the CLI does when none is running. The token grammar (delimiters, escaping, nesting, where a token may appear) lives in templating.md; this spec covers what a resolver does with a token'spath. Vault selection syntax ([vault:]namespace/key) is defined in vault-registry.md §5; theManager/VaultDrivermechanics in vault-drivers.md.
1. Model¶
A resolver turns one token's path into a concrete string, for a given
scheme. The Loader walks the merged value tree (step 3 of the lifecycle,
config-loading.md §5), and for each $(scheme:path) token
it dispatches to the resolver registered for scheme. A scheme with no
registered resolver is a load-time error (ErrUnknownScheme, §7).
package flexconf
// Resolver turns a token's path into a value for one scheme. Implementations
// are invoked on the merged tree during Load (config-loading.md §5, step 3) and
// MAY be called many times per Load. A Resolver MUST NOT mutate shared state
// during Resolve; the Loader may call resolvers for independent tokens in any
// order (resolution order across tokens is unspecified — a resolved value is
// never re-scanned, templating.md).
type Resolver interface {
// Scheme is the token scheme this resolver handles ("env", "secret", ...).
Scheme() string
// Resolve maps the path (the text after "scheme:") to a value. The returned
// string is spliced into the scalar as-is; type conversion happens later at
// bind time (config-loading.md §5, step 4). Errors are wrapped by the Loader
// with the offending key path; a resolver MUST NOT include a resolved secret
// value in its error (redaction, config-loading.md §6).
Resolve(ctx context.Context, path string) (string, error)
}
- A resolved value is inert: it is never re-scanned for tokens (a
$(...)inside a secret is literal). This is fixed in templating.md and restated here because it is a resolver-visible guarantee. - Resolvers are the only thing that turns a token into a value; binding, merging, and validation never see the token, only its resolved string.
2. Registration & the resolver set¶
The default resolvers — env, secret, file — are the resolver set New
installs on every Loader. Throughout the specs these three are always the
"default resolvers"; "resolver" unqualified means the abstraction of §1 (a default
resolver, a custom-scheme resolver, or a Loader-scoped one), never specifically
the built-in trio. Three extension points, mirroring the vault-driver pattern
(vault-drivers.md §9):
package flexconf
// RegisterResolver registers a custom-scheme resolver process-wide, so importing
// a package for its side effect makes a scheme available by name. Registering a
// scheme that is already registered (including a default resolver:
// "env"/"secret"/"file") PANICS — schemes are a global namespace and silent
// shadowing is a footgun.
func RegisterResolver(r Resolver)
// WithResolver overrides or adds a resolver for THIS Loader only (tests, or an
// app that wants a bespoke secret/env source without touching the global set).
// A Loader-scoped resolver shadows a global one of the same scheme. It adds to /
// overrides whatever set is already in effect (the default set, or a set fixed by
// WithResolvers below).
func WithResolver(r Resolver) Option
// WithResolvers REPLACES this Loader's default resolver set with exactly the
// resolvers given, instead of the env/secret/file default. It is how a Loader is
// made progressively — or completely — static:
//
// WithResolvers() // EMPTY set: no token processing at all (§2.1)
// WithResolvers(myEnv, myFile) // exactly these; no secret: resolver present
//
// WithResolver still composes on top of the resulting set. Use this to build a
// Loader that must NOT carry a given scheme — most importantly the internal
// registry loader, which cannot have a secret: resolver (§2.1).
func WithResolvers(rs ...Resolver) Option
- The default
env/secret/fileresolvers are installed byNew; their behaviour is tuned by Loader options (WithEnv,WithSecretPolicy, §3–§5) rather than by re-registering the scheme.WithResolversis the only way to install a different (including empty) set in their place. - Custom schemes are for non-secret composition (
$(file:...), a hypothetical$(http:...)); a custom scheme MUST NOT be used to smuggle secrets past the redaction/taint machinery — secret material only ever enters throughsecret:.
2.1 The empty resolver set — a static Loader¶
WithResolvers() with no arguments installs an empty set. Such a Loader does
no token processing whatever: it reads, merges, binds, and validates literal
YAML, and any $(...) occurrence in the input — a scalar scheme token or a
$(config:...) include (templating.md §7) — is a load-time error
(ErrUnknownScheme, §7), because there is no handler registered for it. Include
expansion is part of the default processing the empty set removes, so a static
Loader neither resolves nor splices; the input is taken exactly as written.
This is the mechanism the vault registry uses to load itself through the ordinary Loader pipeline (vault-registry.md §3, config-loading.md §1). Two independent reasons force an empty set there:
- A
secret:resolver would be circular. Thesecret:resolver resolves against the registry (§5.1, step 1). If the registry itself were loaded by a Loader carryingsecret:, loading the registry would depend on the registry. The registry loader therefore MUST NOT carry asecret:resolver. - The registry is normatively static — it MUST contain no
$(...)tokens at all, not even$(env:...)(vault-registry.md §2.3). An empty set enforces that directly: any token fails loud rather than resolving.
So the registry is not a special-cased loader; it is exactly
(the shared pipeline) + WithResolvers() over the environment-derived layer files
(vault-registry.md §3). ~-expansion and relative-path
resolution (vault-registry.md §2.3) are not resolver work
— they are a post-bind normalization the registry applies to the bound
VaultConf, unaffected by the empty resolver set.
Note — replacement, not subtraction (v1).
WithResolversreplaces the set wholesale; there is noWithoutResolver("secret")that keeps the other defaults and drops one. The two shapes v1 supports are the full default set (fromNew, optionally extended withWithResolver) and an explicit replacement set (most commonly empty). A surgical "all defaults except secret" is niche and left to a caller-assembled explicit set; a first-class subtractive option is deferred.
3. env: — environment variables¶
- Resolves to the value of environment variable
NAME. - Missing variable is a hard error (
ErrEnvNotSet, §7) — fail loud, per specs index. flexconf does not silently substitute empty string. - The source is the process environment by default;
WithEnv(env func(string) (string, bool))overrides it for a Loader (testability). Built-in default readsos.LookupEnv. NAMEis opaque to flexconf; no interpolation or nesting inside it.
Decided — no
$(env:NAME:-default). An env-only default (missing var → literal default) is rejected: a missing env var is always a hard error (above), and no scheme has defaulting syntax (templating.md §10). Supply fallbacks via config-file defaults in the schema/binding layer instead.
4. file: — file contents¶
- Resolves to the verbatim contents of the file (bytes read as a UTF-8
string; no trailing-newline trimming — use the value exactly as stored, so a
PEM or multi-line token round-trips). Missing/unreadable file →
ErrFileNotFound/ a wrapped read error (§7). - A relative path resolves against the directory of the config file that
contained the token (same base rule as
$(config:...)includes, templating.md §7), not the process working directory — so a value's meaning does not depend on where the app was launched. - The file source is injectable via
WithFS(fsys fs.FS)(testability). file:reads non-secret content. Reading a credential is what a vault driver (e.g. the KeePass driver, vault-drivers.md §10) is for;file:values are not tainted/redacted.
5. secret: — vaults, through the agent¶
This is the scheme that unifies config and secrets. Its path is
[<vault>:]<namespace>/<key> (parsing rules: vault-registry.md §5); it
selects a named vault (or the default) and returns that secret's single
string value (vault-drivers.md §7). Every resolved secret:
value is flagged secret-origin, so the Loader never prints it in an error
message (errors.md §1). v1 has no config-dump feature, so there
is nothing to redact beyond error messages — there is no separate taint set or
«redacted» dump format (errors.md §4).
5.1 Resolution goes through the agent by default¶
To match the CLI's mental model and avoid re-prompting for a master password on
every Load, the secret: resolver reaches each referenced vault through the
background agent (cli.md §6) — the ssh-agent-style process that holds
one unlocked vault in memory keyed by VaultID (vault-registry.md §6):
- Parse
path→(vaultName?, namespace/key); resolvevaultName(or the default) against the effective vault registry (vault-registry.md) to aVaultConf(driver + non-secret config) and derive itsVaultID. - If an agent is running for that
VaultID, sendget{addr}over its socket and use the value. This is the common, no-prompt path: a vault unlocked once (bysecret unlock, or by a prior token in this sameLoad) serves every laterGetfor free. - If no agent is running, do exactly what the CLI's auto-unlock does
(cli.md §4.1, §unlock): build the vault's real driver from the
registry,
Configureit, calldriver.Credentials(), collect answers via the process-wideflexpromptprompter (prompter.md) in one interaction, spawn a detached agent for theVaultID, forwardunlock{answers}to it, wipe the answers, then retry theget. The agent then stays resident and idle-locks per its timeout (cli.md §6.5).
The resolver therefore never holds an unlocked vault itself; the agent is the single owner of unlocked material, and the single-writer/serialized-access model (vault-drivers.md §10a) holds unchanged.
5.2 The internal agent-proxy driver¶
The client side of §5.1 is an agent-proxy VaultDriver provided by the
internal agent package (internal/agent, §5.5) rather than bespoke resolver
code. It is not a publicly registered driver — an app would never write
driver: agent in a registry (a vault is defined by its real backend, and
proxying is a resolution concern, not a vault definition). It proxies to a
running agent for a target VaultID:
- Configure takes the target vault name (and a handle to the resolved registry) — the vault whose secrets it proxies. It performs no backend I/O.
- Credentials/Unlock: to spawn+unlock a missing agent it needs the real
backend's credential declaration, so it reads the target vault's real driver
from the registry and forwards that driver's
Credentials();Unlockforwards the collected answers to the spawned agent (never opening the backend itself). - Get/Set/List dial the agent socket and forward the request, mapping agent
responses back to the
flexvaultsentinels (vault-drivers.md §8).
The secret: resolver builds one flexvault.Manager per referenced vault,
wrapping the internal agent-proxy driver. Uniform Manager lifecycle, no
special-casing in the loader — the "reach the agent, spawn if absent" behaviour is
entirely inside the proxy.
5.3 In-process (agent-less) mode¶
An agent is not always wanted — a one-shot CI job, a short-lived batch, or a
non-interactive env-vault backend (a backlog
candidate) has nothing to gain from a resident process and may not be able to
spawn one (see §5.4). A Loader
MAY select in-process resolution:
// WithSecretPolicy chooses how the secret: resolver reaches vaults:
// PolicyAgent (default) — proxy through the background agent, spawning one
// via the process prompter if none is running (§5.1).
// PolicyInProcess — build a flexvault.Manager around the vault's REAL driver
// and unlock it in-process via the process prompter; no agent is
// spawned and unlocked material lives only for this Loader.
func WithSecretPolicy(p SecretPolicy) Option
In PolicyInProcess, each vault is unlocked once per process (the Loader
caches the unlocked Manager for the life of the Loader) and locked when the Loader
is closed. This still avoids per-token re-prompting within a run, just without
cross-process sharing.
5.4 Spawning constraint & fallback (self-exec)¶
Agent spawning is self-exec: the process re-executes os.Args[0] with a
marker and runs the agent loop at the top of main via the internal agent
package's RunAgentIfRequested() (cli.md §6.2). A flexcli-mounted app
and the cmd/flexconf binary already call it. A program that uses flexconf as
a library only and never wired that entry point cannot host a self-exec agent —
re-running its main would not run the agent loop.
Rule (normative):
- A library consumer that wants agent-backed resolution MUST call
flexconf.RunAgentIfRequested()early inmain(a public re-export of the internalRunAgentIfRequested; it is a no-op unless the self-exec marker is present). - If
PolicyAgentis selected and the process cannot host a spawned agent (entry point not wired and no reusable agent already running), the resolver MUST fail loud with a clear, actionable error (ErrAgentUnavailable, §7) telling the caller to either wire the entry point or selectPolicyInProcess. It MUST NOT silently hang or silently downgrade.
5.5 Package layering consequence (updates prior specs)¶
For the secret: resolver (in flexconf) to reach and spawn an agent without
importing flexcli (forbidden by overview.md §6), the agent
runtime lives in a module-root internal package, internal/agent — importable
by every package in the module (flexconf, flexcli, cmd/flexconf) yet kept
out of the public API surface:
internal/agent(new) — the agent runtime: the socket protocol, the client (Dial), the server loop (Serve), self-exec spawning (RunAgentIfRequested),VaultIDderivation, socket/PID/lock-file management, the idle auto-lock, and the internal agent-proxyVaultDriverof §5.2. It importsflexvault+flexprompt; it is not part of the SDK's public API.flexconfimportsinternal/agentfor thesecret:resolver and re-exportsRunAgentIfRequested(§5.4).flexclikeeps only the Cobra command group and process-option plumbing; it now drivesinternal/agentinstead of owning the agent.flexcli'sApp.RunAgentIfRequested()delegates tointernal/agent'sRunAgentIfRequested().
Dependency direction stays acyclic and downward:
internal/agent → flexvault → flexprompt, with both flexconf → internal/agent
and flexcli → internal/agent. This supersedes the "agent lives in flexcli"
placement in cli.md §1 and the module-layout table in
overview.md §6; the CLI behaviour those describe (§4.1, §6) is
unchanged — only the code's home moves.
6. Ordering within a Load¶
- Resolution runs on the merged tree, after layering, before binding (config-loading.md §5). A value introduced by a higher-precedence layer may itself contain a token.
- Tokens are resolved independently; a resolved value is not re-scanned (§1, templating.md). There is therefore no defined inter-token order and no token-to-token data flow.
- Within one
Load, vault unlocks (hence prompts) are serialized — at most oneDispatchruns at a time (config-loading.md §4, prompter.md §1). The firstsecret:token targeting a given vault triggers the unlock; later tokens for that vault reuse it (agent or in-process cache), so aLoadprompts at most once per referenced vault.
7. Errors¶
Sentinel errors, usable with errors.Is (full taxonomy in errors.md):
var (
ErrUnknownScheme = errors.New("flexconf: no resolver registered for scheme")
ErrEnvNotSet = errors.New("flexconf: environment variable not set") // env: (§3)
ErrFileNotFound = errors.New("flexconf: file not found for file: token") // file: (§4)
ErrAgentUnavailable = errors.New("flexconf: no agent and process cannot host one") // secret:, PolicyAgent (§5.4)
)
- The Loader wraps a resolver error with the offending key path and, for
secret:, redacts any value (config-loading.md §6). secret:also surfaces theflexvaultsentinels (ErrNotFound,ErrLocked,ErrAuth, …, vault-drivers.md §8) and, on cancelled prompting,flexprompt.ErrPromptCancelled(prompter.md §5) — terminal, no retry.
8. Resolved decisions¶
- Resolver interface —
Scheme()+Resolve(ctx, path), invoked on the merged tree; resolved values are inert (§1). - Registration — default resolvers per-Loader; global
RegisterResolverfor custom schemes;WithResolverfor Loader-scoped overrides;WithResolversto replace the default set (empty = a static Loader, §2, §2.1). - Default resolvers —
env(hard-fail on missing),file(verbatim, relative to the containing file, non-secret),secret(§3–§5). secret:goes through the agent by default — an internal agent-proxy driver; spawn+unlock like the CLI when none is running (§5.1–§5.2).- In-process fallback mode —
WithSecretPolicy(PolicyInProcess); unlock once per process, no agent (§5.3). - Agent runtime lives in
internal/agent;flexclibecomes a thin wrapper; layering stays downward-only (§5.5). Supersedes the agent's placement in cli.md §1 and overview.md §6.
9. Settled — out of scope for v1¶
These were once tracked as open; they are now firm v1 decisions.
- Secret-origin redaction, no dump format. Every
secret:value is flagged secret-origin so it never appears in an error message (errors.md §1). Because v1 has no config-dump feature, there is noDump()/«redacted»format and noredaction.md— this is resolved, not deferred (errors.md §4). - Lazy / rotating secrets — no. v1 resolves eagerly at
Load(overview.md §8). There is no lazy/on-access mode; a rotating secret is re-read only on a freshLoad. - Agent cache coherence (accepted limitation, out of scope). An unlocked agent holds the
vault's decrypted contents in memory, so a value read from it may be stale
if the underlying secret changes after the agent unlocked it — e.g. an
out-of-band rotation, another tool editing the backend file, or a
setthat did not go through this agent. v1 makes no freshness guarantee across such external modifications: within the single-writer model (vault-drivers.md §10a) writes funnel through the one agent and it updates its own view, but reads are otherwise served from the unlocked snapshot with no invalidation/TTL/re-read. A cache-invalidation orstatus-based freshness check is deferred to the caching/rotation work (vault-drivers.md §11); until then, a consumer needing a guaranteed-fresh value shouldsecret lock(or restart the agent) first.