keryx has a studio, and the studio saves as you work. Change a setting, it goes to disk, the app picks it up and carries on. Nothing exotic about that. Every desktop app written in the last thirty years does the same thing.
So keryx put in a request against go/config, which had only just been extracted into its own module and was feeling rather pleased with itself. The ask was boring to the point of being polite: when you write the file back out, keep the comments the human wrote, keep the key order, keep the formatting, and only touch the file that actually owns the key I changed. Nobody sensible rewrites a config library over comment preservation.
What “save your config” was really doing
Here’s the problem with a config library that merges layers for you. By the time it can answer a question, it has already thrown away the one thing you’d need to write an answer back.
Viper resolves your configuration down into a single view. Defaults at the bottom, then the file, then environment variables, then flags, each one covering the last. For reading that’s exactly right. You ask for server.port, you get the winner, and nobody has to care where it came from. Then you ask it to save. And it writes out the view. Not your file. The view.
Two things fall out of that, and the second one made me put my coffee down. Every comment the author wrote is gone, because comments were never in the merged view to begin with. And every value that came from somewhere else is now sat in your file, promoted to a permanent fact on disk. Defaults you never chose. Flags from one invocation last Tuesday. And environment variables.
So a user exports an API token, runs your app, your app helpfully saves its configuration… and you have written their token into a file in plain text. Nobody asked for that. Nothing warned them. The save button did it. I’ve banged on before about how everyone reads your config and how a configurable AI endpoint is an attack surface, and this is the same beast wandering in through a door I’d never thought to check.
And you can’t wrap your way out of it. The thing you need, which is which layer did this value come from, gets discarded during the merge, long before anybody contemplates a write. It isn’t hidden away somewhere awkward. It doesn’t exist.
The question I didn’t want to ask
I went into this fond of Viper and fully intending to keep it. It had done us proud for years and I had no appetite whatsoever for a rewrite in the middle of an extraction. Find the right place to hook in, bolt a writer alongside, done.
An hour in, I asked myself the question that ended that plan. Was Viper actually the problem here? Were we tying ourselves in knots honouring its contract, when what we fundamentally needed was a thing it was never going to give us?
Then, because I didn’t remotely trust myself to answer that fairly, I rigged the test in Viper’s favour. Greenfield exercise. Pretend there’s no existing code and no incumbent, write down everything this module needs from a config component, and only then go back and count how many of them Viper covers. I expected about 70%. More to the point, I wanted about 70%, because 70% is a lovely comfortable number that lets you keep what you’ve got and paper over the rest.
It came out at 20%.
That needs a caveat or it’s just unfair. Viper’s own docs reckon it does about 95% of what most projects need, and I’d say that’s honest. Both numbers are true at once. It’s excellent at the common case, and this module had walked itself somewhere distinctly uncommon by deciding that writing files properly was a first-class feature rather than a hack bolted on the end.
Record it on the way through
The fix falls straight out of the diagnosis. If the problem is that provenance gets binned during the merge, then record it while you merge.
So go/config grew a Store that owns configuration I/O end to end. It layers the sources itself, and as it goes it keeps track of which layer every value came from. Reading is View(), which hands back resolved answers exactly as before. Writing is Apply(), which takes a set of changes and, crucially, can work out where each one belongs.
Change a key that came from the project file, and the project file gets edited. A value that arrived from the environment was never yours to persist, so it doesn’t get written anywhere at all.
The editing goes through a document layer that rewrites bytes in place rather than re-encoding, which is what saves the comments and the ordering. Where a change spans several files they commit one at a time, each writing to a temp file and renaming it over the target, with a rollback if a later one falls over. Sequential and boring, deliberately. There was a fan-out design floating about at one point that would have made it concurrent and exciting, and I’m glad it never shipped.
Then, somewhere near the end of all that, I looked at what Viper was actually still doing.
Nothing. Not a thing. Everything left had already been taken over: the Store did layering, resolution and precedence, the codecs did formats, the document layer did writes. Whatever remained was being handled directly, and rather better, by the two libraries Viper wraps. It was a box on the diagram with no arrows coming out of it. I sat there for a few seconds going, hang on… Viper’s gone? Nobody decided to remove it. It just ran out of reasons to be in the building.
I never actually decided to
I opened that session having explicitly ruled out replacing Viper. First path considered, first one rejected, on the entirely sensible grounds that it was wildly disproportionate to a request about preserving comments. I closed it having replaced Viper, in commit 1f5129c, without ever actually deciding to. It wasn’t a decision. It was a consequence. Solve the provenance problem properly and the dependency stops earning its keep all by itself.
For the record, Viper went and its neighbours stayed. afero, cast, pflag and mapstructure are all still sat in that go.mod doing jobs they’re good at. This was not a purge, and anyone who tells you they removed a whole ecosystem in an afternoon is describing their go.mod, not their code.
The bit that still nags
The rewrite was the interesting work and the reversal is the better story, but neither is the thing I keep coming back to.
It’s that the real bug was never “the writer doesn’t preserve comments”. It was “saving your config can write your secrets to disk”, and it had been sat there the whole time, in a library I’d used happily for years, down a code path I’d simply never had cause to walk. It took a feature request from an unrelated project, about comment preservation of all things, to march me into it. I only found it because keryx wanted its settings file to stay tidy.





