The config that got baked into every vault, forever
Greenhouse has a plant metaphor running through it. Ideas are “Seeds,” they go “Germinating,” a project moves through “Sprouting,” “Growing,” “Budding,” all the way to “Fruiting.” Every one of those words lived in a Rust struct and got serialized straight into each vault’s config.yaml the moment the vault was created.
That sounds harmless until you think about what it means for anything that isn’t the current plant template in the current language. I want to eventually offer Spanish and Portuguese translations, and separately, I’ve been holding onto the idea of a template that swaps the plant words for the exact stage names a specific creative-process teacher already uses with his students. Neither of those is a UI change. Both of them are a per-vault data migration, because every vault ever created had already written “Sprouting” to disk in English, permanently, and nothing was reading a lookup table instead.
The fix was to stop treating those words as data. Stage names and the plant dressing terms are now derived at read time from a stage’s id, through one function, every time. They’re marked #[serde(skip)] on the Rust struct, so they don’t get written to config.yaml and they don’t get read back from it either, even if an old vault already has them sitting there from before this fix. I decided that old, stale values just get ignored going forward. No migration script, no rewriting existing files. A future template swap or a language change is now a one-function edit, not an every-vault problem.
While I was in there, I found a second, smaller version of the same class of bug. The dashboard’s cards were guessing a stage’s display name by capitalizing its raw id (humanizeStage), completely bypassing the real config, while the project detail view used the actual configured name. Those two agreed today only because the default template’s ids happen to capitalize into the same words as their names. A custom template would have shown “S1” on one screen and something readable on the other. Same root cause, stage names treated as improvised strings instead of one real source of truth, just showing up in two different layers of the app instead of one.
The part I’m most glad I did before calling this done: my first pass at test coverage all passed, and none of it would have caught a severed wire. Every mock I’d written fed the app either an empty stage list or stage names that happened to be identical to what the old broken fallback already produced. A second look at the diff pointed out that the bug I was fixing is, by definition, only visible when the real config name differs from the guessed one, and none of my tests exercised that case. I added tests that deliberately use a stage name that diverges from the id, then mutation-tested them by temporarily ripping the wiring back out and confirming they actually go red. They did. Green tests that can’t fail aren’t tests, they’re decoration.
Related reading
The errors were never the problem. The strings were.
Every error Greenhouse showed users started life as a hand-formatted English string in Rust. The fix: send the shape of the problem over the wire, not a sentence about it.
Two notes nobody ever saw
Greenhouse saved a capture note and a handoff note faithfully, and showed neither: one had no field in the wire type, the other had a working command nobody called.
The scan that offered to import itself
An import scanner that found the app's own scaffolding, then re-offered a folder it had just imported - two versions of the same missing conversation between layers.