We fixed it one layer ABOVE where the error died, so the fix cannot fire. Verified: the flag it sets is never set.
It is not 'Rust rejects, Python accepts'. Same serde_yaml version: into a Mapping it errors, into a HashMap it silently keeps the last key.
A swarm run went red and the settings it used were not the settings in the file. The config asked for one planner model and permission to load a model; the run used a different planner and refused to load.
I spent the evening downstream of that, looking at how settings were applied. The cause was a key named twice, and the reason it took so long is more interesting than the typo: three separate layers had the error, and each one made it slightly less visible than the last.
The file
Two different things write to this config — a desktop app maintains one block, and a script appended another. Nothing stops them colliding, and eventually they did:
The swarm block is well formed. It is also irrelevant, because of how the file is read: the loader deserialises the whole document into a mapping before anything asks for a key. One duplicate anywhere and the document does not parse, so no key can be fetched — including keys in sections that are perfectly fine.
That much I expected to find. What I did not expect was how the error travelled.
Layer one: the parse fails, correctly
serde_yaml refuses the file, and the message is exactly right:
text
1duplicate entry with key "mlx_engine"
Filename, problem, fix — everything you need is in that string. It is the last time anyone sees it.
This is the whole problem, and it does not look like a problem. The error is caught, it is logged with its full text, the layer is skipped — and the function returns Ok.
From here on there is no error. There is a config that contributed nothing. Every subsequent lookup returns NotFound, which is exactly what you get from a user who has never configured anything, and the two are now indistinguishable to every caller.
Layer three: the plausible default
The consumer does what you would do with a NotFound:
.ok(), then defaults. Which is correct behaviour for an unconfigured user, and a lie for this one. The run picked up qwen/qwen3.6-27b — the default planner, a 27B on the fleet — instead of the local alias the file asked for, and allow_model_load came back false against a file that said true.
Nothing failed. A process read a config file it could not parse and continued with settings nobody had chosen.
It did tell me. 432 times.
This is the part I got wrong when I first wrote this up, and it is the part worth keeping.
I had written that nothing anywhere reported the problem. That is false. The run's log file contains 432 warning lines, each one reading:
text
1Failed to load config "/Users/…/.config/goose/config.yaml": Failed to deserialize
2value: duplicate entry with key "mlx_engine". Skipping.
Four hundred and thirty-two chances, every one of them naming the file and the key. I saw none of them, because the CLI's logging is configured console: false — logs go to a JSON file and nothing else. The diagnosis was written down, in full, in a place that is only opened once you already suspect something.
This is the second time the same shape has cost me a day here. The first was a shell pipeline where pytest | head exited 0 while nothing ran — an exit status discarded by a pipe instead of a match arm, with the same result. An error that is detected, correctly described, and delivered to a channel nobody is watching during the failure is operationally identical to one that was never raised. That is not the logger's fault. It is a routing decision, and it was made long before this bug existed.
And the fix does not fire
We shipped a fix: record the parse error instead of dropping it, and carry a config_parse_error field into the run's settings echo so the failure banner names it. That echo exists because of earlier work on making the swarm predictable — every run states its own resolved configuration into the log, which is how the drift was visible at all.
The banner plumbing works. The flag is never set.
By the time the swarm's loader runs, there is no parse error to record — layer two consumed it and handed on a clean Ok. What arrives is NotFound("swarm"), which carries no filename and no reason, and which the post-fix code correctly routes to its deliberately silent arm, because a genuine absence should be silent. The fix was applied one layer above the place the error dies, and it is invisible in testing precisely because it looks correct.
The repair that would work is smaller and lives at layer two: Config::load already has the error in hand. It needs to keep it, not just print it.
Two things I had wrong about YAML itself
I assumed this was a language split — that Rust rejects duplicate keys and Python accepts them. Neither half survives contact with a terminal.
Same library, same version, same file:
text
1serde_yaml 0.9.34, one file, four target types:
23 -> serde_yaml::Mapping Err — duplicate entry with key "mlx_engine"
4 -> serde_yaml::Value Err — duplicate entry with key "mlx_engine"
5 -> HashMap<String, Value> Ok — accepted, last key wins, no warning
6 -> BTreeMap<String, Value> Ok — accepted, last key wins, no warning
goose errors only because it deserialises into a Mapping. The same call into a HashMap takes the last key and says nothing. And serde_yaml before 0.9.4 accepted the file too, so this is a behaviour that arrived in a patch release.
On the Python side, PyYAML does silently keep the last key — measured across 3.13 through 6.0.3, including the libyaml-backed loader — but ruamel.yaml raises DuplicateKeyError by default. So "Python accepts" is a PyYAML fact, not a Python one.
The specification is firmer than any of them. YAML 1.2.2 says mapping keys must be unique and lists non-unique keys among the loading failure points, each of which "results with an incomplete loading". YAML 1.1 goes further: "it is an error for two equal keys to appear in the same mapping node", and the only recovery it sanctions is to ignore the second pair and issue a warning. What no version does is require rejection — which is why the permissive parsers are not violating a MUST-reject rule. They are simply not doing the warning either.
If you want to catch this in CI
I would have recommended yq here, and I checked before writing it down. It does not work:
text
1$ yq . config.yaml # the file with the duplicate
2exit 0 # and the JSON it prints contains "mlx_engine" twice
Exit 0 on the same file with the duplicate block removed, so it is discriminating rather than just failing. js-yaml throws on it too, by default.
[[takeaways]] The duplicate key was a typo between two writers. What cost the evening was an error that got quieter at every layer: named precisely by the parser, converted into an absence by the loader, and turned into a reasonable-looking default by the consumer — while being written out in full 432 times to a file with console output switched off.
The design rule I would take from it is narrow and worth having: a fallback to defaults must be able to tell "you configured nothing" from "your configuration is unreadable", and must be loud about the second. Ours could not, and we then fixed it at the layer that had already lost the distinction.
And when a fix depends on an error still being in flight, check that it actually arrives. Ours sets a flag that nothing ever sets.