Backends and capabilities¶
A backend is a source of configuration. The Store owns everything about coordination — merging, provenance, snapshot construction, notification, serialising access — and a backend owns only the knowledge of how to read, and where relevant write and watch, its own sources.
That division is the whole design. If coordination ever becomes per-backend, the fragility a single owner removes returns once per backend.
What a backend can do is answered by the type system¶
There is no flag saying whether a backend can be written to or watched. Those are answered by whether it implements the optional interfaces:
type Backend interface { // every backend
ID() string
Load(ctx context.Context, below []Layer) ([]Layer, error)
Capabilities() Capabilities
}
type WritableBackend interface { // optional
Backend
Prepare(ctx context.Context, edits []Edit) (Pending, error)
}
type WatchableBackend interface { // optional
Backend
Watch(ctx context.Context, interval time.Duration, onChange func()) (func(), error)
}
The reason is not tidiness. A flag can disagree with the code: a backend could
report itself writable and have no way to persist, or report itself unwritable
and implement Prepare anyway. Neither state means anything, and a caller
checking the flag and a caller checking the method would then disagree about the
same backend. Asking the type system settles it once, at compile time, and a
backend cannot claim one thing and do another.
This module learned that the hard way. An earlier version carried a Writable
flag as well as the interface, and writability ended up being decided three
different ways — so a backend could be excluded as a write target while its file
was missing and routed the moment it existed. The same source was writable or
not depending on whether it happened to exist yet.
A file's format is a codec, by the same reasoning¶
Everything a file backend does — reading bytes, translating a missing file,
fingerprinting content for conflict detection, staging to a temporary path,
committing by atomic rename, preserving mode, resolving symlinks, rolling back,
watching — is identical whatever format the file holds. Only two things depend
on the format: turning bytes into values, and editing bytes in place. Those two
are a Codec:
type Codec interface { // every file format
Decode(path string, src []byte) ([]map[string]any, error)
}
type EditingCodec interface { // optional
Codec
Check(path string, src []byte) error
Apply(path string, src []byte, edits []Edit) ([]byte, error)
Empty() []byte
}
This is the same capability split as Backend/WritableBackend, one level down.
A format that cannot be round-tripped safely implements only Decode, and
NewCodecBackend returns a backend that is not a write target — routing skips
it and a write lands in the next writable layer down, reported as shadowed rather
than failing. A format that can be edited implements EditingCodec, and the
backend it produces is writable. The type system decides, not a flag, exactly as
above.
YAML is the one codec the core ships, because it is the default and its
comment-preserving editor is the module's headline feature. Every other file
format is a sibling config-<format> module supplying its own codec, so a
consumer who needs JSON does not acquire a TOML parser in their dependency graph.
Writing a codec is how a new file format is added without touching this module:
the file machinery — including the conflict-detection subtlety that is easy to
get wrong, where the fingerprint must be taken at load rather than at write — is
written once and shared, rather than reimplemented and mis-implemented per format.
→ Write a custom backend · the
non-YAML format adapters spec
What Capabilities is for, and what it is not¶
Capabilities does not describe what a backend can do. It describes
properties of a backend that the rest of the system has to reason about when
several backends of different kinds are in play:
| Field | What it declares |
|---|---|
PreservesComments |
Whether an edit retains comments and formatting. True for document-like sources; meaningless for a key-value store, which has nowhere to put a comment. |
AtomicMultiKey |
Whether several keys can be written as one indivisible operation. |
NativeWatch |
Whether the backend can report foreign changes itself, or has to be polled. |
Sensitive |
Whether it holds secret material. |
Sensitive is enforced; the rest are declared ahead of the backend that will
read them, because each carries a consequence that is much cheaper to record now
than to discover later:
- A value from a
Sensitivebackend must never be written into a layer that is not. This is a security property. Configuration written back from a secret store into a plain file is how credentials end up in version control. The write path enforces it: because secrets backends are read-only, a write to a key one owns routes down to the next writable layer — a plain file — so the core refuses that write withErrSensitiveLeakrather than let the secret land there. - The comment guarantee is document-backend-only. Everything this module says about preserving comments applies to YAML files. A key-value backend cannot honour it, so the promise has to be scoped rather than implied.
- Cross-backend atomicity is impossible. A change spanning a file and a remote parameter store cannot be atomic. That has to be either refused or declared plainly, never papered over.
- Foreign-change latency differs per backend and has to be stated, so an application knows whether it will hear about a change in milliseconds or at the next poll.
If those seem like a lot of unread fields, that is the point: they are the questions a second backend will force, written down while the answers are obvious rather than when something is already broken.
Load receives what is beneath it¶
Most backends ignore below. One kind cannot: a backend whose reading of its
own input depends on what is already defined.
The environment backend is that case. APP_SERVER_PORT could mean server.port
or server_port, and nothing in the variable name says which — so it resolves
the name against the keys the lower-precedence layers already define.
That dependency used to be satisfied by a separate call made before Load,
which the Store had to remember to make. It was forgotten once, and the
consequence was subtle: a write could pass validation, land on disk, and then
break the reload it triggered, leaving the file changed and the process running
on last-known-good.
Passing it as an argument removes the possibility. There is no separate step to forget, because there is no separate step.
Related¶
- The Store — why one component owns configuration I/O
- Provenance — how a layer's identity is recorded and reported
- Precedence and the merge model