Store architecture¶
Revisions¶
R1 — 2026-07-19: viper is removed outright, superseding D6¶
D6 said viper is retained as a resolution engine behind the snapshot boundary, and the
"Replacing viper" alternative was rejected at an estimated 8,000–12,000 LOC. Both are now
wrong. Viper is gone: absent from go.mod, go.sum and every source file.
This was not decided; it fell out of implementing D6, and was noticed only after the fact during a dependency review. Recording it properly rather than editing D6 silently, because the reasoning for the original estimate is worth keeping — it was sound against what it measured, and it measured the wrong thing.
Why the estimate was ten times too high. It priced replacing viper whole: config search
paths, merge, precedence, provenance, watching, writing, multi-document handling. But D6 made
moving every one of those into the Store a prerequisite. Once that landed, viper's residual
job was two things — cast a resolved value to a type, and decode a map into a struct — and its
implementations of those are spf13/cast and go-viper/mapstructure, which this module
already imported directly. The estimate was made before D6 and never revisited after it.
Measured residue: 781 LOC across view.go (392), env.go (175), unmarshal.go (121) and
flag.go (93), against a 4,084 LOC module.
What this changes:
- D6's mechanism stands unchanged — the Store hands the read surface pre-merged data plus
provenance, and nothing below that boundary sees a file. Only the identity of what sits
behind the boundary changed, from viper to
View. - D6's closing note — "replacing it later is a swap … not a rewrite" — was correct, and the swap has happened. It cost less than the note implied because D6 itself was the expensive part.
- Every defect D6 claims to resolve structurally is still resolved, by the same argument.
- The breaking changes in D6 (
GetViper(),NewContainerFromViper,WriteConfigAs) are unaffected: all three were already removed.
Process note. The approved spec said viper was retained while the implementation removed it, for four commits. An implementation MUST NOT diverge from an approved decision without the spec being amended first; this revision is the correction, not a precedent.
R2 — 2026-07-19: writing from inside an observer is refused¶
D8's claim that a cascade is "unrepresentable" is true only of the watcher loop. It was read
as covering observer-initiated writes, which it never did. Those are now refused with
ErrWriteFromObserver.
What was actually unrepresentable. A write does not come back round through the watcher — the Store builds the next snapshot from what it just wrote, so there is no filesystem round trip to re-trigger it. That still holds and is unchanged.
What was not. Apply → notify → observer → Apply → notify is a loop entirely inside the
Store, and nothing prevented it. Measured depth 21 in a probe, bounded only because the probe
bailed out.
Two defects were masking it, and both are fixed independently of this decision:
- The file fingerprint never advanced past load.
fileBackend.loadedwas set only when reading, and the Store deliberately never re-reads after committing, so the second write to any file compared against load-time content and was reported as a conflict with the first. The cascade test's nested write failed withErrConflictbefore it could publish, so the test saw a bounded loop and passed. Two consecutive writes to one file could never succeed — a defect well beyond the cascade it was hiding. Applynotified unconditionally;Reloaddid not. Reload has always hadif !changed { return nil }. Apply had no equivalent, so a write resolving to the configuration already published was still announced as a change. An idempotent observer therefore looped forever: its second write was a no-op, notified anyway, and asked for the same no-op again. The two entry points disagreed about what counts as a change.
Why refusal rather than accommodation. With (2) fixed, a convergent observer settles by itself. A non-convergent one — every reaction producing a genuinely new value — cannot converge under any design; the only choice is what it gets. Suppressing nested notification would leave other observers indefinitely stale; coalescing needs an iteration cap that is itself arbitrary; a depth ceiling turns a crash into an error without making the program correct. None is worth the complexity for a program that is already broken. Writing while reacting to a write is a breakdown of the ownership model and is refused.
The sanctioned pattern. An observer that must change configuration records what is needed, returns, and writes from outside the observation — a worker goroutine, a queue, the next tick. Serialising and de-duplicating those deferred writes is the consumer's responsibility, being the only party that knows which still matter.
Why the check is goroutine-scoped. Notification runs outside the Store lock, by design
(holding it across an observer deadlocks the Store). An unrelated goroutine may therefore
write while observers execute, and that write is legitimate. A flag or mutex cannot separate it
from the observer's own re-entrant call, and refusing it would be a worse defect than the
cascade. goroutine.go documents why goroutine identity is required and why a marked context
threaded through Observable.Run was rejected — an observer passing context.Background()
would defeat it silently, turning a guaranteed refusal into a cooperative one.
Breaking: Store.Apply and Store.Reload return ErrWriteFromObserver when called from
within an observer callback.
R3 — 2026-07-19: invisible and bidirectional characters are escaped by design¶
AC3 said astral-plane emoji "survive a write unmangled", which read as a promise that every character comes back byte-for-byte. It does not, and should not. The criterion is amended above to state the actual rule.
Measured behaviour. Every character a reader can see survives verbatim — astral-plane emoji, CJK, accented Latin, Greek, Cyrillic. Every invisible or bidirectional-control character is emitted as an escape:
| Escaped | Preserved verbatim |
|---|---|
U+202E RLO, U+202D LRO, U+2067 RLI, U+2069 PDI |
astral-plane emoji (U+1F30D) |
U+200E LRM, U+200F RLM, U+061C ALM |
CJK ideographs |
U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ |
accented Latin, Greek, Cyrillic |
U+2060 word joiner, U+FEFF ZWNBSP, U+00AD soft hyphen |
This is a security property, not a formatting artefact. The bidi controls are the Trojan
Source construct (CVE-2021-42574): they make a document render one way and parse another,
so a reviewer approves what they see while the parser reads something else. Configuration is
exactly where that matters — these files gate deployments and carry credentials. The
invisible-space family is the quieter version: admin and ad<ZWSP>min are indistinguishable
on screen and are different strings. Escaping puts them in front of whoever reads the file,
which is what the Go compiler and the forge renderers do.
Escaping is lossless. An escape in a double-quoted scalar denotes exactly the character it replaces, so a round trip returns the original string byte for byte. Asserted rather than assumed, by decoding the emitted document and comparing.
Where it is enforced. The behaviour comes from the emitter beneath yamldoc, not from
either module, so a substrate change could withdraw it silently. It is pinned by a test in
yamldoc covering thirteen dangerous characters and five visible ones. Nothing else in the
toolkit would notice the regression.
How this was misread. A family emoji is several astral-plane characters joined by U+200D.
The emoji survive; the joiners between them are escaped. Read against AC3's original wording
that looked like a fidelity failure, and was initially recorded as one. It is the feature
working.
Also corrected in the same pass: a map-valued Put over a subtree whose first key differed
in length from the replacement was misaligned — silently promoting content to the top level
when the replaced mapping led with a merge key, and panicking outright when the replacement key
was longer. Fixed upstream in yamldoc; AC3's merge-key clause holds again once that release
lands.
R4 — 2026-07-19: four criteria reconciled with the implementation¶
Four acceptance criteria described an implementation that does not exist. Each was resolved against measured downstream usage rather than by preference, and the evidence is recorded because in three of the four the criterion was wrong rather than the code.
AC17 — ephemeral overrides are replaced by Store.AddLayer. SourceOverride was a
declared constant with no API behind it: nothing could create one. Rather than build an
invisible layer that every read path, Unmarshal, Keys and validation would have to consult,
the capability is provided as what it always was — another layer. AddLayer(ctx, name,
io.Reader) contributes an in-memory source at the highest precedence, taking part in
precedence, provenance, merging and shadowing like any other, and needing nothing remembered
about it.
Being in-memory it is not writable, so routing already skips it and a later write lands in the file beneath and is reported as shadowed. That property is inherited from the existing source model rather than special-cased, which is the argument for this shape over a bespoke override.
Measured before deciding: every downstream Set is the first half of stage-then-persist —
setTelemetryEnabled does Set then WriteConfig, writeBitbucketCredentials is named for
it. Not one use is an override. That idiom is now Apply, in one step and without the
WriteConfigAs env-secret leak. Compiled-in defaults remain WithReaders at construction,
where they sit at the bottom of the order rather than the top.
AC18 — withdrawn. A derived view is a read scope, not an observable. *View has no
registration method, so the criterion was not merely unimplemented but inexpressible.
Measured: Sub is used downstream purely to scope reads (vcs/config_adapter.go:28,
vcs/release/registry.go:24), with zero observer registrations on a Sub in either
consumer. The old Containable did expose AddObserverFunc, so this was possible and never
fired — advertised, broken, unused. Scoped change notification remains available through
ObserveSection, which is what all four downstream adapters actually use.
Migration note: Sub returns *View, which satisfies Reader but not Binder. An
adapter chain taking a scoped container must be typed Reader.
AC7 — provenance answers the combined question, not the split one. D7 imagined the Store
answering "which file" and the container adding "and it is shadowed by". Env and flags are
ordinary layers, so Origin returns whichever won — env included — and the tests assert
exactly that. The combined answer is strictly more information than the split one, and this
spec's own non-goals section already describes Explain as the intended diagnostic. A
FileOrigin accessor was rejected: its difference from Origin is subtle, which is how
callers end up using the wrong one.
AC24 — the D10 break is accepted and documented. ObserveSection takes Binder and
WithSectionDefaultFunc takes Observed, so "no downstream adapter changes" is false. The
scale is smaller than the raw count suggests. Measured across go-tool-base: 160
config.Containable references, of which 89 are bare parameter declarations that become
config.Reader by rename; only 12 call methods Reader lacks (GetViper 6,
WriteConfigAs 4, BindPFlag 1, ToJSON 1); and ObserveSection has 4 call sites, with
*Store already satisfying Binder.
So D10's guarantee is amended to what it can honestly promise: the typed-section semantics
are frozen — ObservedSection[T], change-only delivery, monotonic Version, the five
WithSection* options — and the parameter types are not. The port is a rename plus twelve
genuine sites, which belongs in the migration document D19 requires.
R5 — 2026-07-19: the Watchable seam D11 names is built¶
D11 declared Watchable alongside Writable. Writable was implemented as
WritableBackend; Watchable was not, so watching type-asserted the unexported
*fileBackend and read its unexported fs and path fields.
The consequence was not cosmetic. WithBackend exists so a consumer can supply its own
source, and such a backend could never be watched — Store.Watch reported
ErrWatchUnavailable with a real file on disk, or, in a set that also held a file backend,
silently omitted it while reporting success. That is the failure Store.Watch's own doc
comment says the design prevents.
This is not what D12 defers. D12 defers building additional backends and explicitly asks
that the seam be defined now, so the coordination logic never becomes per-backend. It already
had: the Store knew that a source is a path on an afero.Fs, which is a backend's fact about
itself.
WatchableBackend is now declared next to WritableBackend, fileBackend implements it, and
Store.Watch collects watchable backends by interface assertion. The Store knows only that
some backends can say when something moved, and decides for itself whether the resolved
configuration actually changed.
Also fixed by the same change: watching previously took the first file backend's filesystem and used it for every path, so a store mixing a real file with an in-memory one statted half its sources against the wrong filesystem and never noticed a change to them. Each backend now watches its own source, so the question cannot arise.
R6 — 2026-07-19: Capabilities carries no writability flag¶
D11 expresses writability through an optional interface and its Capabilities struct has no
such field. The implementation added Capabilities.Writable, which created a second gate that
disagreed with the first.
Writability was decided three ways: by Layer.Source.Writable for a backend that had
contributed layers, by the WritableBackend assertion and Capabilities().Writable for one
that had not, and by the assertion alone in prepare. A backend implementing WritableBackend
while reporting Writable: false was therefore excluded as a write target while its file was
missing and routed the moment it existed — the same source writable or not depending on whether
it happened to exist yet.
The flag is removed. Writability is the WritableBackend assertion, as D11 designs it and as
prepare already required, so the type system checks it once rather than every caller checking
a flag at runtime — and a backend can no longer claim one thing and do another. R5 puts
watchability on the same footing.
The remaining four fields stay, and are not dead weight. D12 records a specific consequence
for each, deliberately, so it is not discovered when a second backend lands: a value from a
Sensitive source must never be written into a layer that is not, the comment guarantee is
document-backend-only and must be scoped rather than implied, cross-backend atomicity is
impossible and must be refused or declared, and foreign-change latency differs per backend and
must be stated. Deleting them would delete that record.
CompareAndSwap, which D11 lists, is not implemented and is not restored. The
prepare/verify/commit protocol already expresses compare-and-swap: Pending.Verify is the
comparison and Commit is the swap. Declaring a field for it would describe the same property
twice, in two places that could disagree.
R7 — 2026-07-19: a backend's key-space dependency moves into Load's signature¶
keyAware was an unexported interface carrying an undocumented temporal contract:
observeKnownKeys had to be called before Load, and only the Store methods that remembered
to do so enforced it.
Being unexported, no backend outside this package could participate — so it was the environment
backend special-cased, wearing an interface's clothes. The contract was invisible in Backend,
and it was forgotten once on this branch: rebuild skipped the step, and the result was a write
that validated, landed, and then broke the reload it triggered, leaving the file changed and the
process on last-known-good.
Load now takes the layers beneath it:
Most backends ignore the argument. The dependency is real, so it belongs in the signature rather than in a side channel: no caller can skip a step that does not exist separately, and the environment backend stops holding Store-derived mutable state between two calls it does not control.
Breaking for anyone implementing Backend, which is this package and the generated mocks.
Written up for users in explanation/backends.md, along with the reasoning behind R5 and R6 —
so the next person to wonder why writability is an interface and Capabilities is a set of
unread fields finds the answer in the documentation rather than rediscovering it.
R8 — 2026-07-19: SectionChange.Initial becomes meaningful, on the caller's terms¶
Initial was always false and Changed always true. The section a binding starts with is
stored before the observer registers, so a delivery to an apply callback was never the first
one — two fields that looked like data and were constants.
The obvious repair, firing apply once at bind so Initial occurs, is wrong for a reason worth
recording. An apply callback is a consumer's reaction to configuration, and it routinely closes
over something constructed after the binding — a server, a pool, a template cache. Firing it
inside ObserveSection would run it against a half-built world, which is a nil dereference at
startup in a callback whose author reasonably assumed it only ran on change.
Measured before deciding: go-tool-base has no production WithSectionApply call sites;
the option appears in four test files, each asserting an exact delivery count. So the blast
radius was small — but "nothing uses it yet" is a reason to get the shape right, not a licence
to break it later.
ObservedSection.ApplyInitial() gives the caller the trigger:
settings, err := config.ObserveSection[T](cfg, "server", config.WithSectionApply(reconfigure))
// ... construct whatever reconfigure closes over ...
if err := settings.ApplyInitial(); err != nil { return err }
Both fields are now honest: Initial: true, Changed: false for that delivery, the reverse for
every later one. A consumer that never calls it gets change-only delivery, exactly as before, so
the addition changes nothing that exists. Calling it twice is a no-op, and calling it after a
change has landed is also a no-op — delivering the section the binding started with, once a
later one has been applied, would hand the caller stale configuration and call it initial.
Additive, so D10's freeze holds: no existing signature or behaviour moves.
R9 — 2026-07-19: one owner for struct-tag precedence¶
Two functions answered "what config key does this struct field come from", and disagreed.
configFieldName took the first non-empty tag in order mapstructure → json → yaml and returned
one name; fieldKeys treats mapstructure as canonical with yaml then json as aliases. They sat
either side of the same call in UnmarshalSection: the first decided whether the section
existed, the second decided what it decoded to.
Why there were two, since it is instructive. configFieldName is inherited — it arrived
with the extraction from go-tool-base and is byte-identical to the pre-rewrite container's.
fieldKeys was written for this rewrite, when honouring all three tag families became a
requirement, and it needs a different model: mapstructure can only match one name, so an
alternate spelling has to be resolved into the canonical key before decoding. The probe only
ever needed a name to test with, so first-non-empty was adequate on its own terms.
The rewrite replaced the decode path and inherited the probe. Three things kept it hidden: they
are in different files, the probe only decides anything when SectionExists is false, and no
test covered a field carrying both a json and a yaml tag under a prefix-less binding.
This is the same shape as R6 — an inherited mechanism left in place beside its replacement, quietly answering the same question differently.
The probe now asks fieldKeys and tests the canonical name plus every alias, so a field written
under any spelling the decoder accepts is found by the check that decides whether to decode at
all.
Also fixed, found while reproducing it: UnmarshalKey("") resolved an empty path, found
nothing and returned without decoding — so a prefix-less binding reported its section present
and handed back an untouched target, while Unmarshal on the same view decoded correctly. An
empty key now means the scope the view describes, which is what such a binding plainly intends.
R10 — 2026-07-20: D8's foreign-change coalescing is implemented¶
D8 required two things and only one was built. It says notification is "exactly once per
logical change… Own writes achieve this by construction (D4); foreign changes coalesce."
The first half was implemented and is what makes Apply exactly-once regardless of how many
files it touches. The second half was not: a foreign change spanning two files produced two
reloads and two notifications, and the first carried a combination nobody intended — the first
file updated, the second not yet.
Measured before being fixed: two files written 5ms apart gave two notifications with the first showing the half-applied state, and only writes landing inside the same reload window (0ms apart, by luck) gave one. The behaviour was documented as a caveat before it was recognised as a divergence from an approved decision, which is the same failure mode as R1 — documenting what the code does rather than checking it against what was agreed.
Why the removed debounce was right to remove, and why this is not it. D8 rejected the
pre-Store 250ms debounce because it was "nondeterministic for multi-file saves": a Save()
writing two layers could fire observers once or twice depending on whether the bursts straddled
the window. That criticism is correct and stands. The answer was to make the Store's own writes
exactly-once by construction — the component that performs a write knows its extent, and needs
no timing heuristic to recognise it.
That reasoning applies only to writes this Store performs. For a foreign change there is no construction to appeal to: the filesystem reports that two files changed and not that they changed together, and no amount of architecture recovers information the operating system never supplied. Timing is genuinely the only signal available, so it is used here and nowhere else.
What is implemented. A settle window on the watcher-triggered path only. Apply never
reaches it and remains exactly-once by construction. WithSettleInterval configures it;
DefaultSettleInterval is 250ms, inherited from the pre-Store default where it was chosen to
tolerate slow and networked filesystems. An injected watcher (WithWatcher) defaults to no
window, because such a caller is supplying precisely the information the window compensates for
— overriding that would make WithWatcher non-deterministic, which is the only reason to use it.
Settling is bounded at four times the window from the first change in a burst. A pure wait-for-quiet window never fires while changes keep arriving faster than it, which would leave observers told nothing at all — the silent-failure mode refused everywhere else in this design.
What this does not do, stated because the honest limit matters more than the mitigation. It reduces the problem; it does not remove it. Writes spaced further apart than the window are indistinguishable from two separate changes, because that is what they look like. A logical change spanning several files is atomic only if whoever makes it makes it atomically. The guarantee belongs to the writer — by writing atomically, or by keeping settings that change together in one file, which is always read atomically. Both the residual case and the coalescing are asserted in tests so neither is folklore.
Why this exists¶
The module needs to write configuration back to the files it was loaded from — preserving comments, targeting the originating layer, never flattening the merged view. A first design kept the existing container and added a writer beside it. That design worked on paper and was fragile by construction, which is not acceptable for a component every tool in the toolkit depends on.
This spec replaces it.
The diagnosis¶
Every mitigation the previous design accumulated — a commit lease, fingerprint checks, two-phase commit, feedback-loop breaking, provenance-index invalidation, debounce tuning — existed for one reason:
The filesystem was shared mutable state with no single owner. The container read files, the writer wrote them, the watcher observed them. Three components, one resource, so every interaction needed a protocol — and every protocol is a place to get it wrong.
Moving responsibilities between those three components does not help; all three still touch the files. The fix is to remove the sharing.
The principle¶
One component owns config I/O. Everything else is a view over what it produces.
Terminology¶
| Term | Meaning |
|---|---|
| read / get | retrieve a value from a container. Never mutates, never persists. |
| edit / set | set a value in the container's ephemeral override layer. Touches no persistence. |
| write | persist a change through the Store. |
| Store | sole owner of config I/O: load, parse, provenance, write, watch. Serialises all access. |
| Backend | a concrete source of config data behind the Store (local FS today; Vault/etcd/SSM later). |
| Layer | one contributing source of configuration, in precedence order. |
| Snapshot | an immutable, fully-resolved set of layers plus provenance, emitted by the Store. |
| Container | a view over one Snapshot. Provides typed reads, Sub, unmarshalling, env/flag resolution. Never touches I/O. |
| provenance | which layer supplied a key's effective value. |
| shadowed | a value present in a layer but outranked by a higher-precedence layer, or by env/flags. |
edit and write remain distinct operations on distinct components. Container.Set is an
ephemeral override and is never persisted; persistence happens only through the Store.
Architecture¶
┌──────────────────────────────────────────────────────────┐
│ Store — sole owner of config I/O │
│ │
│ Backend(s) parse + provenance watch │
│ ───────── ────────────────── ───── │
│ local FS yamldoc module fsnotify | poll│
│ (afero) (documents only, D5) FOREIGN changes│
│ only (D8) │
│ │
│ Apply(ChangeSet) ──► validate ──► write ──► NEW │
│ SNAPSHOT │
│ ALL access serialised internally (D3) │
└───────────────────────────┬──────────────────────────────┘
│ immutable Snapshot
▼
┌──────────────────────────────────────────────────────────┐
│ Container — a view over one Snapshot │
│ typed reads · Sub · Unmarshal · env · flags · override │
│ viper as resolution engine only (D6) │
│ NEVER touches the filesystem │
└───────────────────────────┬──────────────────────────────┘
│ notify (exactly once, D9)
▼
observers · ObservedSection[T] (D10 — FROZEN)
Decisions¶
D1 — The Store is the sole owner of config I/O¶
No other component reads, writes, or watches config sources. The Container performs no I/O.
This is the decision every other property depends on. It is what removes the need for a cross-component commit lease, makes the provenance index consistent by construction, and makes the write→notify path deterministic.
D2 — Snapshots are immutable; the Container is a view¶
The Store emits a Snapshot: parsed layers, merged data, and provenance, frozen at emission. A Container holds one snapshot and swaps its pointer atomically when a new one arrives.
Consequence: a caller holding a snapshot sees a coherent configuration for that snapshot's whole lifetime. A sequence of related reads cannot straddle a reload and observe a mixture of old and new state. Under the previous design this was a latent flake nobody had noticed; here it is structural.
D3 — All Store access is serialised internally¶
One owner, one internal lock, no protocol. Concurrent Apply calls serialise; a reader never
observes a partially-applied change set.
The previous design's commit lease was a cross-component mechanism attempting this from the outside, and it had a hole: two writers could both pass their fingerprint check before either committed, and the second silently overwrote the first. Internal serialisation makes that unrepresentable.
Scope: in-process. Cross-process races remain, and D14's fingerprint check is the defence. Portable cross-process locking needs lock files and is out of scope — state the limit, do not imply stronger protection than exists.
D4 — Own writes construct the next Snapshot directly. The watcher is for foreign changes only.¶
Previously a write propagated as: write file → fsnotify → debounce → re-read every file → rebuild → swap → notify. A round trip through the filesystem, gated on a timing heuristic, to learn something already known.
Store.Apply produces the new snapshot directly from the content it just wrote, then
notifies. The watcher's sole job becomes detecting changes the Store did not make.
This single change eliminates, structurally rather than by mitigation:
- notification nondeterminism — own writes notify exactly once, by construction, with no coalescing heuristic
- the write→reload→react feedback loop — own writes never re-enter through the watcher, so the cascade is unrepresentable rather than detected and broken
- debounce sensitivity for own saves
- redundant re-parsing of unchanged files
D5 — The document layer is the yamldoc module¶
File backends read and write documents through
gitlab.com/phpboyscout/go/yamldoc — a standalone library for comment-preserving YAML
editing, extracted from this design and published separately.
Amended 2026-07-19. This decision originally specified an internal package built on
goccy/go-yaml. The work was extracted to its own module before implementation, on the grounds that comment-preserving YAML editing is independently useful and a config library is a poor home for it: nobody wanting a YAML editor should have to depend on a precedence engine. The mechanism now lives there; the policy stays here.
What yamldoc provides (its spec is the authority; summarised here so this document
remains readable on its own):
- parse bytes into ordered documents, locate nodes by dotted path, set, remove, re-emit
- comments preserved and attached to the same keys; key order, quoting, block scalars, anchors, aliases and merge keys preserved
- never emits bytes that do not parse — every emit re-parses its own output
- detects constructs it cannot round-trip safely and reports them
- no file I/O, no merging, no precedence, and no meaning assigned to document order
What stays here. Everything yamldoc deliberately refuses to decide: which layer a write
targets (D13), what document order means (D6a), how empty containers affect routing and
enumeration (D6b), validation (D15), and whether an unsafe document is refused (below).
Guarantee inherited from yamldoc: the data structure is preserved and comments survive
attached to the correct keys. Not guaranteed: blank lines, indentation, comment
alignment, the --- marker on a single-document file, byte-identity.
Comment style is the consumer's choice; the guarantee is retention, not style. Any input style is accepted, and normalising on write — including flow → block — is acceptable provided no comment is lost.
Unsafe documents are refused at load — and refusal is this module's decision, not
yamldoc's. yamldoc reports constructs it cannot round-trip (currently: a multi-line flow
collection with interior comments, which corrupts rather than degrades — the closing delimiter
is swallowed into the comment). This module turns that report into a load-time refusal
naming the file and location, because discovering it at commit means the user has already made
their edits and the failure looks arbitrary.
f, err := yamldoc.Parse(src)
if u := f.Unsupported(); len(u) > 0 {
return fmt.Errorf("%s cannot be safely edited: %s", path, u[0])
}
Hard invariant: yamldoc is never a value source.
yamldoc and the resolution engine decode identical input to different Go types (8080 → uint64 vs int; large integers → string
vs an irrecoverable float64). validate.go's isInt rejects uint64, so yamldoc-decoded
values reaching the read path would fail every schema integer. The boundary is
documents vs values, and it must not be crossed in either direction. Enforce with a lint
rule and a test.
D6 — Viper is reduced to a resolution engine behind the snapshot boundary¶
Superseded in part by R1. The snapshot boundary and everything it structurally resolves stand. Viper no longer sits behind it — the boundary is now
Viewover aSnapshot, and viper is not a dependency. Read "viper" below as "the resolution engine".
The Store hands the Container pre-merged data plus provenance. Viper never sees a file again: it resolves typed values, env bindings, flag precedence and unmarshalling from data supplied via a reader.
This removes three defects structurally:
WriteConfigAshas no file to write, so the env-secret leak cannot exist- provenance lives in the Store, natural rather than reconstructed
- flag bindings surviving reload becomes the Container's own concern — it rebuilds from the new snapshot and re-applies its own bindings, instead of a reload silently discarding them
It also contains a future decision: with viper behind a snapshot boundary, replacing it later is a swap of one "resolve typed values from merged data + env + flags" implementation, not a rewrite.
D6a — A document is a layer. Multi-document files are fully supported.¶
Viper silently reads the first document of a multi-document file and discards the rest, with no error. Measured:
input : alpha: 1, shared: from-doc-one --- beta: 2, shared: from-doc-two
viper : map[alpha:1 shared:from-doc-one] beta = <nil>
A user with a two-document config file has half of it silently ignored. That is a pre-existing defect, and because D6 puts the Store between the files and viper, this design can simply fix it — viper never sees the file, so its limitation does not apply.
The model: a document is a layer. Not "files, some of which have several documents" —
an ordered sequence of layers, each identified by (source, documentIndex). A single-document
file contributes one layer; an N-document file contributes N.
Everything else then works unchanged:
- Merge order flattens across files and documents alike; documents overlay in the sequence the file defines, exactly as files overlay in load order. One rule, no special case.
- Routing (D13): reverse merge order, first writable match — naturally selects the right document. No extra dimension.
- Provenance (D7): gains a document index for free —
config.yaml#1:14. - Writability: a document is writable on the same terms as any other layer.
Semantics are the module's choice, and must be documented as such. YAML defines a multi-document stream as a sequence of independent documents, not an overlay chain — k8s treats them as separate resources. Later-document-wins is an interpretation this module adopts because it is the only one consistent with how it already treats files. State it plainly; do not imply it is YAML semantics.
Access is yamldoc's; meaning is ours. yamldoc exposes every document and lets any of
them be edited, verified against a 3-document fixture: editing one leaves the others
byte-untouched with all separators and comments intact. It deliberately assigns no meaning
to their order — later-document-wins is this module's interpretation, layered on top.
Not supported: creating or removing whole documents. Writes target existing documents.
D6b — An empty container is a value, not an absence¶
Principle: emptiness never implies removal. key: {} and key: [] are present values,
distinct from key being absent. Code may legitimately require a parent to exist while it
holds no entries.
Consequences across the write surface:
- Deleting the last key of a mapping yields
key: {}— it does not remove the parent (D17). - Removing the last element of a sequence yields
key: []. - A map-valued
Putwith an empty map (D16) sets the subtree to{}. It is not a delete;Removeis the only way to remove a key. - Empty containers round-trip through a write unchanged, and carry provenance like any other value.
Viper is inconsistent here, in two opposite directions. Measured:
| Input | IsSet |
Get |
in AllKeys() |
in AllSettings() |
|---|---|---|---|---|
emptymap: {} |
true | map[] |
NO | NO |
emptylist: [] |
true | [] |
yes | yes |
emptymapblock: (no value) |
false | nil | yes | NO |
nullval: null |
false | nil | yes | NO |
emptystr: "" |
true | "" |
yes | yes |
An empty map is visible to the getters but invisible to enumeration and serialisation; a null-valued key is the reverse. Empty lists behave consistently, so the defect is specific to empty maps.
This is why the previous write path was unsafe for empty containers: WriteConfigAs
serialised AllSettings(), so emptymap: {} was silently deleted on every write — the
flattening defect family again.
The Store's position:
- The document model is authoritative. The Store parses and writes documents directly, so an empty container survives a write regardless of viper's view of it. The write-side defect is fixed by D1/D6.
- The Store's key enumeration is authoritative for structure, and includes empty
containers. Anything needing a complete key list — notably
detectUnknownKeysin validation — MUST use it rather than viper'sAllKeys(), which omits empty maps and includes null-valued keys the getters deny. - Getter behaviour is unchanged, so no consumer adapts:
IsSet("emptymap")remains true,Getstill returns an empty map.
D7 — The Store owns file-layer provenance; the Container owns env/flag shadowing¶
The Store answers "which file supplies db.host, at what line". The Container answers
"…and it is currently shadowed by env:KEYRX_DB_HOST", because env, flags and the ephemeral
override layer have no file and belong to resolution, not storage.
Callers get the combined answer through the Container, which is the only component that can see both halves.
D8 — Watching is pluggable and never silently absent¶
The Store owns change detection for foreign writes, with a backend-appropriate mechanism:
fsnotify over an OS filesystem, polling over any other afero.Fs, and polling as the
required fallback for NFS/SMB/FUSE and hosts with exhausted watch descriptors.
Scope clarified by R2. The cascade this decision makes unrepresentable is the watcher loop: an own write never returns through the watcher. It was read more broadly than that. An observer that writes back cascades entirely inside the Store, and is refused rather than prevented.
A watcher that cannot function MUST fail loudly at construction. The present
implementation calls fsnotify.Add() regardless of filesystem, fails on MemMapFs, and
swallows the error at Debug level — so hot-reload is silently dead on the in-memory worktrees
keryx uses. Silent absence of a declared capability is prohibited.
D9 — Notification is exactly-once per logical change, after the swap, and fail-closed¶
Extended by R2. Two points below were stated but not implemented: a write that resolves to no change is not a logical change and MUST NOT notify (
Applylacked the guardReloadhad), and an observer MUST NOT write from within its callback — that is now refused, not merely discouraged.
- Exactly once per logical change. A multi-file
Applyemits one notification, not one per file. Own writes achieve this by construction (D4); foreign changes coalesce.Implemented by R10. The construction half shipped with the rewrite; foreign coalescing did not, and is now a settle window on the watcher path alone.
- A write that changes nothing is not a logical change. A write may still rewrite the file — reflowing comments, moving a key into the layer that should own it — without altering the resolved configuration. Observers react to configuration, so that write MUST NOT notify.
- After the swap. An observer reading the container it is handed must see the new values. Copy observers under lock, invoke outside it — the existing discipline, preserved.
- Fail-closed. A snapshot that fails to build or validate is rejected: no swap, no
observer run, last-known-good retained.
OnReloadErrorremains the separate channel for rejections and MUST NOT be conflated with the observer path. - Derived views forward registration. Observers registered on a
Subview MUST fire. Today they never do, precisely because notification was an emergent property of who held the watcher rather than an owned responsibility.
D10 — The typed-section surface is a FROZEN public contract¶
Narrowed by R4. The semantics are frozen; the parameter types are not.
ObserveSectiontakesBinderandWithSectionDefaultFunctakesObserved, so "no downstream adapter changes" does not hold. Measured port: a rename across 89 declarations plus twelve genuine call sites.
This is the module's extraction mechanism and may not churn, however much else changes.
Reusable packages declare a tiny local interface and never import config:
// go/transport/http/config.go — no config import
type ServerSettingsSource interface {
Current() *ServerSettings
}
*config.ObservedSection[T] satisfies it structurally. That is what let
go/transport/{http,grpc,gateway} and the otel core be extracted, with
go-tool-base/pkg/*/config_adapter.go as the only meeting point.
Unchanged, guaranteed: ObservedSection[T].Current() *T, Value(), Exists(),
Version(); ObserveSection(cfg Containable, key string, opts...); all five WithSection*
options including WithSectionDefaultFunc(func(next Containable) T, merge); SectionChange[T]
with change-only delivery and monotonic Version.
No downstream adapter changes. Observers still receive a Containable; only what sits
behind it changes.
Two improvements come free: observers gain snapshot consistency (D2), and a section's apply
callback can no longer run twice for one logical edit (D9).
Optimisation available, not required: the snapshot knows which subtrees changed, so
ObservedSection may skip decoding untouched sections instead of decoding everything and
discarding most of it via DeepEqual.
D11 — Backends are asymmetric interfaces with declared capabilities¶
Completed by R5. The
Watchableseam this decision names was not built with the rest; watching reached throughBackendinto a concrete unexported type instead. It now exists asWatchableBackend.
A single Backend{Load; Save} interface is a trap. Reading is fetch, parse, normalise.
Writing carries ownership, partial-vs-whole update, CAS, conflict detection,
validation-before-commit, secret handling, audit, IAM failure, eventual consistency, rollback,
and multi-key transactions most stores lack. A uniform interface either lies about those or
degrades to the weakest member.
type Source interface { // every backend
ID() string // provenance identity
Load(ctx context.Context) (Layer, error)
}
type Writable interface { // optional
Apply(ctx context.Context, cs ChangeSet) (Layer, error)
}
type Watchable interface { // optional
Watch(ctx context.Context, fn func(Layer)) (stop func(), err error)
}
type Capabilities struct {
PreservesComments bool // document-like backends only
AtomicMultiKey bool // etcd yes; SSM no
CompareAndSwap bool
NativeWatch bool // vs poll-only
Sensitive bool // e.g. Vault — never mirror into a file layer
}
The coordination logic stays in the Store for every backend — serialisation, snapshot construction, provenance assembly, notification. It must never become per-backend, or the fragility this design removes returns N times over.
D12 — Ship one backend. Define the seam; do not build the plugin system.¶
The first cut ships the local filesystem backend only.
An in-memory backend is not needed for testing. Afero already provides file-and-memory
under one interface, so the in-memory store is the file backend over MemMapFs. Building a
second backend to serve tests would earn no abstraction.
Add Writable/Watchable/Capabilities implementations when a genuinely different backend
arrives (Vault, etcd, SSM). Designing those interfaces against imagined requirements is how
such abstractions go wrong.
Four consequences to settle when a second backend lands, recorded now so they are not discovered then:
- Write routing must respect capability and sensitivity. A value sourced from a
Sensitivebackend must never route to a file layer. This is the env-secret leak in a new costume — a security property, not a preference. - The comment guarantee is document-backend-only. D5's promise must be scoped in the docs, not implied universally.
- Cross-backend atomicity is impossible. A ChangeSet spanning a file and an SSM parameter cannot be atomic. Either refuse cross-backend commits or declare them explicitly non-atomic and report exactly what landed. Do not paper over it.
- Foreign-change latency is a per-backend property (etcd: native events; SSM: polling) and must be declared. Own-write notification remains deterministic regardless.
D13 — Edit routing: reverse merge order, first writable match¶
Merge order is deterministic (lowest→highest precedence). Routing walks it in reverse and selects the first writable match: an existing key routes to the highest-precedence writable layer defining it; a new key to the highest-precedence writable layer. Callers may override with an explicit target.
Rationale: it makes an edit visible — the value written is the value read back. Writing to a base layer could leave the edit immediately shadowed.
Non-writable layers are skipped (env, flags, compiled-in defaults) and are identifiable as such.
A written-but-shadowed edit MUST be reported, not swallowed: "written to prod.yaml,
currently shadowed by env:KEYRX_DB_HOST". Correct to write, but the caller must be able to
tell the user.
Node placement is a separate rule. Within the target document: attach at the deepest existing ancestor, synthesise missing intermediates, append as the last child — leaving existing keys' comments and order untouched.
D14 — Apply is plan/apply: prepare → validate → verify → commit¶
Writes are slow, batched and correct in preference to fast. A config write is user-initiated and human-scale.
Batch per Apply, not per change. Accumulate a ChangeSet, resolve layers once, parse each affected document once, write once. A settings screen saving 50 fields must not trigger 50 routing passes.
- Prepare — build each target's new content; stage it (temp file in the same directory for the FS backend).
- Validate — D15.
- Verify — re-check every fingerprint captured at read. Any foreign change aborts the whole set with a distinguishable conflict error. Cheap and race-sensitive, so it sits closest to commit.
- Commit — atomically replace each target (rename for the FS backend), back to back.
Retain originals across commit. On a partial failure, restore already-committed targets; if restoration also fails, the error MUST name precisely which targets are in which state. A caller must never be left guessing.
Sequential, not concurrent. Parallel commits do not address the race (which is against
foreign mutators), make partial application more likely, forfeit the "everything up to N
succeeded" property, and would push per-implementation thread-safety onto every afero.Fs.
The plan is inspectable without executing — targets, shadowing warnings, and what
validation would say. That gives a CLI --dry-run and a studio the preview it needs.
D15 — Writes are schema-validated by simulating the reload they will cause¶
Validate the resolved candidate, never a layer in isolation. A single file may legitimately be invalid alone and valid once merged — a base omitting a key its overlay supplies. Layer-scoped validation would reject correct writes.
Mechanism: build the candidate snapshot the write will produce, including env and flag layers, and validate that. Because it is the same construction path a reload uses, validation and reload cannot disagree — if pre-write validation passes, the resulting snapshot will also pass. A separate validation path would eventually drift, and the failure mode is nasty: a write that validates, lands, then gets rejected by the reload it triggers, leaving the file changed and the config on last-known-good.
Reject only violations the write introduces. Validate before and after; fail only if the after-set is a strict superset. A config that is already invalid must stay editable — otherwise one missing key makes the config unfixable through the surface designed to fix it. Errors MUST distinguish "your change is invalid" from "your config was already invalid".
Schema source: the container's schema, overridable per call. No schema → no validation, without error. Validation here is deliberately decentralised, so many containers carry no schema and this is a no-op for them — expected, not a gap.
D16 — Map-valued Put replaces the addressed subtree; the caller owns the consequences¶
Put accepts scalars and maps. A map-valued Put replaces the addressed node — keys
absent from the map are removed, so removals take effect. Deep-merge was rejected: it makes
"this subtree is now exactly this" inexpressible, which is what consumers need.
Scalars-and-deletes-only is not viable: avatarcmd.go:57 does
v.Set("avatars."+name, map[string]any{…}), and every theme command routes through
Catalog.WriteTo, documented as "replacing the whole subtree (so removals take effect)".
A caller supplying a map asserts ownership of that subtree and accepts that comments, anchors and block styles within it may not survive. The library does not guess intent. This must be documented prominently — a sharp edge by design.
Measured consequence. The themes: subtree Catalog.WriteTo targets holds 2 anchors,
8 aliases, 18 comment lines and 9 block scalars. A naive replace expands each alias into a
duplicated copy, so a deliberately DRY structure silently becomes six literal copies.
Migration note: porting Catalog.WriteTo unchanged will damage the file on the first
keryx theme edit. The correct port diffs the catalog (known before and after) and issues
targeted Put/Remove calls. Remove is the primitive that makes this possible — and
the reason whole-subtree replacement existed at all, since viper exposes no
Unset/Delete/Remove/Clear at any visibility.
Upgradeable without API change: internal diffing — touching only genuinely changed values — is strictly more preserving under the same contract. Ship replace; revisit if the loss proves to matter.
D17 — Comment ownership on delete is yamldoc's, and is a dependency requirement¶
Amended 2026-07-19. These rules were specified here and are now implemented and tested in
yamldoc, since they are questions about YAML documents rather than about configuration. They remain recorded as a requirement this module places on its dependency — if a future substrate change broke them, the fidelity guarantee in D5 would break with it.
With formatting and ordering out of scope, "comments stay grouped with the right keys" is the entire fidelity guarantee — and deletion is where encoders misbehave.
| Rule | Provided by the parser? |
|---|---|
| Head comment directly above a key → removed with it | free |
| Head comment after a blank line = section comment → survives | implemented in yamldoc |
| Inline comment on the key's own line → removed with it | free |
| Trailing comment after the last key → hoisted to the preceding sibling | implemented in yamldoc |
| No bleed onto the following key's head comment | free |
Two of the five needed implementing rather than inheriting. Distinguishing "directly above"
from "after a blank line" is invisible to the parse tree — both attach identically — so
yamldoc retains the source's blank-line positions to decide. The trailing case is worse: the
parser attaches such a comment to the last entry of the block, so a naive removal of an
unrelated key destroys a comment describing the whole section.
Where ownership is genuinely ambiguous, yamldoc errs towards survival: orphaning a
comment is untidy and visible, destroying one loses the author's work silently.
Corollary for creates: appending a key to a mapping makes it absorb any trailing comment block as its head comment. Sometimes ideal, sometimes theft — same positional analysis, handled together.
Also yamldoc's, and equally load-bearing: removing the last entry of a nested mapping
must emit key: {} rather than the substrate's column-0 {}, which does not parse. See D6b
for why emptiness is a value here.
D18 — Snapshot consistency: implicit reads, pinned observers, scoped With¶
(Resolves the former O1. Decided 2026-07-19.)
Three mechanisms, chosen because the exposure is narrower than it first appears.
Reads are implicit. cfg.GetString(...) resolves against the latest snapshot. Each
individual read is atomic (a single pointer load), so no read ever sees a torn value. No API
churn — every existing call site is unaffected.
Observers receive a pinned container. When an observer fires it is handed a container bound
to the snapshot that triggered the notification, not "latest". Without this, an observer
processing snapshot N can read values from N+1 partway through its callback. This is a real
defect, and the fix is invisible: the signature is unchanged, so no consumer adapts to it.
The same applies to ObservedSection rehydration.
With provides scoped multi-read consistency.
err := cfg.With(func(pinned config.Containable) error {
host := pinned.GetString("db.host")
port := pinned.GetInt("db.port")
return dial(host, port)
})
A pinned view is a Containable — no new type, no second getter surface, and it composes
with everything that already accepts one. Scoping to a closure removes both footguns of a
bare handle: a snapshot cannot be held indefinitely (pinning parsed data in memory) and cannot
silently serve arbitrarily stale config.
Why not require pinning for all reads. It would make the straddle unrepresentable, at the
cost of changing every call site across 20 modules, go-tool-base and keryx. Disproportionate,
because the exposure is already small:
Unmarshal,UnmarshalKeyandUnmarshalSectionare single operations — they decode a whole struct from one snapshot and cannot straddle.- Therefore the entire typed-section pattern is already immune. Every extracted package
consumes
Current() *Settings, receiving one immutable struct. That is the pattern the docs already recommend, and it is the one used bygo/transport/{http,grpc,gateway}and the otel core.
What remains exposed is code making several individual Get* calls — adapters, CLI wiring,
legacy call sites — and With covers exactly that.
State the limit honestly in the docs. The default read path does not carry the
multi-read guarantee. D2's consistency property applies within a single call, within a With
block, and across a typed section. That must be documented plainly rather than implied by the
existence of snapshots.
D19 — Documentation is a deliverable, not a follow-up¶
The Diátaxis site under docs/ is part of this module's product. It MUST be revised for
the new design — not appended to — and revision is part of the definition of done for each
work item, not a subsequent task.
The reasoning developed here belongs in explanation/, not only in this spec. A spec is a
decision record for maintainers; explanation pages are how users understand why the module
behaves as it does. The single-owner argument, what it eliminates, and the measured
limitations must be readable without opening a spec.
Pages that change, and why:
| Page | Change |
|---|---|
explanation/why-a-wrapper.md |
Currently reads as a catalogue of viper traps the wrapper papers over. Several no longer apply (the Sub trap, the write path); the argument becomes single-ownership and layer-correct writes. Substantial rewrite. |
explanation/precedence-and-merge.md |
Merge model gains documents as layers (D6a), provenance (D7) and shadowing queries. The "which files contributed?" section is superseded by real provenance. |
explanation/hot-reload-safety.md |
The reload model changes wholesale: own writes construct snapshots directly, the watcher handles only foreign changes, notification is exactly-once (D4, D9). |
NEW explanation/the-store.md |
Why a single owner; what fragility it removes; snapshots and consistency (D1–D4, D18). |
NEW explanation/provenance.md |
Where a value came from, what shadows it, and why merge-eager designs cannot answer this (D7). |
NEW how-to/write-config.md |
The write surface: Put/Remove, routing, dry run, conflicts (D13–D16). |
how-to/load-and-merge.md, how-to/hot-reload.md, how-to/typed-sections.md, how-to/validate-config.md, how-to/bind-cli-flags.md, how-to/test-with-mocks.md, getting-started.md, index.md |
Construction changes (a Store is created and a Container binds to it); removed APIs; new capabilities. |
Documented limits are mandatory, not optional. Each of these MUST appear in user-facing docs, not merely in this spec, because each is a sharp edge a user can hit:
- D5's fidelity guarantee and what it does not cover; the refusal of multi-line flow collections with interior comments
- D6a's multi-document overlay semantics, stated as this module's choice, not YAML's
- D6b's empty-container-is-a-value rule
- D16's map-valued
Putsharp edge, with the anchor/comment loss spelled out - D18's caveat that the default read path does not carry the multi-read guarantee
- D3's in-process-only serialisation, and that cross-process races rely on conflict detection
Migration documentation is required, covering the removed APIs, the Catalog.WriteTo-style
port (D16), and what downstream consumers must change — with the explicit statement that the
typed-section surface does not change.
D20 — Tests are written in advance; unit and BDD both mandatory¶
Implementation is test-first. A behaviour without a failing test that describes it is not ready to be built.
Unit tests cover pure logic: the document layer (parse, locate, mutate, re-emit), comment ownership (D17), merge and precedence, routing (D13), provenance (D7), empty containers (D6b), validation (D15).
Godog scenarios cover behaviour that spans components and time — where a Gherkin scenario genuinely earns its keep over a unit test:
- write → snapshot → notification, end to end (D4, D9)
- reload lifecycle: fail-closed rejection, last-known-good retention,
OnReloadError(D9) - exactly-once notification for a multi-file, multi-document
Apply - concurrent
Applycalls, conflict detection, partial-failure restoration (D3, D14) - watcher behaviour across filesystems, including the polling fallback and loud failure (D8)
- observer propagation, including derived views and the frozen typed-section contract (D10)
This module has no godog today; go-tool-base and keyrx do. Follow the house
convention: features/<area>/*.feature, tagged, with the feature docstring naming the spec
section it covers. Use the same godog version as the sibling modules.
Every numbered acceptance criterion in this spec MUST map to at least one test or scenario, and the mapping must be traceable — a reviewer should be able to confirm coverage without inference.
Adversarial fixtures are mandatory, not corpus-only. The corpus validated the common path and missed two corruption cases: multi-line flow collections with interior comments, and deleting the last key of a nested mapping. Both were found only by constructing the case deliberately. Real-world fixtures prove it works; adversarial fixtures find where it breaks, and both are required.
The spike's fixtures are the starting suite. They already encode every measured failure mode — comment-ownership rules, type-aware scalar dispatch, flow style, multi-document, empty containers — and must be carried into the module's tests rather than rewritten.
Run under -race. Concurrency is load-bearing in D3, D9 and D14.
Breaking changes¶
Carte blanche was granted; these are deliberate.
| Change | Reason |
|---|---|
| Construction: a Store is created and a Container binds to it | D1 |
WriteConfigAs removed from Containable |
D6 — viper has no file; the method's semantics were the env-secret leak |
GetViper() removed |
Incoherent once viper is a resolution engine over a snapshot; returning it exposed a live internal that made the abstraction fictional |
NewContainerFromViper removed |
Replaced by construction from a Store or an in-memory Layer |
Sub() semantics restated against snapshots |
Closes the still-open half-trap where Write/Dump/ToJSON/Validate read a stale sub-snapshot |
SetTypeByDefaultValue deleted |
Verified dead code — only acts when v.defaults is populated, and SetDefault is never called |
Not breaking, guaranteed: the entire typed-section surface (D10), and the Containable
read surface.
Ecosystem impact is bounded: GetViper() is reached for exactly five distinct methods across
all repos (Set, MergeConfig, GetStringSlice, ConfigFileUsed, AllSettings), three of
which become first-class methods. The larger downstream task is keryx's ~27 *viper.Viper
signatures, concentrated in the write-back paths this design replaces — so they are ported,
not merely retyped.
Defects resolved structurally¶
All four are eliminated by construction, not fixed.
| Defect | Why it cannot occur |
|---|---|
Reload discards flag bindings and Set overrides |
The Container rebuilds from a snapshot and re-applies its own bindings; nothing rebuilds a viper from files behind its back (D6) |
Hot-reload silently dead on a non-OS afero.Fs |
Watching is the Store's, with a pluggable mechanism that fails loudly when unavailable (D8) |
| Sub-container observers never fire | One notification path, with derived-view forwarding required (D9) |
WriteConfigAs writes env-sourced secrets into files |
Viper never sees a file; there is no AllSettings() write path (D6) |
Multi-document files silently lose everything after the first --- (pre-existing; viper reads document 1 and discards the rest with no error) |
Fixed, not merely refused. The Store parses every document and merges them as ordered layers before viper sees anything (D6a) |
Empty maps are silently deleted on write (pre-existing; WriteConfigAs serialises AllSettings(), which omits key: {}) |
The Store writes documents, not AllSettings(). An empty container is a value and survives (D6b) |
Acceptance criteria¶
Fidelity
- A file with head, inline and foot comments survives a
Puton one key: the value changes and every comment remains attached to the same key. Blank lines, key order and indentation are not asserted. - Repeated writes converge — no drift or accretion.
- Merge keys, folded and literal block scalars, anchor/alias pairs and astral-plane emoji survive a write unmangled. Every character a reader can see survives verbatim. Invisible and bidirectional-control characters are instead emitted as escapes — losslessly, and deliberately: see R3.
Removedeletes a key and its subtree with no residue, obeying D17's five rules — including hoisting a trailing comment rather than destroying it.- Creating a key attaches at the deepest existing ancestor without disturbing sibling
comments or order.
5a. Comments around and on single-line flow collections survive a write, including a flow
mapping carrying an anchor; edits and deletes on siblings of a flow node leave its comments
and anchor intact.
5b. A document containing a multi-line flow collection with interior comments is refused
at load with an error naming the location — never partially written, never written
invalid.
5c. A multi-document file contributes every document as an ordered layer; a value defined
in a later document overrides the same key in an earlier one. Nothing is silently
discarded.
5d. Editing inside one document of a multi-document file leaves every other document, every
separator and every comment untouched.
5e. Deleting the last key of a nested mapping produces valid YAML (
key: {}), not a column-0{}; the parent key is not cascade-deleted. 5f. An empty map or empty sequence present in a source file survives a write unchanged and is never silently removed — including when the write targets an unrelated key. 5g. A map-valuedPutwith an empty map sets the subtree to{}; it does not remove the key. 5h. The Store's key enumeration includes keys whose value is an empty container, and excludes null-valued keys the getters report as unset — so validation's unknown-key detection cannot disagree with the getters.
Layering and provenance
- Writing to one layer pulls in no value contributed by another layer, defaults, env or flags — verified with a lower-layer key, an env-overridden key, and a bound changed flag.
- Provenance answers, for any key, which source supplied the effective value and which
others define it:
Originnames the winner,Shadowedlists every layer defining it, andExplainrenders the chain. A caller wanting file-only provenance filtersShadowedonKind == SourceFile. Amended by R4. - An edit that remains shadowed after writing is reported as such, naming the shadowing source.
- Repeated saves from a long-running surface route to the originating file every time; the source is never progressively shadowed by an accumulating overlay.
Consistency and concurrency
- A sequence of reads inside a
Withblock observes no mixture of pre- and post-reload state, even when a reload lands mid-block. 10a. An observer that reads several keys observes only the snapshot that triggered it, even when a further reload lands mid-callback. 10b. A singleGet*call never returns a torn or partially-updated value. - Two concurrent
Applycalls do not lose updates: one succeeds and the other either succeeds after it or fails with a distinguishable conflict — never silent last-writer-wins. - A foreign modification between routing and commit produces a conflict error and leaves every target unmodified.
- A commit failure partway through a multi-target set restores the already-committed targets; if restoration also fails, the error names precisely which targets are in which state.
Lifecycle
- A multi-file
Applyemits exactly one notification, and none mid-commit. - Hot-reload functions on a non-OS
afero.Fs; a watcher that cannot function fails loudly at construction rather than silently. - An observer that writes config on notification does not produce an unbounded cascade.
- Flag bindings survive a reload, and a layer added at runtime through
Store.AddLayersurvives one too. Amended by R4. - (Withdrawn — see R4. A derived view is a read scope, not an observable.)
- A rejected snapshot swaps nothing and notifies no observer; the rejection reaches
OnReloadError.
Validation
- A write making the resolved configuration invalid is rejected before any target is modified; a write passing pre-commit validation never triggers a rejected snapshot.
- A write to a file invalid in isolation but valid once merged is accepted.
- A write to an already-invalid configuration succeeds when it introduces no new violation, and a genuine failure distinguishes the two cases.
- A container with no schema writes without validation and without error.
Contract
- The typed-section semantics are unchanged (D10) and every consumer ports mechanically:
verified by compiling the real
config_adapter.gofiles against this module and by a migration document covering the parameter-type change. Amended by R4.
Delivery
24a. Every acceptance criterion above maps to at least one unit test or godog scenario, and
the mapping is traceable without inference (D20).
24b. The test suite includes both the real-config corpus and adversarial fixtures for each
measured failure mode, and passes under -race (D20).
24c. Every Diátaxis page listed in D19 is revised, the new pages exist, and each documented
limit appears in user-facing docs rather than only in this spec (D19).
24d. Migration documentation covers the removed APIs and the consumer-side port, and states
explicitly that the typed-section surface is unchanged (D19).
25. Everything above holds against an in-memory afero.Fs.
Non-goals¶
Split deliberately: some of these are permanent (they conflict with the design's premises) and some are deferred (excluded from this cut, expected to be revisited once there is a working implementation to battle-test). A future reader must not treat the second group as decided against.
Permanent — these conflict with the design¶
- Byte-identical round trips; preserving blank lines, key order, indentation or comment alignment. Out of scope by the stated goal, and pursuing it costs disproportionately (see Rejected alternatives).
- A general-purpose YAML editing library. Comment-preserving editing serves configuration; it is not a product.
- Emitting the merged/resolved view from any API. This is the defect the design exists to remove; re-adding it under any name would reintroduce the env-secret leak.
- Unifying the document and value models (see Rejected alternatives).
- Cross-process locking. Not portably achievable without lock files; D14's fingerprint check is the stated defence and the limit is documented rather than hidden.
Deferred — revisit after the first implementation is battle-tested¶
- Additional backends (Vault, etcd, SSM, ConfigMaps). D11 defines the seam; D12 explains why the plugin system is not built speculatively. This is the most likely of these to become a goal.
- Cross-backend transactional writes. Impossible to make atomic today (D12.3), but if multi-backend use arrives, "refuse" versus "declare non-atomic and report precisely" is a real decision to take then.
- An ephemeral
Container.Unset.Sethas no inverse and viper supplies none to build on. Left open because every consumer inspected wants persisted removal, whichRemoveprovides. Recorded shape if demanded: a tombstone in the ephemeral override layer — needs nothing from viper, but every read path must consult it, andUnmarshal,UnmarshalKey,AllSettings,ToJSONandValidatebypass the accessors, so a partial implementation reproduces the getters-disagree defect. - Internal diffing for map-valued
Put(D16). Strictly more preserving under the same contract, so it can be added without an API change if the comment/anchor loss proves to matter in practice. - Skipping decode for unchanged subtrees in
ObservedSection(D10). A performance property, available once the snapshot tracks per-subtree change. - Creating or removing whole documents within a multi-document file. Documents are read and written as layers (D6a), but the set of documents in a file is fixed by the file.
- Requiring pinned reads everywhere (D18). Would make the multi-read straddle
unrepresentable; currently disproportionate, but reconsider if
Withproves under-reached-for in practice. - Remote config as a coordination layer (as distinct from D11/D12 backends).
- A consumer-facing diagnostics command.
View.Explainalready answers "what is this value, where did it come from, and what else defines it" in one line:
That is the first question of every configuration debugging session, and today it is
answered with grep. Surfacing it as a command in the toolkit's CLI framework — something
of the shape <tool> config explain db.host, plus a whole-config variant listing every
key with its winning source — would put it in front of the people who need it rather than
leaving it as an API only library authors find.
Deferred rather than dismissed: the data is already there and the library work is done, so this is a presentation decision belonging to whichever CLI adopts it first. Worth revisiting once a consumer is wired up and there is a real command surface to hang it on.
Open questions¶
None. O1 resolved as D18; the flow-style verification is complete and folded into D5.
Rejected alternatives¶
Adding a writer beside the existing container. The previous design. Rejected for the fragility it required: a commit lease, fingerprint checks, feedback-loop breaking and index invalidation, all consequences of three components sharing the filesystem with no owner.
Replacing viper. Considered at length this session and rejected — reversed by
R1; viper was removed. The
original reasoning, kept because the first two clauses still hold: no Go library exceeds ~42%
of a greenfield requirement set, none combines provenance with layer-correct writes, and the
replacement is an estimated 8,000–12,000 LOC. D6 makes it a contained future change rather
than a prerequisite.
The estimate was the error. It priced replacing viper whole, but D6 had already made moving merge, precedence, provenance, watching and writing into the Store a prerequisite. The residue was 781 LOC over two libraries already imported directly. See R1.
Unifying document and value models. Retaining comments in the read-path value model. No
system in any language does this successfully: Rust's toml was built on toml_edit and
unwound in v0.9 for a 542µs→267µs parse win; ruamel reverted a decoupling attempt in six weeks
and pays 15–30×; HCL states the rationale in-source. Decisive here: env, flag and default
layers have no document at all.
A single symmetric Backend interface. See D11.
Byte-span splicing and the blank-line placeholder technique. Both existed only to
deliver byte-identity, which is not a requirement. Splicing is also expensive: goccy's
Position.Offset is a 1-based rune offset that drifts −1 per preceding comment, and no
candidate library exposes an end position, so a splice writer must maintain its own line
index.
Explicit separate write targets (a studio saving to .keryx.local.yaml rather than the
source). Rejected: overlay accumulation defeats layering. A surface editing repeatedly and
always writing to an overlay causes that overlay to progressively shadow the source, until the
hand-authored file's comments annotate values nobody reads. Edit-in-place is correct for any
repeated-edit surface. Every candidate use case was checked and none survived.
Appendix — measured findings¶
All measured on this machine unless noted.
Note (2026-07-19). The findings about the YAML substrate — scalar type dispatch, comment ownership, the empty-mapping emission defect, flow-style corruption, multi-document handling — now belong to
yamldocand are recorded in its spec and encoded in its tests. They are retained here because they justify decisions in this document (D5, D6a, D6b, D17) and because a reader deciding whether the dependency is sound should not have to leave to find the evidence.
| Finding | Bears on |
|---|---|
Corpus spike over 8 real files (incl. the live blog .keryx.yaml and a 232-line/60-comment keryx template): no-op round trip preserves every comment attached to the same key — 81/81 raw comment references, 111/111 keys on the largest |
D5 |
| Idempotency: converges at pass 1 (or 0 where input was already stable). No drift | D5 |
| Targeted set × 4 real operations: 1 line changed vs the re-emitted baseline in every case; inline comments survive; quote style preserved | D5 |
| D17 rules: 3 of 5 free, 2 require positional analysis | D17 |
Scalar setting must be type-aware. goccy renders StringNode from its own Value field but Integer/Float/Bool from Token.Value. Mutating only the token — the advice from an earlier survey — silently no-ops for strings, the most common config value type. Observed: the same setter changed enabled: false→true correctly while leaving log.level and content.profile untouched |
implementation |
Path.ReplaceWithNode destroys the node's inline comment; mutate in place |
implementation |
A freshly-parsed node appended to a mapping lands at column 0; copy a sibling's Position.Column/IndentNum/IndentLevel |
D13 |
yaml/v3 turns 99999999999999999999 into float64(1e+20) irrecoverably; threshold is uint64 overflow (~1.8e19), so re-tests must use a value above it |
D5 |
yaml/v4 rc.6 fixes merge keys but escapes astral-plane characters unconditionally (WithUnicode covers BMP only), and that escaping cascades: a literal block containing an emoji is rewritten as a double-quoted string. No stable release |
D5 |
| goccy adds ~0.5 MB to a binary; CUE 14.4 MB; HCL 4.2 MB | D5 |
Flow style, measured (2026-07-19). 4 of 6 fixtures pass. Comments around flow nodes, single-line flow mappings with anchors (the real voice: &voice {…}), single-line flow sequences, and edits/deletes adjacent to flow nodes all preserve comments and anchors. The 2 failures are multi-line flow collections with interior comments, and the failure is corruption, not loss — the closing }/] is swallowed into the comment, producing YAML that both goccy and yaml/v3 reject |
D5 |
Multi-line flow collections occur zero times across the blog, keyrx, go-tool-base and all 20 go/* modules. Single-line flow appears in 30 files |
D5 scope |
Multi-document read, measured (2026-07-19). goccy passes 4/4: documents and per-document comments preserved, leading --- retained, and a --- inside a block scalar correctly not treated as a separator. Viper silently reads only the first document and discards the rest with no error |
D6a |
| Multi-document write, measured. Editing inside document 0 and document 1 of a 3-document fixture each produced only the intended change: other documents byte-untouched, all separators intact, 6/6 comments retained, clean re-parse | D6a |
Deleting the last key of a nested mapping emits invalid YAML — {} at column 0, re-parse fails with "unexpected map key". Deleting one of several keys is fine; deleting the only root key is fine. Found only by constructing the case deliberately; every corpus deletion left a non-empty parent |
D17 |
Viper is inconsistent about empty containers. emptymap: {} — IsSet true and Get returns an empty map, but the key is absent from both AllKeys() and AllSettings(). Conversely nullval: null and a valueless key: are listed by AllKeys() while IsSet reports false. Empty lists are consistent; empty maps are not. Consequence: the old WriteConfigAs (which serialised AllSettings()) silently deleted empty maps on every write |
D6b |
Zero real config files across the blog, keyrx, go-tool-base and all 20 go/* modules contain a document separator — so multi-document support is a capability gain, not a compatibility need |
D6a |
| goccy carries 189 open issues, including the flow-style comment-fidelity bugs above (#821, #820, #870) | D5 risk |
fsnotify.Add() fails on MemMapFs; the error is swallowed at Debug, so reload is silently dead on in-memory filesystems |
D8 |
fsnotify has documented polling as "not yet" since 2012; radovskyb/watcher hardcodes os and cannot observe an afero FS |
D8 |
No Go config library performs atomic writes — grep os.Rename\|CreateTemp across 19 candidates returned zero non-test files |
D14 |
| Seven Go libraries offer per-key provenance; all are read-only. Every library that writes serialises the resolved view. The sets do not intersect | D7 |
viper exposes no Unset/Delete/Remove/Clear; Set writes into the unexported v.override with no inverse |
D16, non-goals |
The themes: subtree holds 2 anchors, 8 aliases, 18 comment lines, 9 block scalars |
D16 |
No verifications outstanding. Both flagged risks were measured and neither resolved as expected:
- Flow style corrupts rather than degrades — the closing delimiter is swallowed into the comment — so the narrow failing construct is refused at load (D5).
- Multi-document turned out to be sound in goccy and broken in viper, which made it a capability to gain rather than a risk to avoid (D6a).
The same spike also surfaced an unrelated corruption case on the mainline delete path (nested mapping emptied → invalid YAML, D17), which the corpus had not exercised because every real deletion left a non-empty parent. That is the argument for constructing adversarial cases rather than testing only against real data.