Vault Drivers & Manager¶
- Status: ✅ Accepted
- Scope: the
VaultDriverinterface and its lifecycle (Configure → Credentials → Unlock), theManagerthat drives an implementation, how it dispatches declared credentials to thePrompter, secret addressing, and the first concrete driver (KeePass). ThePrompteritself — the interface,PromptRequest, the process-wide singleton, and the built-in prompters — lives in prompter.md. Caching, rotation, the resolver integration, and multiple vaults are explicitly deferred (§11, §12).
This spec defines how flexconf talks to a secret backend. A vault driver is
the backend-specific implementation (KeePass first; HashiCorp Vault, cloud
secret managers, encrypted files later). A manager wraps a single driver and
is the object the rest of flexconf (and application code) uses. The manager is
what ultimately backs the secret: templating scheme (see
overview.md §5 and templating.md).
Packages. The types in this spec live in the module's
flexvaultpackage (see overview.md, "Module layout"):flexvaultownsVaultDriver,Manager,Capabilities, the sentinel errors, driver registration, the config decoders, and the concrete drivers underflexvault/driver/*(e.g.flexvault/driver/keepass). ThePrompterand its process-wide singleton live in the separateflexpromptleaf package (prompter.md). The config-loading packageflexconfimportsflexvaultto back thesecret:resolver;flexvaultimportsflexprompt;flexpromptimports neither.
1. Roles: driver vs. manager¶
Both expose the read/write surface (Get/Set/List), so their relationship
must be clear:
-
VaultDriver— the mechanics of one backend. It owns its own configuration (Configure), declares which credentials it needs (Credentials), consumes the answers to open the backend (Unlock), and reads/writes entries. It is deliberately thin, never touches a terminal or an env var, and MAY assume it is not called concurrently (the manager serializes access). -
Manager— the policy and lifecycle layer around one driver. It: - drives the lifecycle in order and enforces state (reject
Get/Set/Listbefore a successfulUnlockwithErrLocked); - owns the single credential dispatch: it asks the driver what it needs,
collects it from the user in one interaction via the process-wide
Prompter(flexprompt.GetPrompter(), prompter.md, overridable per-Manager withWithPrompter), and passes the answers toUnlock; - applies the unlock retry policy (§5);
- serializes concurrent access so drivers stay simple;
- validates/normalizes secret addresses and presents a uniform error surface;
- is the single object handed to the
secret:resolver and to app code; - is the natural home for optional caching (deferred, see §11).
2. Driver lifecycle¶
A driver moves through a fixed sequence, orchestrated by the manager:
Configure ──▶ (Manager: Credentials ─▶ Dispatch) ──▶ Unlock(answers) ──▶ (Get / Set / List)* ──▶ Lock
│ │ │ │
non-secret driver Prompter asks the driver consumes
settings declares user for all of them the answers to
(path, addr) its needs as ONE interaction open the backend
- Configure loads the driver's non-secret settings (file path, server address, read-only flag). No backend I/O, no secrets.
- Credentials returns the list of secret values the driver needs
(
[]flexprompt.PromptRequest), computed from its configured state. A driver needing no secret input returns an empty slice. - Dispatch is performed by the Manager, not the driver: it hands the
declared requests to the process-wide
Prompter(flexprompt.GetPrompter(), prompter.md), which asks the user for all of them in a single interaction and returns ananswersmap keyed byPromptRequest.ID. - Unlock consumes the
answersmap to open/authenticate the backend.
Why declare/consume, and why the Manager owns dispatch. We want all of a
vault's credentials gathered in one user interaction, and we want the
component driving the vault (the Manager) to control when that happens. The
tempting alternative — the driver calls prompt() several times "async" and the
Manager triggers a single dispatch in the middle — cannot be built cleanly in
Go: there is no way to detect that a goroutine has blocked waiting on an answer,
so the Manager could only know the driver is "done asking" via an explicit
signal, plus goroutines and promises. That pays the full cost of async to land
exactly where the simpler declare/consume split already is. So the driver
declares (Credentials) and later consumes (Unlock(answers)); the
PromptRequest.ID (prompter.md §1) is the join key between the
two (§4).
Plaintext vs. derived/session material. Short plaintext lifetime does not mean zero retention. Many backends need derived material for the whole unlocked session, not just to open the vault:
- KeePass: writing an entry (
Set) re-encrypts and saves the.kdbx, which needs the composite key (derived from password [+ keyfile]). A writable vault MUST therefore retain the composite key for the session. - HashiCorp Vault: the token authenticates every request, so it is held for the session.
The rule (normative): a driver SHOULD discard the plaintext credential (the
answers value) once the session key material is derived, SHOULD retain only
the derived material it needs for subsequent operations (and only when it will
need it — e.g. a read-only KeePass vault need not keep the composite key after
decrypt), and MUST clear all retained material on Lock.
2.1 Two kinds of input: vault config vs. vault secret¶
A vault needs two different kinds of input, and flexconf treats them differently on purpose:
| Vault config | Vault secret | |
|---|---|---|
| Examples | KeePass file path; Vault URL, namespace, mount; read-only flag | KeePass master password; Vault token |
| Sensitivity | Non-secret | Secret |
| Declared by | the driver's config struct (Configure, §4) |
Credentials() []flexprompt.PromptRequest (§4) |
| Collected by | decode bound by the host (§2.1) |
Manager via the process-wide flexprompt prompter's Dispatch (prompter.md) |
| Typical source | the vault registry (vaults.yaml) or env vars (ad-hoc) |
env, terminal, GUI dialog, CI secret store |
How is vault config sourced? The driver does not decide — it just calls
decode(&itsConfigStruct). The host binds decode to a source. Vault
definitions live in one place — the vault registry
(~/.config/flexconf/vaults.yaml, vault-registry.md); apps do not define
vaults in their own config.yaml. So in the normal flexconf flow the host binds
decode to the selected vault's sub-tree of the registry (the VaultConf's
driver keys). For standalone/programmatic use without a registry file, flexvault
ships adapters (MapDecoder/EnvDecoder) that bind decode to a map or to
environment variables — the same driver code either way.
# ~/.config/flexconf/vaults.yaml — the sole home for vault definitions
vaults:
personal:
driver: keepass
path: ~/secrets.kdbx # static; no $(...) tokens (vault-registry.md §2.3)
readonly: true
# the master password is NOT here — it is requested via the Prompter at unlock time
There is no "integrated mode" in which an app bundles vault config inside its own
config.yaml; the registry is always the source of a vault's non-secret config.
Bootstrap ordering constraint (normative). Vault config MUST be static:
its values are read literally and it MUST NOT contain any templating tokens
($(...)) — neither $(env:...) nor $(secret:...). $(secret:...) could
never work (the vault is what resolves secret:), and $(env:...) is excluded
so a definition shared across contexts behaves identically everywhere. This is
the static-definitions rule in vault-registry.md §2.3; see it for the
rationale and the ~-expansion allowance.
3. Credential collection via the Prompter¶
A driver never reads a terminal, an env var, or a dialog directly. It declares
what it needs (via Credentials, §4); the Manager passes those requests to a
Prompter, which asks the user for all of them in one interaction and
returns the answers keyed by PromptRequest.ID.
The Prompter interface, the PromptRequest type, the process-wide singleton
(SetPrompter/GetPrompter), the built-in implementations
(NewCLIPrompter/NewMapPrompter/NewEnvPrompter), and the prompter sentinel
errors all live in the flexprompt leaf package, specified in
prompter.md. This spec references them; it does not redefine
them.
What is specific to vault drivers — how Credentials/Unlock declare and
consume the requests (§4), and how the Manager owns the single Dispatch and
its retry policy (§5) — is defined below.
4. VaultDriver interface¶
// VaultDriver is implemented by each secret backend. A driver instance manages
// access to exactly one vault. The Manager serializes calls, so implementations
// need not be safe for concurrent use unless they document otherwise.
type VaultDriver interface {
// Name returns the stable driver identifier (e.g. "keepass", "vault"),
// used for registration/selection.
Name() string
// Configure loads this driver's non-secret settings. decode unmarshals the
// driver's configuration section into a value the driver owns (typically a
// driver-defined struct). Configure MUST NOT contact the backend and MUST
// NOT read secrets. Called once, before Credentials/Unlock.
Configure(decode func(target any) error) error
// Credentials declares the secret values this driver needs to unlock, based
// on its configured state (e.g. password Optional when a keyfile is set).
// Returns an empty slice if no secret input is needed. Each request's ID is
// the key the driver will read from Unlock's answers map.
Credentials() []flexprompt.PromptRequest
// Unlock opens the vault using the configured settings and the answers
// gathered for the requests from Credentials (keyed by PromptRequest.ID).
// It MUST succeed before Get/Set/List work. On bad credentials it MUST
// return ErrAuth (wrapped with detail) and leave the vault locked. Drivers
// SHOULD discard plaintext answers once derived material is computed (§2).
Unlock(ctx context.Context, answers map[string]string) error
// Capabilities reports what this configured vault supports (e.g. writes).
Capabilities() Capabilities
// Get retrieves the secret value at "namespace/key" (§6). ErrNotFound if
// absent, ErrLocked if not unlocked. A secret is a single string value.
Get(ctx context.Context, addr string) (string, error)
// Set stores value at "namespace/key" (create or overwrite). ErrReadOnly if
// the vault is not writable, ErrLocked if not unlocked.
Set(ctx context.Context, addr string, value string) error
// List enumerates the vault (§6): with namespace == "" it returns the
// namespaces; with a namespace it returns the key names within it.
// ErrLocked if not unlocked.
List(ctx context.Context, namespace string) ([]string, error)
// Lock releases access and clears sensitive in-memory material. After Lock,
// Get/Set/List MUST return ErrLocked until Unlock succeeds again. Lock on an
// already-locked driver is a no-op returning nil.
Lock() error
}
// Capabilities reports what a configured vault supports. It has room to grow.
type Capabilities struct {
Writable bool // Set is supported (e.g. KeePass opened read/write)
Creatable bool // the driver can create a brand-new empty vault (Initializer)
}
Notes:
context.Contextcovers cancellation/timeouts for remote backends. Local drivers (KeePass) SHOULD honor cancellation where practical but MAY be effectively synchronous.- Write support is optional: a read-only vault MUST report
Writable: falseand MUST returnErrReadOnlyfromSetrather than silently succeeding. - Re-unlock after
Lock: the Manager re-runs Credentials → Dispatch → Unlock, so a re-unlock re-prompts. Capabilities()is safe to call at any time; beforeConfigureit returns the zero value (all fields false). It is only meaningful afterConfigure, once the driver knows its settings (e.g.readonly).Lockis synchronous and takes no context: it performs a best-effort flush of any pending state and then MUST clear all in-memory secret material even if the flush fails.Lockon an already-locked driver is a no-op returning nil.
Optional vault creation — the Initializer¶
Some backends can create a brand-new empty vault (e.g. a fresh .kdbx file);
others (managed cloud vaults) are provisioned out-of-band. This is an optional
capability, kept off the core VaultDriver interface:
// Initializer is implemented by drivers that can create a new, empty vault.
// A driver that implements it MUST report Capabilities().Creatable == true.
type Initializer interface {
// InitCredentials declares the secret values needed to create the vault
// (e.g. a new master password, typically with PromptRequest.Confirm set).
// Distinct from Credentials(), which unlocks an EXISTING vault.
InitCredentials() []flexprompt.PromptRequest
// Init creates a new, empty vault at the configured location using answers
// gathered for InitCredentials(). It MUST fail (not overwrite) if the vault
// already exists. On success the vault is created but NOT left unlocked.
Init(ctx context.Context, answers map[string]string) error
}
The Manager exposes this via Create (§5); the flexcli secret init command
(see cli.md) drives it. Drivers that cannot create a vault simply do
not implement Initializer and report Creatable: false.
The Configure input — decode callback¶
Configure receives a decode func(target any) error rather than a concrete
config type, so each driver defines and owns its config struct while flexconf
stays format-agnostic and can select drivers dynamically by name:
type keepassConfig struct {
Path string `flexconf:"path"`
KeyFile string `flexconf:"keyfile"` // path to key file (non-secret); contents read at Unlock
ReadOnly bool `flexconf:"readonly"`
}
func (d *driver) Configure(decode func(any) error) error {
var c keepassConfig
if err := decode(&c); err != nil { return err }
if c.Path == "" { return errors.New("keepass: path is required") }
d.cfg = c
return nil
}
In the full flexconf flow, the loader supplies decode bound to the driver's
config sub-tree (already parsed from plain, non-secret sources). For standalone
use, flexvault provides adapters: flexvault.MapDecoder(map[string]any) and
flexvault.EnvDecoder(prefix string), each returning a func(any) error.
Declaring and consuming credentials — the ID constant¶
Credentials and Unlock are joined only by PromptRequest.ID. To keep them
from drifting (a typo would silently yield an empty answer), a driver package
defines each key as an exported constant used in both places — the constants also
document, for non-interactive prompters, exactly what keys a driver will ask for:
// keepass package
const CredPassword = "password"
func (d *driver) Credentials() []flexprompt.PromptRequest {
return []flexprompt.PromptRequest{{
ID: CredPassword,
Label: "KeePass master password (" + d.cfg.Path + ")",
Secret: true,
Optional: d.cfg.KeyFile != "", // password may be optional when a keyfile is set
}}
}
func (d *driver) Unlock(ctx context.Context, answers map[string]string) error {
pw := answers[CredPassword] // same constant — no drift
// build composite key from pw and/or keyfile, decrypt d.cfg.Path, ...
// on bad credentials: return fmt.Errorf("keepass: %w", ErrAuth)
_ = pw
return nil
}
5. Manager¶
// Manager wraps a single VaultDriver, drives its lifecycle, enforces state, and
// serializes access. It is safe for concurrent use.
type Manager struct {
// unexported: driver, optional prompter override, mutex, unlocked flag, retry config, (later) cache
}
// NewManager returns a Manager driving the given driver. Credentials are
// collected via the process-wide flexprompt.GetPrompter() unless overridden with
// WithPrompter. Options tune behavior. The vault starts locked.
func NewManager(driver VaultDriver, opts ...Option) *Manager
// WithUnlockRetries caps the ErrAuth re-attempts on Unlock (default 3).
func WithUnlockRetries(n int) Option
// WithPrompter overrides the process-wide prompter for this Manager only
// (useful for tests and, later, multiple vaults with different prompters).
func WithPrompter(p flexprompt.Prompter) Option
// Granular lifecycle:
func (m *Manager) Configure(decode func(target any) error) error
func (m *Manager) Unlock(ctx context.Context) error // Credentials -> Dispatch -> Unlock(answers)
// Open is a convenience running Configure -> Unlock.
func (m *Manager) Open(ctx context.Context, decode func(target any) error) error
// Create initializes a brand-new empty vault (Configure -> InitCredentials ->
// Dispatch -> Init). It requires the driver to implement Initializer; otherwise
// it returns ErrUnsupported. It does NOT leave the vault unlocked — call Unlock
// (or Open) afterward. Fails if the vault already exists.
func (m *Manager) Create(ctx context.Context, decode func(target any) error) error
// Access:
func (m *Manager) Get(ctx context.Context, addr string) (string, error)
func (m *Manager) Set(ctx context.Context, addr string, value string) error
func (m *Manager) List(ctx context.Context, namespace string) ([]string, error)
func (m *Manager) Capabilities() Capabilities
func (m *Manager) Lock() error
func (m *Manager) IsUnlocked() bool
Manager rules:
- MUST enforce lifecycle order:
Unlock/Get/Set/Listbefore a successfulConfigurereturnErrNotConfiguredwithout calling the backend;Get/Set/ListafterConfigurebut before a successfulUnlockreturnErrLockedwithout calling the driver. Unlockwhen the vault is already unlocked is a no-op that succeeds (returns nil) and does not re-prompt. (The CLI's--forcepath, cli.md §4, is what deliberately re-prompts and re-unlocks.)UnlockMUST: calldriver.Credentials(), pass the requests to the active prompter'sDispatch(one interaction) — theWithPrompteroverride if set, elseflexprompt.GetPrompter(), read at dispatch time — then calldriver.Unlock(ctx, answers). It sets the unlocked state only if the driver'sUnlockreturns nil.- Retry policy: on
ErrAuththe Manager re-runs Dispatch → Unlock, up toWithUnlockRetriesattempts (default 3). With a non-interactive prompter re-prompting is pointless, so the effective default is 1 attempt (the Manager SHOULD treat a prompter that returns identical answers, or reports itself non-interactive, as non-retryable). OnErrPromptCancelledit MUST stop immediately and return. - MUST serialize all driver calls (a single
sync.Mutexthis revision — simple and safe; a read/write split is a later optimization). Lockclears the unlocked state and calls the driver'sLock.- MUST validate/normalize the secret address before delegating (see §6).
- MUST NOT include secret values or credentials in errors it wraps.
Typical usage¶
// Once at startup, install the prompter for this process:
flexprompt.SetPrompter(flexprompt.NewCLIPrompter())
drv := keepass.New() // config comes via Configure
mgr := flexvault.NewManager(drv) // no prompter threaded through
// Non-secret driver config (in the full flow this comes from the loaded config).
cfg := map[string]any{"path": "secrets.kdbx"}
if err := mgr.Open(ctx, flexvault.MapDecoder(cfg)); err != nil {
// Configure -> Credentials -> Dispatch (CLI asks for the master password) -> Unlock
return err
}
defer mgr.Lock()
s, err := mgr.Get(ctx, "artifactory/token")
if err != nil { return err }
use(s)
6. Addressing: namespaces & keys¶
Secrets are addressed with a fixed two-level scheme — <namespace>/<key> —
not an arbitrary path tree:
- An address is exactly two non-empty,
/-separated segments, case-sensitive, e.g.artifactory/token,database/password. Neither segment may be empty or itself contain/. - The Manager validates the address before delegating and rejects anything that is not exactly two non-empty segments (leading/trailing whitespace is trimmed first).
Get(ctx, addr)/Set(ctx, addr, value)take a fullnamespace/key.List(ctx, namespace):namespace == ""→ returns the list of namespaces;- non-empty → returns the key names within that namespace.
Both return
[]string. Listing is a single level; there is no recursion because there is no deeper nesting. - An unknown namespace returns an empty slice and a nil error (not
ErrNotFound) — listing a missing namespace is not an error. (secret listthen prints nothing and exits 0.) Set(ctx, addr, value)is create-or-overwrite: it overwrites an existing key and, when thenamespacedoes not yet exist, auto-creates it (e.g. the KeePass group) before writing the key. A missing namespace/key is therefore never an error forSeton a writable vault.
KeePass mapping: a top-level group is a namespace, and an entry within it is a
key. Nesting beyond two levels is not addressable through flexconf (documented
convention for .kdbx files used with it).
Two .kdbx ambiguities are resolved by documented convention (KeePass allows
what the two-level address model does not):
- An entry whose title contains
/is unaddressable through flexconf (the/would be read as the namespace/key separator). Keep titles free of/. - Duplicate titles within one group are permitted by KeePass; flexconf addresses by title, so on a collision the first match wins. The documented convention is to keep entry titles unique within a group.
7. Secret value type¶
A secret is a single string value — not a structured record. Get returns a
string and Set takes a string; $(secret:namespace/key) resolves to that
string. Backends that store richer entries (a KeePass entry has username, URL,
notes, …) expose only the primary value through flexconf; secondary fields are
not addressable (see vault-registry.md §5).
- For KeePass the value is the entry's
Passwordfield. - Secret values and credential answers are
stringin v1. Go strings cannot be reliably zeroed; this is an accepted v1 trade-off for ergonomics. A future hardening pass may move secret material to[]byte— noted, not planned.
8. Errors¶
Sentinel errors, usable with errors.Is:
// Package flexvault.
var (
ErrLocked = errors.New("flexvault: vault is locked")
ErrNotFound = errors.New("flexvault: secret not found")
ErrReadOnly = errors.New("flexvault: vault is read-only")
ErrUnsupported = errors.New("flexvault: operation not supported by driver")
ErrAuth = errors.New("flexvault: unlock failed")
ErrNotConfigured = errors.New("flexvault: driver not configured")
)
-
ErrNotConfiguredis returned whenUnlock/Get/Set/Listis called before a successfulConfigure— a lifecycle-order violation distinct fromErrLocked(which means configured but not unlocked). -
Drivers SHOULD wrap these with backend detail via
fmt.Errorf("...: %w", ErrX)but MUST NOT embed secret values or credentials. ErrAuthdistinguishes bad credentials from other unlock failures (missing file, corrupt DB), which SHOULD use a distinct wrapped error.- See also the
Promptererrors in prompter.md §5.
9. Driver selection & registration sketch¶
To let the secret: resolver pick a backend by name at runtime without
compile-time coupling, drivers register a factory (in flexvault) from their
package init():
package flexvault
// Register associates a driver name with a factory.
func Register(name string, factory func() VaultDriver)
// New constructs a registered driver by name; the caller then Configures and
// Unlocks it (usually via a Manager).
func New(name string) (VaultDriver, error)
How a driver name and its config are chosen at runtime — the named vault
registry, its layering, the default vault, and the $(secret:[vault:]...)
reference syntax — is specified in vault-registry.md. The resolver-side
wiring (how the secret: scheme drives the selected Manager) is deferred to
resolvers.md. This section exists so the interface above is designed with
selection in mind.
10. KeePass driver — first implementation¶
Package: github.com/sylvanld/go-flexconf/flexvault/driver/keepass,
registered as "keepass".
// New returns an unconfigured KeePass driver. Settings arrive via Configure;
// the .kdbx file is opened only at Unlock.
func New() VaultDriver
// Credential keys this driver declares (see §4).
const CredPassword = "password"
Config (keepassConfig, §4): path (required), keyfile (optional path),
readonly (optional, defaults to false — the Go bool zero value, so an
omitted key yields a writable vault). Capabilities().Writable is !readonly.
Read-only-ness is a driver decision surfaced through Capabilities().Writable
(§4): a driver that can only ever read (e.g. a managed cloud vault exposed
read-only) reports Writable: false unconditionally; a driver that can do both,
like KeePass, exposes a readonly config key (SHOULD default to false) so the
operator chooses. There is no separate "readonly" method — Capabilities() is
the single source of truth.
Credentials: Credentials() returns a single PromptRequest{ID: CredPassword,
Secret: true}, marked Optional when a keyfile is configured and suffices on
its own. Unlock reads answers[CredPassword] (see §4 sketch).
Mapping KeePass concepts to the flexconf model:
| KeePass | flexconf |
|---|---|
| Master password | Credentials() → PromptRequest{ID: CredPassword} |
| Key file (path) | keepassConfig.KeyFile (contents read at Unlock) |
| Top-level group | namespace (first address segment) |
| Entry within a group | key (second address segment) |
Entry Password field |
the secret value (Get return / Set input) |
Entry Title/UserName/URL/Notes, custom fields |
not exposed (secrets are a single value, §7) |
Behavior:
Unlockreadsanswers[CredPassword], builds the KeePass composite key from it and/or the key file, and decrypts the.kdbx. Bad credentials MUST returnErrAuth. The plaintext password is not retained past key derivation; the composite key is retained for the session only whenWritable.Get/Listoperate on the in-memory decrypted database.Getreturns the entry'sPasswordas the value.List("")returns top-level group names;List(group)returns entry titles in that group.Setmodifies the in-memory database and persists to file; MUST returnErrReadOnlywhenreadonlyis set.- Create (
Initializer):InitCredentials()returns a singlePromptRequest{ID: CredPassword, Secret: true, Confirm: true}(new master password, entered twice);Initwrites a new empty.kdbxatpathand MUST fail if the file already exists.Capabilities().Creatableistrue. Lockdrops the decrypted database and clears the retained composite key.- Candidate library:
github.com/tobischo/gokeepasslib(confirm license & maintenance before adoption).
10a. Normative concurrency & single-writer model¶
Within one process the Manager serializes all driver calls (§5), so a driver
never sees concurrent access. Across processes, writes are serialized by
routing them through the agent: when an agent holds a VaultID, other
processes go through it (cli.md §6), so every Set for that vault
funnels through one owner.
The one unprotected case is no agent running plus two processes writing the same backend file directly. flexconf makes an explicit single-writer assumption here: concurrent direct writers to the same vault file are unsupported and MAY lose data. There is no cross-process file locking in v1. Operators who need concurrent writers should keep an agent running (the single owner) or serialize externally.
11. Caching & rotation — out of scope for v1¶
Out of scope for v1; noted so the interface doesn't preclude a later addition:
- The manager is the intended home for an optional read-through cache with TTL — not built in v1 (overview.md §8).
- Rotating/dynamic secrets may need a lazy/refresh model rather than eager
Get. v1 resolves eagerly and has no lazy mode (overview.md §8, resolvers.md §9); this is settled, not an open question. - Agent cache coherence — an unlocked agent serves reads from its in-memory
snapshot, so a value changed out-of-band (external rotation, a direct file edit,
a
setnot routed through this agent) can be stale; v1 offers no cross-process invalidation. This is an accepted v1 limitation (detailed in resolvers.md §9), addressable only if the cache/TTL work above is ever done post-v1.
12. Resolved decisions & deferred items¶
Resolved (baked into this spec):
- Configure input — the
decodecallback (§4). ShipsMapDecoderandEnvDecoder; a loader-bound sub-tree decoder comes with the config-loading spec. - Credential collection — declare (
Credentials) / consume (Unlock) with a Manager-owned singleDispatch;IDconstants as the join key (§2–§4). - Prompter delivery —
flexpromptpackage with a process-wide singleton (SetPrompter/GetPrompter), default error prompter when unset, per-ManagerWithPrompteroverride (prompter.md, §5 here). - Write support —
Seton the interface,ErrReadOnly+Capabilities()discovery (§4). - Vault creation — optional
Initializerinterface +Capabilities().Creatable Manager.Create; drivesflexcli secret init(§4, cli.md).- Addressing — fixed two-level
namespace/key;Listreturns[]string(§6). - Secret material type —
stringin v1 (§7). - Type naming — the user-facing type is
Manager(§1).Vaultwas considered as an alternative (vault.Get(...)reads well) but not adopted; non-normative and cheap to revisit. The backend interface isVaultDriverregardless. - Unlock retry — Manager-configurable, default 3 for interactive prompters, 1 (no retry) for non-interactive (§5).
- Lifecycle & edge behavior — pre-
Configurecalls returnErrNotConfigured(§5, §8);Capabilities()beforeConfigureis the zero value (§4); re-Unlockwhen already unlocked is a no-op success (§5);Listof an unknown namespace is an empty slice + nil error,Setis create-or-overwrite (auto-creating the namespace) (§6);Lockis synchronous best-effort and clears material even if the flush fails (§4). - Single-writer model — cross-process writes serialize through the agent; two direct writers to one vault file are an unsupported single-writer assumption, no file locking in v1 (§10a).
Deferred (out of scope, not open design questions):
- Multiple vaults — resolved in vault-registry.md: vaults are named
in a registry and a token selects one via a
vault:segment ($(secret:vault:namespace/key)), defaulting to the registry's default vault. The resolver-side wiring is resolved in resolvers.md §5. - Caching & rotation — §11.
- Resolver integration — resolved in resolvers.md: the
secret:resolver builds oneManagerper referenced vault, reaching each through the background agent (an internal proxy driver), spawning+unlocking one like the CLI when none is running (§5.1–§5.2 there). - Credential zeroing — possible future move of secret material to
[]byte(§7); ergonomicstringchosen for v1.